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)]
141 pub upstream_api_key_env: Option<String>,
142 #[serde(default)]
145 pub upstream_api_key_file: Option<String>,
146 #[serde(default = "default_auth_header")]
149 pub upstream_auth_header: String,
150 #[serde(default = "default_auth_scheme")]
153 pub upstream_auth_scheme: String,
154 #[serde(default)]
159 pub upstream_authorization_passthrough: bool,
160 #[serde(default)]
175 pub ignore_client_authorization: bool,
176 #[serde(default)]
179 pub tool_upstream_bearer_env: Option<String>,
180 #[serde(default)]
183 pub tool_upstream_bearer_file: Option<String>,
184 #[serde(default)]
187 pub require_identity: bool,
188 #[serde(default)]
196 pub allow_wildcard_bind: bool,
197 #[serde(default = "default_audience")]
199 pub audience: String,
200 #[serde(default)]
202 pub identity_jwks_url: Option<String>,
203 #[serde(default = "default_jwks_refresh")]
205 pub identity_jwks_refresh_s: u64,
206 #[serde(default)]
208 pub identity_allowed_issuers: Vec<String>,
209 #[serde(default)]
211 pub identity_hmac_secret_file: Option<String>,
212 #[serde(default = "default_hmac_kid")]
214 pub identity_hmac_kid: String,
215 #[serde(default = "default_max_delegation_depth")]
218 pub max_delegation_depth: usize,
219 #[serde(default)]
224 pub identity_human_subject_pattern: Option<String>,
225
226 #[serde(default)]
244 pub enforce_identity_scopes: bool,
245 #[serde(default = "default_chat_scope")]
247 pub chat_scope: String,
248 #[serde(default = "default_close_scope")]
250 pub session_close_scope: String,
251 #[serde(default = "default_promote_scope")]
253 pub session_promote_scope: String,
254 #[serde(default = "default_workflow")]
257 pub default_workflow: String,
258 #[serde(default = "default_consequential_tools")]
261 pub consequential_tools: Vec<String>,
262 #[serde(default = "default_tool_schema_dir")]
264 pub tool_schema_dir: Option<String>,
265 #[serde(default = "default_true")]
267 pub require_tool_schema: bool,
268 #[serde(default = "default_payout_field")]
275 pub payout_field: String,
276 #[serde(default = "default_wasm_policies")]
278 pub wasm_policy_paths: Vec<String>,
279 #[serde(default = "default_idle")]
281 pub session_idle_close_s: u64,
282 #[serde(default = "default_spool")]
284 pub atif_spool_dir: String,
285 #[serde(default = "default_bridge")]
287 pub bridge_data_dir: String,
288 #[serde(default = "default_bridge_backend")]
290 pub bridge_backend: String,
291 #[serde(default = "default_bridge_manifest")]
293 pub bridge_manifest_path: String,
294 #[serde(default)]
302 pub bridge_endpoint: Option<String>,
303 #[serde(default = "default_state_backend")]
305 pub state_backend: String,
306 #[serde(default)]
309 pub state_endpoint: Option<String>,
310 #[serde(default = "default_embedder_backend")]
312 pub embedder_backend: String,
313 #[serde(default)]
315 pub onnx_model_path: Option<String>,
316 #[serde(default)]
318 pub onnx_tokenizer_path: Option<String>,
319 #[serde(default = "default_onnx_dimension")]
321 pub onnx_dimension: usize,
322 #[serde(default = "default_vector_backend")]
324 pub vector_backend: String,
325 #[serde(default)]
327 pub qdrant_url: Option<String>,
328 #[serde(default = "default_qdrant_collection")]
330 pub qdrant_collection: String,
331 #[serde(default = "default_channel_cap")]
333 pub worker_channel_capacity: usize,
334 #[serde(default = "default_mcp_concurrency")]
346 pub mcp_concurrency: usize,
347 #[serde(default)]
349 pub strict_stage_budget: bool,
350 #[serde(default)]
352 pub breaker: av_loopdetect::BreakerConfig,
353 #[serde(default = "default_compression")]
355 pub compression_enabled: bool,
356 #[serde(default)]
358 pub budget: av_state::BudgetSpec,
359 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub principal_budget: Option<av_state::BudgetSpec>,
378 #[serde(default)]
381 pub allow_anonymous_principal_budget: bool,
382 #[serde(default = "default_reconcile_tick")]
384 pub reconcile_tick_s: u64,
385 #[serde(default, skip_serializing_if = "Option::is_none")]
394 pub atif_retention_days: Option<u32>,
395 #[serde(default = "default_max_request_bytes")]
404 pub max_request_bytes: usize,
405
406 #[serde(default = "default_dashboard_enabled")]
419 pub dashboard_enabled: bool,
420
421 #[serde(default)]
434 pub allowed_hosts: Vec<String>,
435
436 #[serde(default)]
441 pub backends: Vec<BackendConfig>,
442
443 #[serde(default)]
449 pub require_private_backends: bool,
450
451 #[serde(default = "default_tool_upstream_transport")]
457 pub tool_upstream_transport: BackendTransport,
458
459 #[serde(default)]
466 pub mcp_allowed_origins: Vec<String>,
467
468 #[serde(default)]
472 pub intent_map: std::collections::BTreeMap<String, String>,
473
474 #[serde(default)]
478 pub require_intent_mapping: bool,
479
480 #[serde(default)]
484 pub mission: Option<MissionConfig>,
485
486 #[serde(default)]
491 pub missions: Vec<MissionConfig>,
492
493 #[serde(default = "default_intent_token_ttl")]
496 pub intent_token_ttl_s: u64,
497
498 #[serde(default)]
502 pub workload_identity: Option<WorkloadIdentityConfig>,
503
504 #[serde(default)]
507 pub workload_identities: Vec<WorkloadMapping>,
508
509 #[serde(default)]
513 pub authzen: Option<AuthzenConfig>,
514
515 #[serde(default)]
520 pub token_exchange_enabled: bool,
521
522 #[serde(default)]
526 pub token_exchange_seed_file: Option<String>,
527
528 #[serde(default = "default_exchange_ttl")]
531 pub token_exchange_ttl_s: u64,
532
533 #[serde(default)]
536 pub operator_tokens: Vec<OperatorTokenConfig>,
537
538 #[serde(default)]
540 pub introspection_tokens: Vec<IntrospectionTokenConfig>,
541
542 #[serde(default = "default_revocation_audit_per_minute")]
544 pub revocation_audit_per_minute: u32,
545
546 #[serde(default)]
550 pub otel_tenant_endpoint: Option<String>,
551
552 #[serde(default)]
555 pub otel_tenant_auth_file: Option<String>,
556
557 #[serde(default)]
563 pub otel_tenant_content: TenantContent,
564
565 #[serde(default)]
568 pub redaction_patterns: Vec<String>,
569
570 #[serde(default)]
573 pub redaction_paths: Vec<String>,
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
578#[serde(deny_unknown_fields)]
579pub struct OperatorTokenConfig {
580 pub name: String,
582 pub sha256: String,
584}
585
586#[derive(Debug, Clone, Serialize, Deserialize)]
588#[serde(deny_unknown_fields)]
589pub struct IntrospectionTokenConfig {
590 pub backend: String,
592 pub sha256: String,
594}
595
596fn default_revocation_audit_per_minute() -> u32 {
597 600
598}
599
600#[derive(Debug, Clone, Serialize, Deserialize)]
602#[serde(deny_unknown_fields)]
603pub struct BackendConfig {
604 pub name: String,
606 pub url: String,
608 #[serde(default = "default_backend_auth")]
610 pub auth: BackendAuth,
611 #[serde(default)]
614 pub tools: Vec<String>,
615 #[serde(default)]
620 pub transport: BackendTransport,
621}
622
623#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
625#[serde(rename_all = "snake_case")]
626pub enum BackendTransport {
627 #[default]
629 Mcp,
630 JsonRpc,
632}
633
634fn default_tool_upstream_transport() -> BackendTransport {
635 BackendTransport::JsonRpc
636}
637
638pub(crate) fn validate_origin(origin: &str) -> Result<(), String> {
641 let parsed = reqwest::Url::parse(origin).map_err(|_| "not a valid origin".to_owned())?;
642 if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none_or(str::is_empty) {
643 return Err("must be an http:// or https:// origin with a host".to_owned());
644 }
645 if !parsed.username().is_empty()
646 || parsed.password().is_some()
647 || parsed.query().is_some()
648 || parsed.fragment().is_some()
649 || origin.trim_end_matches('/') != parsed.origin().ascii_serialization()
650 {
651 return Err("must be exactly scheme://host[:port], without path, query, or credentials".to_owned());
652 }
653 Ok(())
654}
655
656#[derive(Debug, Clone, Default, Serialize, Deserialize)]
658#[serde(rename_all = "snake_case")]
659pub enum BackendAuth {
660 #[default]
662 None,
663 StaticEnv(String),
665 StaticFile(String),
667 Exchange,
671 ForwardToken,
677}
678
679#[derive(Debug, Clone, Serialize, Deserialize)]
681#[serde(deny_unknown_fields)]
682pub struct MissionConfig {
683 pub id: String,
685 pub allowed_intents: Vec<String>,
688 pub expires_at: u64,
691 #[serde(default, skip_serializing_if = "Vec::is_empty")]
694 pub agents: Vec<String>,
695 #[serde(default, skip_serializing_if = "Vec::is_empty")]
697 pub charters: Vec<String>,
698 #[serde(default, skip_serializing_if = "Vec::is_empty")]
701 pub subjects: Vec<String>,
702}
703
704impl MissionConfig {
705 pub fn has_selectors(&self) -> bool {
707 !self.agents.is_empty() || !self.charters.is_empty() || !self.subjects.is_empty()
708 }
709
710 pub fn selects(&self, instance_uid: &str, charter: &str, subject: Option<&str>) -> bool {
714 self.has_selectors()
715 && (self.agents.is_empty() || self.agents.iter().any(|agent| agent == instance_uid))
716 && (self.charters.is_empty() || self.charters.iter().any(|name| name == charter))
717 && (self.subjects.is_empty() || subject.is_some_and(|sub| self.subjects.iter().any(|s| s == sub)))
718 }
719}
720
721#[derive(Debug, Clone, Serialize, Deserialize)]
723#[serde(deny_unknown_fields)]
724pub struct WorkloadIdentityConfig {
725 pub ca_file: String,
727 #[serde(default = "default_workload_token_ttl")]
729 pub token_ttl_s: u64,
730}
731
732fn default_workload_token_ttl() -> u64 {
733 300
734}
735
736#[derive(Debug, Clone, Serialize, Deserialize)]
738#[serde(deny_unknown_fields)]
739pub struct WorkloadMapping {
740 pub name: String,
742 #[serde(default)]
744 pub cf_app_guid: Option<String>,
745 #[serde(default)]
747 pub cf_space_guid: Option<String>,
748 #[serde(default)]
750 pub cf_org_guid: Option<String>,
751 pub sub: String,
753 pub charter: String,
755 #[serde(default = "default_workload_version")]
757 pub version: String,
758 pub scopes: Vec<String>,
760}
761
762fn default_workload_version() -> String {
763 "1".to_owned()
764}
765
766impl WorkloadMapping {
767 pub fn selects(&self, identity: &av_identity::CfInstanceIdentity) -> bool {
769 let matches =
770 |expected: &Option<String>, actual: &str| expected.as_deref().is_none_or(|value| value == actual);
771 (self.cf_app_guid.is_some() || self.cf_space_guid.is_some() || self.cf_org_guid.is_some())
772 && matches(&self.cf_app_guid, &identity.app_guid)
773 && matches(&self.cf_space_guid, &identity.space_guid)
774 && matches(&self.cf_org_guid, &identity.org_guid)
775 }
776}
777
778#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
780#[serde(rename_all = "snake_case")]
781pub enum TenantContent {
782 #[default]
784 Redacted,
785 Off,
787}
788
789#[derive(Debug, Clone, Serialize, Deserialize)]
792#[serde(deny_unknown_fields)]
793pub struct AuthzenConfig {
794 pub evaluation_endpoint: String,
797 #[serde(default)]
802 pub evaluations_endpoint: Option<String>,
803 #[serde(default)]
805 pub auth_file: Option<String>,
806 #[serde(default = "default_authzen_timeout_ms")]
809 pub timeout_ms: u64,
810}
811
812fn default_authzen_timeout_ms() -> u64 {
813 1_000
814}
815
816fn default_max_delegation_depth() -> usize {
817 4
818}
819fn default_intent_token_ttl() -> u64 {
820 60
821}
822fn default_exchange_ttl() -> u64 {
823 300
824}
825fn default_backend_auth() -> BackendAuth {
826 BackendAuth::None
827}
828fn default_config_version() -> u32 {
829 CONFIG_VERSION
830}
831fn default_listen() -> String {
832 "127.0.0.1:8484".to_owned()
833}
834fn default_audience() -> String {
835 "agentvisor-ai".to_owned()
836}
837fn default_chat_path() -> String {
838 "/v1/chat/completions".to_owned()
839}
840fn default_auth_header() -> String {
841 "authorization".to_owned()
842}
843fn default_auth_scheme() -> String {
844 "Bearer".to_owned()
845}
846fn default_jwks_refresh() -> u64 {
847 300
848}
849fn default_hmac_kid() -> String {
850 "dev-hmac".to_owned()
851}
852fn default_chat_scope() -> String {
853 "chat:write".to_owned()
854}
855fn default_close_scope() -> String {
856 "session:close".to_owned()
857}
858fn default_promote_scope() -> String {
859 "session:promote".to_owned()
860}
861fn default_workflow() -> String {
862 crate::session::Workflow::Unsigned.as_str().to_owned()
863}
864fn default_consequential_tools() -> Vec<String> {
865 ["db_write", "payout", "merge", "deploy"]
866 .into_iter()
867 .map(str::to_owned)
868 .collect()
869}
870fn default_tool_schema_dir() -> Option<String> {
871 Some("config/tool-schemas".to_owned())
872}
873fn default_true() -> bool {
874 true
875}
876fn default_payout_field() -> String {
877 "amount_usd".to_owned()
878}
879fn default_wasm_policies() -> Vec<String> {
880 vec!["config/policies/payload_limit.wat".to_owned()]
881}
882fn default_idle() -> u64 {
883 900
884}
885fn default_spool() -> String {
886 "spool/atif".to_owned()
887}
888fn default_bridge() -> String {
889 "data/bridge".to_owned()
890}
891fn default_bridge_backend() -> String {
892 "embedded".to_owned()
893}
894fn default_provider() -> String {
895 "openai".to_owned()
896}
897fn default_bridge_manifest() -> String {
898 "manifests/bridge.example.yaml".to_owned()
899}
900fn default_state_backend() -> String {
901 "memory".to_owned()
902}
903fn default_embedder_backend() -> String {
904 "hash".to_owned()
905}
906fn default_onnx_dimension() -> usize {
907 384
908}
909fn default_vector_backend() -> String {
910 "memory".to_owned()
911}
912fn default_qdrant_collection() -> String {
913 "agent_steps".to_owned()
914}
915fn default_channel_cap() -> usize {
916 32_768
917}
918fn default_mcp_concurrency() -> usize {
919 128
920}
921fn default_compression() -> bool {
922 true
923}
924fn default_reconcile_tick() -> u64 {
925 5
926}
927fn default_max_request_bytes() -> usize {
928 4 * 1024 * 1024
929}
930fn default_dashboard_enabled() -> bool {
931 true
932}
933
934#[derive(Debug, Clone, PartialEq, Eq)]
950pub enum BridgeBackend {
951 Embedded,
953 Kafka {
956 endpoint: String,
958 },
959 Nats {
961 endpoint: String,
963 },
964}
965
966#[derive(Debug, Clone, PartialEq, Eq)]
969pub enum StateBackend {
970 Memory,
972 Redis {
975 endpoint: String,
977 },
978}
979
980#[derive(Debug, Clone, PartialEq, Eq)]
982pub enum EmbedderBackend {
983 Hash,
985 Onnx {
987 model_path: String,
989 tokenizer_path: String,
991 },
992}
993
994#[derive(Debug, Clone, PartialEq, Eq)]
996pub enum VectorBackend {
997 Memory,
999 Qdrant {
1001 url: String,
1003 },
1004}
1005
1006#[derive(Debug, Clone, PartialEq, Eq)]
1008pub struct ResolvedBackends {
1009 pub bridge: BridgeBackend,
1011 pub state: StateBackend,
1013 pub embedder: EmbedderBackend,
1015 pub vector: VectorBackend,
1017}
1018
1019#[derive(Debug, Clone, PartialEq, Eq)]
1021pub enum ConfigSource {
1022 File(std::path::PathBuf),
1024 BuiltIn,
1026}
1027
1028impl std::fmt::Display for ConfigSource {
1029 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1030 match self {
1031 Self::File(path) => write!(f, "{}", path.display()),
1032 Self::BuiltIn => f.write_str("built-in defaults (zero-config)"),
1033 }
1034 }
1035}
1036
1037pub const CONFIG_SEARCH_PATHS: [&str; 2] = ["agentvisor.toml", "config/harness.toml"];
1048
1049pub fn user_config_path_from(home: &std::path::Path) -> std::path::PathBuf {
1051 home.join(".agentvisor").join("agentvisor.toml")
1052}
1053
1054pub fn user_config_path() -> Option<std::path::PathBuf> {
1057 #[allow(deprecated)] std::env::home_dir().map(|home| user_config_path_from(&home))
1059}
1060
1061pub fn resolve_config_source() -> Result<ConfigSource, String> {
1068 if let Some(path) = std::env::var_os("AV_CONFIG") {
1069 if !path.is_empty() {
1072 let path = std::path::PathBuf::from(path);
1073 if !path.is_file() {
1074 return Err(format!(
1075 "AV_CONFIG points to {} which does not exist or is not a file",
1076 path.display()
1077 ));
1078 }
1079 return Ok(ConfigSource::File(path));
1080 }
1081 }
1082 for candidate in CONFIG_SEARCH_PATHS {
1083 let path = std::path::Path::new(candidate);
1084 if path.is_file() {
1085 return Ok(ConfigSource::File(path.to_path_buf()));
1086 }
1087 }
1088 if let Some(path) = user_config_path() {
1089 if path.is_file() {
1090 return Ok(ConfigSource::File(path));
1091 }
1092 }
1093 Ok(ConfigSource::BuiltIn)
1094}
1095
1096pub fn load_config() -> Result<(HarnessConfig, ConfigSource), String> {
1098 load_config_with_override(None)
1099}
1100
1101pub fn load_config_with_override(
1106 explicit: Option<std::path::PathBuf>,
1107) -> Result<(HarnessConfig, ConfigSource), String> {
1108 let source = match explicit {
1109 Some(path) => {
1110 if !path.is_file() {
1111 return Err(format!(
1112 "--config points to {} which does not exist or is not a file",
1113 path.display()
1114 ));
1115 }
1116 ConfigSource::File(path)
1117 }
1118 None => resolve_config_source()?,
1119 };
1120 let mut config = match &source {
1121 ConfigSource::File(path) => {
1122 let text = av_core::fsutil::read_capped_string(
1127 std::path::Path::new(path),
1128 av_core::fsutil::MAX_CONTROL_BYTES,
1129 )
1130 .map_err(|error| format!("read harness config {}: {error}", path.display()))?;
1131 HarnessConfig::from_toml_unvalidated(&text)
1132 .map_err(|error| format!("{}: {error}", path.display()))?
1133 }
1134 ConfigSource::BuiltIn => HarnessConfig::builtin()?,
1135 };
1136 config.apply_env_overrides();
1137 if config.upstream_url.is_empty() && source == ConfigSource::BuiltIn {
1138 return Err(format!(
1139 "no configuration found and AV_UPSTREAM_URL is not set.\n\
1140 Quick start (pick one):\n\
1141 \x20 avctl # guided setup\n\
1142 \x20 avctl init --preset openai # write an annotated agentvisor.toml\n\
1143 \x20 AV_UPSTREAM_URL=http://127.0.0.1:11434 agentvisord # zero-config\n\
1144 Searched: $AV_CONFIG, {}, ~/.agentvisor/agentvisor.toml",
1145 CONFIG_SEARCH_PATHS.join(", ")
1146 ));
1147 }
1148 config.validate().map_err(|error| format!("{source}: {error}"))?;
1149 Ok((config, source))
1150}
1151
1152impl HarnessConfig {
1153 pub fn tenant_otel_authorization(&self) -> Result<Option<axum::http::HeaderValue>, String> {
1156 let token = crate::pipeline::read_secret(
1157 None,
1158 self.otel_tenant_auth_file.as_deref(),
1159 "tenant OTEL bearer token",
1160 )
1161 .map_err(|error| error.to_string())?;
1162 token
1163 .map(|token| {
1164 let mut header =
1165 axum::http::HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| {
1166 "tenant OTEL bearer token cannot be represented as an HTTP header".to_owned()
1167 })?;
1168 header.set_sensitive(true);
1169 Ok(header)
1170 })
1171 .transpose()
1172 }
1173
1174 pub fn from_toml(s: &str) -> Result<Self, String> {
1176 Self::check_declared_version(s)?;
1177 let cfg: Self = toml::from_str(s).map_err(|e| format!("config parse: {e}"))?;
1178 cfg.validate()?;
1179 Ok(cfg)
1180 }
1181
1182 pub fn from_toml_unvalidated(s: &str) -> Result<Self, String> {
1185 Self::check_declared_version(s)?;
1186 toml::from_str(s).map_err(|e| format!("config parse: {e}"))
1187 }
1188
1189 fn check_declared_version(s: &str) -> Result<(), String> {
1194 let loose: toml::Value = toml::from_str(s).map_err(|e| format!("config parse: {e}"))?;
1195 if let Some(version) = loose.get("config_version") {
1196 let declared = version.as_integer().unwrap_or(-1);
1197 if declared != i64::from(CONFIG_VERSION) {
1198 return Err(format!(
1199 "unsupported config_version {version} (this build supports {CONFIG_VERSION})",
1200 ));
1201 }
1202 }
1203 Ok(())
1204 }
1205
1206 pub fn builtin() -> Result<Self, String> {
1210 toml::from_str("upstream_url = \"\"").map_err(|e| format!("built-in config: {e}"))
1211 }
1212
1213 pub fn uses_default_manifest_path(&self) -> bool {
1218 self.bridge_manifest_path == default_bridge_manifest()
1219 }
1220
1221 pub fn uses_default_tool_schema_dir(&self) -> bool {
1224 self.tool_schema_dir == default_tool_schema_dir()
1225 }
1226
1227 pub fn is_default_policy_path(path: &str) -> bool {
1230 default_wasm_policies().iter().any(|entry| entry == path)
1231 }
1232
1233 pub fn effective_drain_timeout(&self) -> std::time::Duration {
1239 let read_timeout_s = self
1240 .upstream_read_timeout_s
1241 .unwrap_or(DEFAULT_UPSTREAM_READ_TIMEOUT_S);
1242 let mcp_timeout_s = self
1243 .mcp_request_timeout_s
1244 .unwrap_or(DEFAULT_MCP_REQUEST_TIMEOUT_S);
1245 let seconds = self
1246 .shutdown_drain_timeout_s
1247 .unwrap_or_else(|| read_timeout_s.max(mcp_timeout_s).saturating_add(5).max(30));
1248 std::time::Duration::from_secs(seconds)
1249 }
1250
1251 pub fn duplicated_chat_path_segment(&self) -> Option<&str> {
1257 if !self.upstream_chat_path.is_empty()
1260 && self
1261 .upstream_url
1262 .trim_end_matches('/')
1263 .ends_with(&self.upstream_chat_path)
1264 {
1265 return Some(self.upstream_chat_path.trim_start_matches('/'));
1266 }
1267 let first_segment = self
1268 .upstream_chat_path
1269 .trim_start_matches('/')
1270 .split('/')
1271 .next()?;
1272 if first_segment.is_empty() {
1273 return None;
1274 }
1275 let base = self.upstream_url.trim_end_matches('/');
1276 let last_segment = base.rsplit('/').next()?;
1277 if last_segment.contains('.') || last_segment.contains(':') || base.ends_with("//") {
1279 return None;
1280 }
1281 (last_segment == first_segment).then_some(first_segment)
1282 }
1283
1284 pub fn apply_env_overrides(&mut self) {
1290 self.apply_env_overrides_from(|name| std::env::var(name).ok());
1291 }
1292
1293 pub fn apply_env_overrides_from(&mut self, get: impl Fn(&str) -> Option<String>) {
1295 let non_empty = |value: String| if value.is_empty() { None } else { Some(value) };
1296 if let Some(listen) = get("AV_LISTEN").and_then(non_empty) {
1297 self.listen = listen;
1298 }
1299 if let Some(url) = get("AV_UPSTREAM_URL").and_then(non_empty) {
1300 self.upstream_url = url;
1301 }
1302 if let Some(path) = get("AV_UPSTREAM_CHAT_PATH").and_then(non_empty) {
1303 self.upstream_chat_path = path;
1304 }
1305 if let Some(header) = get("AV_UPSTREAM_AUTH_HEADER").and_then(non_empty) {
1306 self.upstream_auth_header = header;
1307 }
1308 if let Some(scheme) = get("AV_UPSTREAM_AUTH_SCHEME") {
1310 self.upstream_auth_scheme = scheme;
1311 }
1312 if let Some(endpoint) = get("AV_STATE_ENDPOINT").and_then(non_empty) {
1313 self.state_endpoint = Some(endpoint);
1314 }
1315 if let Some(endpoint) = get("AV_BRIDGE_ENDPOINT").and_then(non_empty) {
1316 self.bridge_endpoint = Some(endpoint);
1317 }
1318 if let Some(url) = get("AV_QDRANT_URL").and_then(non_empty) {
1319 self.qdrant_url = Some(url);
1320 }
1321 if self.upstream_api_key_env.is_none()
1326 && self.upstream_api_key_file.is_none()
1327 && !self.upstream_authorization_passthrough
1328 {
1329 if let Some(path) = get("AV_UPSTREAM_KEY_FILE").and_then(non_empty) {
1330 self.upstream_api_key_file = Some(path);
1331 }
1332 }
1333 if self.upstream_api_key_env.is_none()
1338 && self.upstream_api_key_file.is_none()
1339 && !self.upstream_authorization_passthrough
1340 && get("AV_UPSTREAM_API_KEY").and_then(non_empty).is_some()
1341 {
1342 self.upstream_api_key_env = Some("AV_UPSTREAM_API_KEY".to_owned());
1343 }
1344 }
1345
1346 fn required_companion<'a>(value: Option<&'a str>, error: &str) -> Result<&'a str, String> {
1349 value.filter(|v| !v.is_empty()).ok_or_else(|| error.to_owned())
1350 }
1351
1352 pub fn bridge(&self) -> Result<BridgeBackend, String> {
1359 const ENDPOINT_REQUIRED: &str = "bridge_endpoint is required for kafka and nats backends";
1360 match self.bridge_backend.as_str() {
1361 "embedded" => Ok(BridgeBackend::Embedded),
1362 "kafka" => Ok(BridgeBackend::Kafka {
1363 endpoint: Self::required_companion(self.bridge_endpoint.as_deref(), ENDPOINT_REQUIRED)?
1364 .to_owned(),
1365 }),
1366 "nats" => Ok(BridgeBackend::Nats {
1367 endpoint: Self::required_companion(self.bridge_endpoint.as_deref(), ENDPOINT_REQUIRED)?
1368 .to_owned(),
1369 }),
1370 other => Err(format!(
1371 "bridge_backend must be embedded|kafka|nats, got {other:?}"
1372 )),
1373 }
1374 }
1375
1376 pub fn state(&self) -> Result<StateBackend, String> {
1380 match self.state_backend.as_str() {
1381 "memory" => Ok(StateBackend::Memory),
1382 "redis" => Ok(StateBackend::Redis {
1383 endpoint: Self::required_companion(
1384 self.state_endpoint.as_deref(),
1385 "state_endpoint is required for the redis backend",
1386 )?
1387 .to_owned(),
1388 }),
1389 other => Err(format!("state_backend must be memory|redis, got {other:?}")),
1390 }
1391 }
1392
1393 pub fn embedder(&self) -> Result<EmbedderBackend, String> {
1396 const PATHS_REQUIRED: &str =
1397 "onnx_model_path and onnx_tokenizer_path are required for the onnx backend";
1398 match self.embedder_backend.as_str() {
1399 "hash" => Ok(EmbedderBackend::Hash),
1400 "onnx" => Ok(EmbedderBackend::Onnx {
1401 model_path: Self::required_companion(self.onnx_model_path.as_deref(), PATHS_REQUIRED)?
1402 .to_owned(),
1403 tokenizer_path: Self::required_companion(
1404 self.onnx_tokenizer_path.as_deref(),
1405 PATHS_REQUIRED,
1406 )?
1407 .to_owned(),
1408 }),
1409 other => Err(format!("embedder_backend must be hash|onnx, got {other:?}")),
1410 }
1411 }
1412
1413 pub fn vector(&self) -> Result<VectorBackend, String> {
1416 match self.vector_backend.as_str() {
1417 "memory" => Ok(VectorBackend::Memory),
1418 "qdrant" => Ok(VectorBackend::Qdrant {
1419 url: Self::required_companion(
1420 self.qdrant_url.as_deref(),
1421 "qdrant_url is required for the qdrant vector backend",
1422 )?
1423 .to_owned(),
1424 }),
1425 other => Err(format!("vector_backend must be memory|qdrant, got {other:?}")),
1426 }
1427 }
1428
1429 pub fn backends(&self) -> Result<ResolvedBackends, Vec<String>> {
1433 let mut errors = Vec::new();
1434 let bridge = self.bridge().map_err(|error| errors.push(error)).ok();
1435 let state = self.state().map_err(|error| errors.push(error)).ok();
1436 let embedder = self.embedder().map_err(|error| errors.push(error)).ok();
1437 let vector = self.vector().map_err(|error| errors.push(error)).ok();
1438 match (bridge, state, embedder, vector) {
1439 (Some(bridge), Some(state), Some(embedder), Some(vector)) => Ok(ResolvedBackends {
1440 bridge,
1441 state,
1442 embedder,
1443 vector,
1444 }),
1445 _ => Err(errors),
1446 }
1447 }
1448
1449 #[must_use]
1464 pub fn unsupported_backend_requirements(&self) -> Vec<String> {
1465 let mut missing = Vec::new();
1466 let mut require = |enabled: bool, field: &str, value: &str, feature: &str| {
1467 if !enabled {
1468 missing.push(format!(
1469 "{field} = {value:?} requires the `{feature}` cargo feature, \
1470 which this build was compiled without (rebuild with \
1471 --features {feature} or full)"
1472 ));
1473 }
1474 };
1475 match self.bridge() {
1476 Ok(BridgeBackend::Kafka { .. }) => {
1477 require(cfg!(feature = "kafka"), "bridge_backend", "kafka", "kafka");
1478 }
1479 Ok(BridgeBackend::Nats { .. }) => {
1480 require(cfg!(feature = "nats"), "bridge_backend", "nats", "nats");
1481 }
1482 Ok(BridgeBackend::Embedded) | Err(_) => {}
1483 }
1484 if matches!(self.state(), Ok(StateBackend::Redis { .. })) {
1485 require(cfg!(feature = "redis"), "state_backend", "redis", "redis");
1486 }
1487 if matches!(self.embedder(), Ok(EmbedderBackend::Onnx { .. })) {
1488 require(cfg!(feature = "onnx"), "embedder_backend", "onnx", "onnx");
1489 }
1490 if matches!(self.vector(), Ok(VectorBackend::Qdrant { .. })) {
1491 require(cfg!(feature = "qdrant"), "vector_backend", "qdrant", "qdrant");
1492 }
1493 if self.otel_tenant_endpoint.is_some() {
1494 require(cfg!(feature = "otel"), "otel_tenant_endpoint", "<set>", "otel");
1495 }
1496 missing
1497 }
1498
1499 pub fn validate(&self) -> Result<(), String> {
1503 if self.config_version != CONFIG_VERSION {
1504 return Err(format!(
1507 "unsupported config_version {} (this build supports {CONFIG_VERSION})",
1508 self.config_version
1509 ));
1510 }
1511 let mut errors = Vec::new();
1512 self.collect_validation_errors(&mut errors);
1513 if errors.len() > 1 {
1514 return Err(format!(
1515 "{} config errors:\n - {}",
1516 errors.len(),
1517 errors.join("\n - ")
1518 ));
1519 }
1520 match errors.pop() {
1521 None => Ok(()),
1522 Some(error) => Err(error),
1523 }
1524 }
1525
1526 fn collect_validation_errors(&self, errors: &mut Vec<String>) {
1534 if self.listen.is_empty() {
1535 errors.push("listen is required (host:port, e.g. 127.0.0.1:8484)".into());
1536 }
1537 match self.listen.rsplit_once(':') {
1541 Some((host, port)) if !host.is_empty() && port.parse::<u16>().is_ok() => {}
1542 _ => {
1543 errors.push(format!(
1544 "listen {:?} must be host:port with a port in 0-65535",
1545 self.listen
1546 ));
1547 }
1548 }
1549 if !self.require_identity && !self.allow_wildcard_bind {
1559 let host = self
1560 .listen
1561 .rsplit_once(':')
1562 .map(|(h, _)| h.trim_start_matches('[').trim_end_matches(']'))
1563 .unwrap_or("");
1564 let is_wildcard = matches!(host, "*" | "")
1574 || host
1575 .parse::<std::net::IpAddr>()
1576 .is_ok_and(|ip| ip.to_canonical().is_unspecified());
1577 if is_wildcard {
1578 errors.push(format!(
1579 "listen {:?} binds every interface while require_identity = false: any \
1580 peer reaching this host would speak to the proxy anonymously with the \
1581 operator's provider credentials. Either pin listen to a loopback / \
1582 private-network address, set require_identity = true with an \
1583 identity_jwks_url / identity_hmac_secret_file, or set \
1584 allow_wildcard_bind = true if an outer network layer (container port \
1585 map, service mesh, ingress ACL) controls who reaches the listener.",
1586 self.listen
1587 ));
1588 }
1589 }
1590 if self.dashboard_enabled && self.require_identity && !self.allow_wildcard_bind {
1600 let host = self
1601 .listen
1602 .rsplit_once(':')
1603 .map(|(h, _)| h.trim_start_matches('[').trim_end_matches(']'))
1604 .unwrap_or("");
1605 let is_wildcard = matches!(host, "*" | "")
1606 || host
1607 .parse::<std::net::IpAddr>()
1608 .is_ok_and(|ip| ip.to_canonical().is_unspecified());
1609 if is_wildcard {
1610 errors.push(format!(
1611 "listen {:?} binds every interface with dashboard_enabled = true: the \
1612 dashboard has no identity gate (require_identity covers only the proxy \
1613 API), so any peer can enumerate sessions and download receipts. Set \
1614 dashboard_enabled = false, pin listen, or set allow_wildcard_bind = \
1615 true if an outer network layer controls who reaches the listener.",
1616 self.listen
1617 ));
1618 }
1619 }
1620 if self.reconcile_tick_s == 0 {
1621 errors.push("reconcile_tick_s must be greater than zero".into());
1622 }
1623 if self.session_idle_close_s == 0 {
1624 errors.push(
1625 "session_idle_close_s must be greater than zero (0 would close every open session at each reconcile tick)"
1626 .into(),
1627 );
1628 }
1629 if self.atif_retention_days == Some(0) {
1639 errors.push(
1640 "atif_retention_days = 0 would delete every sealed ATIF pair on the first hourly sweep; \
1641 omit the field to disable retention, or set >= 1 to enable an N-day window"
1642 .into(),
1643 );
1644 }
1645 Self::validate_budget_spec(&self.budget, "budget", errors);
1667 if let Some(pb) = &self.principal_budget {
1668 Self::validate_budget_spec(pb, "principal_budget", errors);
1669 }
1670 if self.upstream_url.is_empty() {
1671 errors.push("upstream_url is required".into());
1672 }
1673 if !self.upstream_chat_path.starts_with('/') {
1674 errors.push(format!(
1675 "upstream_chat_path must start with '/', got {:?}",
1676 self.upstream_chat_path
1677 ));
1678 }
1679 if self.upstream_api_key_env.as_deref().is_some_and(str::is_empty) {
1680 errors.push("upstream_api_key_env must not be empty when set".into());
1681 }
1682 if self.upstream_api_key_file.as_deref().is_some_and(str::is_empty) {
1683 errors.push("upstream_api_key_file must not be empty when set".into());
1684 }
1685 if self.upstream_api_key_env.is_some() && self.upstream_api_key_file.is_some() {
1686 errors.push(
1687 "set only one of upstream_api_key_env or upstream_api_key_file (ambiguous key source)".into(),
1688 );
1689 }
1690 let has_static_key = self.upstream_api_key_env.is_some() || self.upstream_api_key_file.is_some();
1691 if self.upstream_authorization_passthrough && has_static_key {
1692 errors.push(
1693 "upstream_authorization_passthrough conflicts with upstream_api_key_env/file: choose one auth mode"
1694 .into(),
1695 );
1696 }
1697 if self.upstream_authorization_passthrough && self.require_identity {
1698 errors.push(
1699 "upstream_authorization_passthrough cannot be combined with require_identity: the \
1700 Authorization header carries the NHI token, which must never be sent upstream"
1701 .into(),
1702 );
1703 }
1704 if self.ignore_client_authorization && self.require_identity {
1705 errors.push(
1706 "ignore_client_authorization cannot be combined with require_identity: the \
1707 identity validator must see the Authorization header, not discard it"
1708 .into(),
1709 );
1710 }
1711 if self.ignore_client_authorization && self.upstream_authorization_passthrough {
1712 errors.push(
1713 "ignore_client_authorization conflicts with upstream_authorization_passthrough: \
1714 a client Authorization header cannot be both discarded and forwarded upstream"
1715 .into(),
1716 );
1717 }
1718 if axum::http::HeaderName::try_from(self.upstream_auth_header.as_str()).is_err() {
1719 errors.push(format!(
1720 "upstream_auth_header {:?} is not a valid HTTP header name",
1721 self.upstream_auth_header
1722 ));
1723 }
1724 for entry in &self.allowed_hosts {
1725 let unbracketed = entry.trim_start_matches('[').trim_end_matches(']');
1732 let has_port = entry.rsplit_once(':').is_some_and(|(head, port)| {
1733 !head.is_empty()
1734 && !port.is_empty()
1735 && port.chars().all(|c| c.is_ascii_digit())
1736 && !entry.ends_with(']')
1737 });
1738 if entry.trim().is_empty() {
1739 errors.push("allowed_hosts contains an empty entry".into());
1740 } else if entry != entry.trim() {
1741 errors.push(format!(
1742 "allowed_hosts entry {entry:?} has leading/trailing whitespace and would never match a Host header"
1743 ));
1744 } else if has_port && unbracketed.parse::<std::net::Ipv6Addr>().is_err() {
1745 errors.push(format!(
1746 "allowed_hosts entry {entry:?} includes a port; the Host check compares \
1747 hostnames with the port stripped, so this entry would never match — drop the port"
1748 ));
1749 }
1750 }
1751 if self
1752 .upstream_auth_scheme
1753 .bytes()
1754 .any(|byte| !(0x21..=0x7e).contains(&byte))
1755 {
1756 errors.push(
1757 "upstream_auth_scheme must contain only visible ASCII with no spaces (use \"\" for a raw key)"
1758 .into(),
1759 );
1760 }
1761 if self.tool_upstream_bearer_env.is_some() && self.tool_upstream_bearer_file.is_some() {
1762 errors.push(
1763 "set only one of tool_upstream_bearer_env or tool_upstream_bearer_file (ambiguous token source)"
1764 .into(),
1765 );
1766 }
1767 if self
1778 .tool_upstream_bearer_env
1779 .as_deref()
1780 .is_some_and(str::is_empty)
1781 {
1782 errors.push("tool_upstream_bearer_env must not be empty when set".into());
1783 }
1784 if self
1785 .tool_upstream_bearer_file
1786 .as_deref()
1787 .is_some_and(str::is_empty)
1788 {
1789 errors.push("tool_upstream_bearer_file must not be empty when set".into());
1790 }
1791 if (self.tool_upstream_bearer_env.is_some() || self.tool_upstream_bearer_file.is_some())
1792 && self.tool_upstream_url.as_deref().is_none_or(str::is_empty)
1793 {
1794 errors.push("tool_upstream_bearer_env/file requires tool_upstream_url to be set".into());
1795 }
1796 if crate::session::Workflow::parse(&self.default_workflow).is_none() {
1797 errors.push(format!(
1798 "default_workflow must be signed|unsigned, got {:?}",
1799 self.default_workflow
1800 ));
1801 }
1802 if self.principal_budget.is_some() && !self.require_identity && !self.allow_anonymous_principal_budget
1810 {
1811 errors.push(
1812 "principal_budget was set but require_identity = false: every unauthenticated \
1813 request would share a single `anonymous` principal ledger, making the budget \
1814 a fleet-wide bucket instead of the per-principal cap it appears to be. Either \
1815 set require_identity = true, or set allow_anonymous_principal_budget = true to \
1816 acknowledge single-tenant / trusted-network semantics."
1817 .into(),
1818 );
1819 }
1820 if self.require_identity
1821 && self.identity_jwks_url.as_deref().is_none_or(str::is_empty)
1822 && self
1823 .identity_hmac_secret_file
1824 .as_deref()
1825 .is_none_or(str::is_empty)
1826 {
1827 errors.push("require_identity=true needs identity_jwks_url or identity_hmac_secret_file".into());
1828 }
1829 if self.enforce_identity_scopes && !self.require_identity {
1846 errors.push(
1847 "enforce_identity_scopes=true has no effect while require_identity=false: \
1848 unauthenticated requests fall through to the anonymous identity and bypass \
1849 the scope gate entirely. Either set require_identity=true, or set \
1850 enforce_identity_scopes=false to make the posture explicit."
1851 .into(),
1852 );
1853 }
1854 if self.identity_jwks_refresh_s == 0 {
1855 errors.push("identity_jwks_refresh_s must be greater than zero".into());
1856 }
1857 if self.identity_jwks_refresh_s < 30 {
1863 errors.push(format!(
1864 "identity_jwks_refresh_s {} is too aggressive; a value below 30 s hammers the IdP \
1865 without benefit given NHI TTLs measured in minutes",
1866 self.identity_jwks_refresh_s
1867 ));
1868 }
1869 if self.identity_hmac_kid.is_empty() {
1870 errors.push("identity_hmac_kid must not be empty".into());
1871 }
1872 for (field, value) in [
1882 ("chat_scope", &self.chat_scope),
1883 ("session_close_scope", &self.session_close_scope),
1884 ("session_promote_scope", &self.session_promote_scope),
1885 ] {
1886 if value.is_empty() {
1887 errors.push(format!("{field} must not be empty"));
1888 }
1889 if value.bytes().any(|byte| !(0x21..=0x7e).contains(&byte)) {
1890 errors.push(format!(
1891 "{field} {value:?} must be visible ASCII with no whitespace or control bytes"
1892 ));
1893 }
1894 }
1895 if self.worker_channel_capacity == 0 {
1896 errors.push("worker_channel_capacity must be > 0".into());
1897 }
1898 if self.mcp_concurrency == 0 {
1903 errors.push("mcp_concurrency must be > 0 (0 refuses every MCP call at admission)".into());
1904 }
1905 if self.mcp_concurrency > MAX_MCP_CONCURRENCY {
1906 errors.push(format!(
1907 "mcp_concurrency {} exceeds the safety cap of {} — each admitted MCP call \
1908 can buffer up to 16 MiB, so oversized values silently commit multi-GB of \
1909 resident memory to a slow-upstream attack surface",
1910 self.mcp_concurrency, MAX_MCP_CONCURRENCY
1911 ));
1912 }
1913 if crate::provider::adapter_for(&self.provider).is_none() {
1914 errors.push(format!(
1915 "provider must be one of {:?}, got {:?} (Anthropic/Gemini adapters are planned)",
1916 crate::provider::SUPPORTED_PROVIDERS,
1917 self.provider
1918 ));
1919 }
1920 if self.bridge_manifest_path.is_empty() {
1921 errors.push("bridge_manifest_path is required".into());
1922 }
1923 if self.atif_spool_dir.is_empty() {
1932 errors.push("atif_spool_dir must not be empty".into());
1933 }
1934 if self.bridge_data_dir.is_empty() {
1935 errors.push("bridge_data_dir must not be empty".into());
1936 }
1937 if self.payout_field.is_empty() || !self.payout_field.chars().all(|c| c.is_ascii_graphic()) {
1957 errors.push(
1958 "payout_field must be non-empty visible ASCII (letters, digits, and \
1959 common punctuation only). An empty, whitespace, or invisible-format \
1960 (BOM / zero-width) value would match no argument key, silently disabling \
1961 max_payout_usd_micros — the very silent-bypass class this field exists \
1962 to prevent. Omit the key to use the default, or set it to your tool \
1963 schema's payout argument name."
1964 .into(),
1965 );
1966 }
1967 match self.bridge() {
1976 Err(error) => errors.push(error),
1977 Ok(BridgeBackend::Nats { endpoint }) => {
1978 if !(endpoint.starts_with("nats://") || endpoint.starts_with("tls://")) {
1979 errors.push(format!(
1980 "bridge_endpoint (nats backend) must be nats:// or tls://, got {endpoint:?}"
1981 ));
1982 }
1983 }
1984 Ok(BridgeBackend::Embedded | BridgeBackend::Kafka { .. }) => {}
1988 }
1989 match self.state() {
1990 Err(error) => errors.push(error),
1991 Ok(StateBackend::Redis { endpoint }) => {
1992 let mut previous_tls = None;
2005 for (index, member) in endpoint.split(',').map(str::trim).enumerate() {
2006 let ok = member.starts_with("redis://")
2007 || member.starts_with("rediss://")
2008 || member.starts_with("unix:")
2009 || member.starts_with("redis+unix:");
2010 if !ok {
2011 errors.push(format!(
2012 "state_endpoint (redis backend) member {} must be \
2013 redis://, rediss://, unix:, or redis+unix:",
2014 index + 1
2015 ));
2016 }
2017 let tls = member.starts_with("rediss://");
2018 if tls
2019 && member
2020 .split_once('#')
2021 .is_some_and(|(_, fragment)| fragment == "insecure")
2022 {
2023 errors
2024 .push("state_endpoint cannot disable Redis TLS certificate verification".into());
2025 }
2026 if previous_tls.is_some_and(|previous| previous != tls) {
2027 errors.push("state_endpoint cluster members must all use the same transport".into());
2028 }
2029 previous_tls = Some(tls);
2030 }
2031 }
2032 Ok(StateBackend::Memory) => {}
2033 }
2034 if let Err(error) = self.embedder() {
2035 errors.push(error);
2036 }
2037 match self.vector() {
2038 Err(error) => errors.push(error),
2039 Ok(VectorBackend::Qdrant { url }) => {
2040 if !(url.starts_with("http://") || url.starts_with("https://")) {
2041 errors.push(format!("qdrant_url must be http:// or https://, got {url:?}"));
2042 }
2043 }
2044 Ok(VectorBackend::Memory) => {}
2045 }
2046 if self.onnx_dimension == 0 {
2047 errors.push("onnx_dimension must be greater than zero".into());
2048 }
2049 if self.qdrant_collection.is_empty() {
2050 errors.push("qdrant_collection must not be empty".into());
2051 }
2052 if self.breaker.window == 0 {
2053 errors.push(
2054 "breaker.window must be greater than zero (0 trips on token count alone, ignoring semantic similarity)"
2055 .into(),
2056 );
2057 }
2058 if !self.breaker.delta_epsilon.is_finite() || self.breaker.delta_epsilon <= 0.0 {
2059 errors.push(format!(
2060 "breaker.delta_epsilon must be a finite number greater than zero, got {}",
2061 self.breaker.delta_epsilon
2062 ));
2063 }
2064 if self.worker_channel_capacity > MAX_WORKER_CHANNEL_CAPACITY {
2068 errors.push(format!(
2069 "worker_channel_capacity {} exceeds the safety cap of {} — oversized channels hide real backpressure and let per-shard buffers grow unboundedly under overload",
2070 self.worker_channel_capacity, MAX_WORKER_CHANNEL_CAPACITY
2071 ));
2072 }
2073 if self.max_request_bytes == 0 {
2074 errors.push(
2075 "max_request_bytes must be > 0 — a 0 cap forwards to DefaultBodyLimit::max(0), \
2076 rejecting every non-empty POST body silently"
2077 .into(),
2078 );
2079 }
2080 if self.max_request_bytes > MAX_REQUEST_BYTES_CAP {
2081 errors.push(format!(
2082 "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",
2083 self.max_request_bytes, MAX_REQUEST_BYTES_CAP
2084 ));
2085 }
2086 if self.onnx_dimension > MAX_ONNX_DIMENSION {
2087 errors.push(format!(
2088 "onnx_dimension {} exceeds the safety cap of {} — most sentence-transformer models are <= 4096",
2089 self.onnx_dimension, MAX_ONNX_DIMENSION
2090 ));
2091 }
2092 let mut interval_fields: Vec<(&'static str, u64)> = vec![
2096 ("reconcile_tick_s", self.reconcile_tick_s),
2097 ("session_idle_close_s", self.session_idle_close_s),
2098 ("identity_jwks_refresh_s", self.identity_jwks_refresh_s),
2099 ];
2100 if let Some(read_timeout) = self.upstream_read_timeout_s {
2101 if read_timeout == 0 {
2105 errors.push(
2106 "upstream_read_timeout_s = 0 would time out every upstream read immediately — omit the key to use the built-in 60 s default".into(),
2107 );
2108 }
2109 interval_fields.push(("upstream_read_timeout_s", read_timeout));
2110 }
2111 if let Some(timeout) = self.mcp_request_timeout_s {
2112 if timeout == 0 {
2113 errors.push(
2114 "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(),
2115 );
2116 }
2117 interval_fields.push(("mcp_request_timeout_s", timeout));
2118 }
2119 if let Some(drain) = self.shutdown_drain_timeout_s {
2120 if drain == 0 {
2121 errors.push(
2122 "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(),
2123 );
2124 }
2125 interval_fields.push(("shutdown_drain_timeout_s", drain));
2126 }
2127 if self.shutdown_ready_drain_s > 0 {
2128 interval_fields.push(("shutdown_ready_drain_s", self.shutdown_ready_drain_s));
2129 }
2130 for (field, value) in interval_fields {
2131 if value > MAX_SECONDS_INTERVAL {
2132 errors.push(format!(
2133 "{field} = {value} exceeds the safety cap of {MAX_SECONDS_INTERVAL} seconds (1 day) — did you mean milliseconds?"
2134 ));
2135 }
2136 }
2137 if !(self.upstream_url.starts_with("http://") || self.upstream_url.starts_with("https://")) {
2154 errors.push(format!(
2155 "upstream_url must be http:// or https://, got {:?}",
2156 self.upstream_url
2157 ));
2158 }
2159 {
2164 let rest = self
2165 .upstream_url
2166 .split_once("://")
2167 .map(|(_, rest)| rest)
2168 .unwrap_or_default();
2169 let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
2170 if host.is_empty() {
2171 errors.push(format!("upstream_url has no host, got {:?}", self.upstream_url));
2172 }
2173 }
2174 if self.upstream_url.contains(['?', '#']) {
2183 errors.push(format!(
2184 "upstream_url must not carry a query or fragment (request paths are \
2185 appended to it), got {:?}",
2186 self.upstream_url
2187 ));
2188 }
2189 if let Some(tool_upstream) = &self.tool_upstream_url {
2190 if tool_upstream.is_empty() {
2196 errors.push(
2197 "tool_upstream_url must not be empty; omit the field to disable tool forwarding"
2198 .to_owned(),
2199 );
2200 }
2201 if !(tool_upstream.starts_with("http://") || tool_upstream.starts_with("https://")) {
2202 errors.push(format!(
2203 "tool_upstream_url must be http:// or https://, got {tool_upstream:?}"
2204 ));
2205 }
2206 let rest = tool_upstream
2212 .split_once("://")
2213 .map(|(_, rest)| rest)
2214 .unwrap_or_default();
2215 let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
2216 if !tool_upstream.is_empty() && host.is_empty() {
2217 errors.push(format!("tool_upstream_url has no host, got {tool_upstream:?}"));
2218 }
2219 }
2220 if let Some(url) = self
2232 .identity_jwks_url
2233 .as_deref()
2234 .filter(|value| !value.is_empty())
2235 {
2236 if !(url.starts_with("http://") || url.starts_with("https://")) {
2237 errors.push(format!(
2238 "identity_jwks_url must be http:// or https://, got {url:?}"
2239 ));
2240 }
2241 }
2242 if self.require_intent_mapping && self.intent_map.is_empty() {
2248 errors.push(
2249 "require_intent_mapping is true but intent_map is empty; every tool call \
2250 would be denied with UNMAPPED_TOOL"
2251 .into(),
2252 );
2253 }
2254 if self.intent_token_ttl_s == 0 {
2255 errors.push("intent_token_ttl_s must be > 0".into());
2256 }
2257 if self.intent_token_ttl_s > 900 {
2258 errors.push(
2259 "intent_token_ttl_s exceeds the 900 s ceiling; intent tokens must be short-lived".into(),
2260 );
2261 }
2262 let mut mission_ids = std::collections::HashSet::new();
2263 for mission in self.mission.iter().chain(&self.missions) {
2264 if !mission_ids.insert(mission.id.as_str()) {
2265 errors.push(format!("mission id {:?} is used more than once", mission.id));
2266 }
2267 }
2268 if self.mission.as_ref().is_some_and(MissionConfig::has_selectors) {
2269 errors.push(
2270 "[mission] applies to every caller and takes no agents/charters/subjects; \
2271 put per-agent missions in [[missions]]"
2272 .into(),
2273 );
2274 }
2275 for mission in &self.missions {
2276 if !mission.has_selectors() {
2277 errors.push(format!(
2278 "[[missions]] entry {:?} has no agents, charters, or subjects; use [mission] \
2279 for a mission that applies to every caller",
2280 mission.id
2281 ));
2282 }
2283 if mission.allowed_intents.is_empty() {
2284 errors.push(format!(
2285 "mission {:?} has no allowed_intents; every call would be denied",
2286 mission.id
2287 ));
2288 }
2289 }
2290 if let Some(workload) = &self.workload_identity {
2291 if workload.ca_file.trim().is_empty() {
2292 errors.push("workload_identity.ca_file must not be empty".into());
2293 }
2294 if !(60..=900).contains(&workload.token_ttl_s) {
2295 errors.push("workload_identity.token_ttl_s must be between 60 and 900".into());
2296 }
2297 if self.token_exchange_seed_file.is_none() {
2298 errors.push(
2299 "[workload_identity] requires token_exchange_seed_file: the gateway signs agent \
2300 tokens with that key"
2301 .into(),
2302 );
2303 }
2304 if self.workload_identities.is_empty() {
2305 errors
2306 .push("[workload_identity] is set but no [[workload_identities]] are registered".into());
2307 }
2308 } else if !self.workload_identities.is_empty() {
2309 errors.push("[[workload_identities]] requires [workload_identity]".into());
2310 }
2311 let human = self
2313 .identity_human_subject_pattern
2314 .as_deref()
2315 .and_then(|pattern| {
2316 let mut validator = av_identity::IdentityValidator::new(&self.audience);
2317 validator
2318 .set_human_subject_pattern(pattern)
2319 .ok()
2320 .map(|()| validator)
2321 });
2322 let mut workload_names = std::collections::HashSet::new();
2323 for workload in &self.workload_identities {
2324 if !workload_names.insert(workload.name.as_str()) {
2325 errors.push(format!(
2326 "workload name {:?} is used more than once",
2327 workload.name
2328 ));
2329 }
2330 if workload.name.is_empty()
2331 || !workload
2332 .name
2333 .bytes()
2334 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
2335 {
2336 errors.push(format!(
2337 "workload name {:?} must be ASCII letters, digits, '-', '_', or '.'",
2338 workload.name
2339 ));
2340 }
2341 if workload.cf_app_guid.is_none()
2342 && workload.cf_space_guid.is_none()
2343 && workload.cf_org_guid.is_none()
2344 {
2345 errors.push(format!(
2346 "workload {:?} needs cf_app_guid, cf_space_guid, or cf_org_guid",
2347 workload.name
2348 ));
2349 }
2350 if workload.sub.trim().is_empty() {
2351 errors.push(format!(
2352 "workload {:?} needs `sub`: the human principal its agents act for",
2353 workload.name
2354 ));
2355 } else if human
2356 .as_ref()
2357 .is_some_and(|validator| !validator.subject_is_human(&workload.sub))
2358 {
2359 errors.push(format!(
2360 "workload {:?} sub {:?} does not match identity_human_subject_pattern",
2361 workload.name, workload.sub
2362 ));
2363 }
2364 if workload.charter.trim().is_empty() || workload.scopes.is_empty() {
2365 errors.push(format!(
2366 "workload {:?} needs a charter and at least one scope",
2367 workload.name
2368 ));
2369 }
2370 }
2371 if let Some(authzen) = &self.authzen {
2372 for (field, endpoint) in
2373 std::iter::once(("authzen.evaluation_endpoint", Some(&authzen.evaluation_endpoint))).chain(
2374 std::iter::once((
2375 "authzen.evaluations_endpoint",
2376 authzen.evaluations_endpoint.as_ref(),
2377 )),
2378 )
2379 {
2380 if let Some(endpoint) = endpoint {
2381 if let Err(error) = crate::backend::validate_http_endpoint(endpoint, field) {
2382 errors.push(error);
2383 }
2384 }
2385 }
2386 if !(1..=10_000).contains(&authzen.timeout_ms) {
2387 errors.push("authzen.timeout_ms must be between 1 and 10000".into());
2388 }
2389 if authzen
2390 .auth_file
2391 .as_deref()
2392 .is_some_and(|path| path.trim().is_empty())
2393 {
2394 errors.push("authzen.auth_file must not be empty".into());
2395 }
2396 }
2397 if let Some(ref mission) = self.mission {
2398 let now_s = av_core::time::now_ms() / 1000;
2399 if mission.expires_at <= now_s {
2400 tracing::warn!(
2401 mission_id = %mission.id,
2402 expires_at = mission.expires_at,
2403 now = now_s,
2404 "mission is already expired; the PDP will deny every call \
2405 until the mission is removed or updated"
2406 );
2407 }
2408 if mission.allowed_intents.is_empty() {
2409 errors.push(format!(
2410 "mission {:?} has no allowed_intents; every call would be denied",
2411 mission.id
2412 ));
2413 }
2414 }
2415
2416 if self
2418 .token_exchange_seed_file
2419 .as_ref()
2420 .is_some_and(|path| path.trim().is_empty())
2421 {
2422 errors.push(
2423 "token_exchange_seed_file must not be empty; omit it when token signing is disabled".into(),
2424 );
2425 }
2426 if self.token_exchange_enabled && self.token_exchange_seed_file.is_none() {
2427 errors.push(
2428 "token_exchange_enabled is true but token_exchange_seed_file is not set; \
2429 the /v1/token endpoint has no key to sign exchanged tokens with"
2430 .into(),
2431 );
2432 }
2433 let has_identity_source = self
2434 .identity_jwks_url
2435 .as_deref()
2436 .is_some_and(|url| !url.is_empty())
2437 || self
2438 .identity_hmac_secret_file
2439 .as_deref()
2440 .is_some_and(|path| !path.is_empty());
2441 if self.token_exchange_enabled && !has_identity_source {
2442 errors.push(
2443 "token_exchange_enabled is true but neither identity_jwks_url nor \
2444 identity_hmac_secret_file is set; the /v1/token endpoint cannot validate \
2445 subject tokens"
2446 .into(),
2447 );
2448 }
2449 if self.token_exchange_enabled && self.backends.is_empty() {
2450 errors.push(
2451 "token_exchange_enabled is true but no [[backends]] are configured; the \
2452 gateway signs exchanged tokens only for configured backend names, so every \
2453 exchange would be refused with invalid_target"
2454 .into(),
2455 );
2456 }
2457 if self.token_exchange_ttl_s == 0 {
2458 errors.push("token_exchange_ttl_s must be > 0".into());
2459 }
2460 if self.token_exchange_ttl_s > 900 {
2461 errors.push(format!(
2462 "token_exchange_ttl_s = {} exceeds the 900 s ceiling; \
2463 exchanged tokens must be short-lived",
2464 self.token_exchange_ttl_s
2465 ));
2466 }
2467 let has_exchange_backend = self
2468 .backends
2469 .iter()
2470 .any(|b| matches!(b.auth, BackendAuth::Exchange));
2471 if has_exchange_backend && self.token_exchange_seed_file.is_none() {
2472 errors.push(
2473 "a backend uses auth = \"exchange\" but token_exchange_seed_file is not set; \
2474 the gateway cannot sign exchanged tokens without a seed"
2475 .into(),
2476 );
2477 }
2478 if has_exchange_backend && !self.require_identity {
2479 errors.push(
2480 "a backend uses auth = \"exchange\" but require_identity is false; \
2481 the gateway needs a validated caller identity to perform token exchange"
2482 .into(),
2483 );
2484 }
2485 if self
2486 .backends
2487 .iter()
2488 .any(|b| matches!(b.auth, BackendAuth::ForwardToken))
2489 && !self.require_identity
2490 {
2491 errors.push(
2492 "a backend uses auth = \"forward_token\" but require_identity is false; \
2493 only a validated caller token may be forwarded to a backend"
2494 .into(),
2495 );
2496 }
2497 if let Some(pattern) = &self.identity_human_subject_pattern {
2498 if let Err(error) =
2499 av_identity::IdentityValidator::new(&self.audience).set_human_subject_pattern(pattern)
2500 {
2501 errors.push(format!("identity_human_subject_pattern: {error}"));
2502 }
2503 }
2504 for origin in &self.mcp_allowed_origins {
2505 if let Err(error) = validate_origin(origin) {
2506 errors.push(format!("mcp_allowed_origins entry {origin:?}: {error}"));
2507 }
2508 }
2509 errors.extend(crate::backend::validate_configs(&self.backends));
2510 if self.require_private_backends {
2511 for (field, url) in self
2512 .backends
2513 .iter()
2514 .map(|backend| (format!("backend {:?} url", backend.name), backend.url.as_str()))
2515 .chain(
2516 self.tool_upstream_url
2517 .iter()
2518 .map(|url| ("tool_upstream_url".to_owned(), url.as_str())),
2519 )
2520 {
2521 if let Err(error) = crate::backend::validate_private_endpoint(url, &field) {
2522 errors.push(error);
2523 }
2524 }
2525 }
2526 if self.backends.iter().any(|backend| backend.name == self.audience) {
2527 errors.push(format!(
2528 "a backend is named {:?}, the same as `audience`; tokens this gateway signs for that \
2529 backend would also be valid inbound identities, so choose another name",
2530 self.audience
2531 ));
2532 }
2533 if !self.redaction_patterns.is_empty() || !self.redaction_paths.is_empty() {
2534 if let Err(error) = av_redact::RedactionEngine::new(av_redact::RedactionConfig {
2535 regex_patterns: self.redaction_patterns.clone(),
2536 pointer_paths: self.redaction_paths.clone(),
2537 ..av_redact::RedactionConfig::default()
2538 }) {
2539 errors.push(format!("invalid redaction configuration: {error}"));
2540 }
2541 }
2542 if let Some(endpoint) = &self.otel_tenant_endpoint {
2543 if let Err(error) = crate::backend::validate_http_endpoint(endpoint, "otel_tenant_endpoint") {
2544 errors.push(error);
2545 }
2546 }
2547 if let Some(path) = &self.otel_tenant_auth_file {
2548 if path.trim().is_empty() {
2549 errors.push("otel_tenant_auth_file must not be empty".into());
2550 }
2551 if self.otel_tenant_endpoint.is_none() {
2552 errors.push("otel_tenant_auth_file requires otel_tenant_endpoint".into());
2553 }
2554 }
2555 if self.revocation_audit_per_minute == 0 {
2556 errors.push("revocation_audit_per_minute must be > 0".into());
2557 }
2558 let valid_digest = |digest: &str| digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit());
2559 let mut operator_names = std::collections::HashSet::new();
2560 let mut introspection_backends = std::collections::HashSet::new();
2561 let mut credential_digests = std::collections::HashSet::new();
2562 if !self.operator_tokens.is_empty() && !has_identity_source {
2563 errors.push("operator_tokens requires identity_jwks_url or identity_hmac_secret_file".into());
2564 }
2565 if !self.introspection_tokens.is_empty() {
2566 if !has_identity_source {
2567 errors.push(
2568 "introspection_tokens requires identity_jwks_url or identity_hmac_secret_file".into(),
2569 );
2570 }
2571 if self
2572 .token_exchange_seed_file
2573 .as_deref()
2574 .is_none_or(|path| path.trim().is_empty())
2575 {
2576 errors.push("introspection_tokens requires token_exchange_seed_file".into());
2577 }
2578 }
2579 for token in &self.operator_tokens {
2580 if token.name.trim().is_empty() || !operator_names.insert(&token.name) {
2581 errors.push("operator_tokens names must be non-empty and unique".into());
2582 }
2583 if !valid_digest(&token.sha256) {
2584 errors.push(format!(
2585 "operator_tokens {:?} sha256 must contain exactly 64 hexadecimal characters",
2586 token.name
2587 ));
2588 }
2589 if !credential_digests.insert(token.sha256.to_ascii_lowercase()) {
2590 errors.push(
2591 "operator_tokens and introspection_tokens must use distinct sha256 credential digests"
2592 .into(),
2593 );
2594 }
2595 }
2596 for token in &self.introspection_tokens {
2597 if !introspection_backends.insert(&token.backend) {
2598 errors.push(format!(
2599 "duplicate introspection_tokens backend {:?}",
2600 token.backend
2601 ));
2602 }
2603 let known_backend = self.backends.iter().any(|backend| backend.name == token.backend)
2604 || (token.backend == crate::backend::DEFAULT_BACKEND_NAME
2605 && self.backends.is_empty()
2606 && self.tool_upstream_url.is_some());
2607 if !known_backend {
2608 errors.push(format!(
2609 "introspection_tokens references unknown backend {:?}",
2610 token.backend
2611 ));
2612 }
2613 if !valid_digest(&token.sha256) {
2614 errors.push(format!(
2615 "introspection_tokens {:?} sha256 must contain exactly 64 hexadecimal characters",
2616 token.backend
2617 ));
2618 }
2619 if !credential_digests.insert(token.sha256.to_ascii_lowercase()) {
2620 errors.push(
2621 "operator_tokens and introspection_tokens must use distinct sha256 credential digests"
2622 .into(),
2623 );
2624 }
2625 }
2626 }
2627
2628 fn validate_budget_spec(spec: &av_state::BudgetSpec, scope: &str, errors: &mut Vec<String>) {
2634 if spec.max_tokens == Some(0) {
2635 errors.push(format!(
2636 "{scope}.max_tokens must be > 0 (Some(0) refuses every non-zero spend, 429-storming \
2637 every non-empty chat request); omit the field to disable the cap"
2638 ));
2639 }
2640 if spec.max_total_tool_calls == Some(0) {
2641 errors.push(format!(
2642 "{scope}.max_total_tool_calls must be > 0 (Some(0) refuses every tool call); \
2643 omit the field to disable the cap"
2644 ));
2645 }
2646 if spec.max_payout_usd_micros == Some(0) {
2647 errors.push(format!(
2648 "{scope}.max_payout_usd_micros must be > 0 (Some(0) refuses every non-zero payout); \
2649 omit the field to disable the cap"
2650 ));
2651 }
2652 for (tool, cap) in &spec.max_tool_calls {
2653 if *cap == 0 {
2654 errors.push(format!(
2655 "{scope}.max_tool_calls[{tool:?}] must be > 0 (0 silently disables the tool with \
2656 no error surface); omit the entry to leave the tool uncapped, or set >= 1"
2657 ));
2658 }
2659 }
2660 }
2661}
2662
2663#[cfg(test)]
2664mod tests {
2665 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2666
2667 #[test]
2668 fn security_config_rejects_backend_routing_and_redaction_errors_structurally() {
2669 let base = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2670 base.validate().unwrap();
2671 for (name, url, tools, expected) in [
2672 ("", "http://backend", vec!["read"], "backend name"),
2673 ("default", "http://backend", vec!["read"], "reserved"),
2674 ("tools", "https:///mcp", vec!["read"], "host"),
2675 ("tools", "file:///tmp/mcp", vec!["read"], "http"),
2676 ("tools", "http://backend:bad", vec!["read"], "valid URL"),
2677 ("tools", "http://backend", vec!["Read"], "lowercase ASCII"),
2678 ("tools", "http://backend", vec!["read", "read"], "repeated"),
2679 ] {
2680 let mut config = base.clone();
2681 config.backends = vec![BackendConfig {
2682 name: name.into(),
2683 url: url.into(),
2684 auth: BackendAuth::None,
2685 tools: tools.into_iter().map(str::to_owned).collect(),
2686 transport: Default::default(),
2687 }];
2688 assert!(config.validate().unwrap_err().contains(expected));
2689 }
2690 for (patterns, paths) in [
2691 (vec!["["], vec![]),
2692 (vec!["REDACTED"], vec![]),
2693 (vec![], vec!["password"]),
2694 ] {
2695 let mut config = base.clone();
2696 config.redaction_patterns = patterns.into_iter().map(str::to_owned).collect();
2697 config.redaction_paths = paths.into_iter().map(str::to_owned).collect();
2698 assert!(config.validate().unwrap_err().contains("redaction"));
2699 }
2700 for (ttl, expected) in [(0, false), (900, true), (901, false), (u64::MAX, false)] {
2701 let mut config = base.clone();
2702 config.intent_token_ttl_s = ttl;
2703 assert_eq!(config.validate().is_ok(), expected);
2704 }
2705 let mut empty_seed = base;
2706 empty_seed.token_exchange_seed_file = Some(String::new());
2707 assert!(empty_seed
2708 .validate()
2709 .unwrap_err()
2710 .contains("token_exchange_seed_file"));
2711 }
2712
2713 #[test]
2714 fn security_config_separates_structural_otel_checks_from_build_features() {
2715 let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2716 config.otel_tenant_endpoint = Some("https://tenant.example/v1/traces".into());
2717 config.validate().unwrap();
2718 assert_eq!(
2719 config
2720 .unsupported_backend_requirements()
2721 .iter()
2722 .any(|error| error.contains("otel")),
2723 !cfg!(feature = "otel")
2724 );
2725 config.otel_tenant_endpoint = Some("tenant.example/traces".into());
2726 assert!(config.validate().unwrap_err().contains("otel_tenant_endpoint"));
2727 config.otel_tenant_endpoint = None;
2728 config.otel_tenant_auth_file = Some("/run/secrets/tenant".into());
2729 assert!(config
2730 .validate()
2731 .unwrap_err()
2732 .contains("requires otel_tenant_endpoint"));
2733 }
2734
2735 #[test]
2736 fn security_config_validates_operator_and_introspection_credentials() {
2737 let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2738 config.identity_hmac_secret_file = Some("/run/secrets/identity".into());
2739 config.token_exchange_seed_file = Some("/run/secrets/exchange".into());
2740 config.backends = vec![BackendConfig {
2741 name: "db".into(),
2742 url: "http://db".into(),
2743 auth: BackendAuth::None,
2744 tools: vec![],
2745 transport: Default::default(),
2746 }];
2747 config.operator_tokens = vec![OperatorTokenConfig {
2748 name: "operator".into(),
2749 sha256: "ab".repeat(32),
2750 }];
2751 config.introspection_tokens = vec![IntrospectionTokenConfig {
2752 backend: "db".into(),
2753 sha256: "cd".repeat(32),
2754 }];
2755 config.validate().unwrap();
2756 let good = config.clone();
2757 config
2758 .operator_tokens
2759 .push(config.operator_tokens.first().unwrap().clone());
2760 assert!(config.validate().unwrap_err().contains("unique"));
2761 config = good.clone();
2762 config
2763 .introspection_tokens
2764 .push(config.introspection_tokens.first().unwrap().clone());
2765 assert!(config
2766 .validate()
2767 .unwrap_err()
2768 .contains("duplicate introspection_tokens"));
2769 config = good.clone();
2770 config.introspection_tokens.first_mut().unwrap().backend = "unknown".into();
2771 assert!(config.validate().unwrap_err().contains("unknown backend"));
2772 for digest in ["f".repeat(63), "g".repeat(64), "f".repeat(65)] {
2773 config = good.clone();
2774 config.operator_tokens.first_mut().unwrap().sha256 = digest.clone();
2775 config.introspection_tokens.first_mut().unwrap().sha256 = digest;
2776 let errors = config.validate().unwrap_err();
2777 assert!(errors.contains("operator_tokens"));
2778 assert!(errors.contains("introspection_tokens"));
2779 }
2780 config = good;
2781 config.revocation_audit_per_minute = 0;
2782 assert!(config
2783 .validate()
2784 .unwrap_err()
2785 .contains("revocation_audit_per_minute"));
2786 }
2787
2788 #[test]
2789 fn security_config_rejects_reused_control_credentials_and_missing_identity_sources() {
2790 let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2791 config.identity_hmac_secret_file = Some("/run/secrets/identity".into());
2792 config.token_exchange_seed_file = Some("/run/secrets/exchange".into());
2793 config.backends = ["db", "search"]
2794 .into_iter()
2795 .map(|name| BackendConfig {
2796 name: name.into(),
2797 url: format!("http://{name}"),
2798 auth: BackendAuth::None,
2799 tools: vec![name.into()],
2800 transport: Default::default(),
2801 })
2802 .collect();
2803 config.operator_tokens = vec![OperatorTokenConfig {
2804 name: "operator".into(),
2805 sha256: "ab".repeat(32),
2806 }];
2807 config.introspection_tokens = vec![IntrospectionTokenConfig {
2808 backend: "db".into(),
2809 sha256: "cd".repeat(32),
2810 }];
2811 config.validate().unwrap();
2812 let valid = config.clone();
2813
2814 config.operator_tokens.push(OperatorTokenConfig {
2815 name: "second-operator".into(),
2816 sha256: "AB".repeat(32),
2817 });
2818 assert!(config.validate().unwrap_err().contains("distinct sha256"));
2819 config = valid.clone();
2820 config.introspection_tokens.push(IntrospectionTokenConfig {
2821 backend: "search".into(),
2822 sha256: "CD".repeat(32),
2823 });
2824 assert!(config.validate().unwrap_err().contains("distinct sha256"));
2825 config = valid.clone();
2826 config.introspection_tokens.first_mut().unwrap().sha256 = config
2827 .operator_tokens
2828 .first()
2829 .unwrap()
2830 .sha256
2831 .to_ascii_uppercase();
2832 assert!(config.validate().unwrap_err().contains("distinct sha256"));
2833
2834 config = valid.clone();
2835 config.identity_hmac_secret_file = None;
2836 let errors = config.validate().unwrap_err();
2837 assert!(errors.contains("operator_tokens requires identity"));
2838 assert!(errors.contains("introspection_tokens requires identity"));
2839 config = valid;
2840 config.token_exchange_seed_file = None;
2841 assert!(config
2842 .validate()
2843 .unwrap_err()
2844 .contains("introspection_tokens requires token_exchange_seed_file"));
2845 config.introspection_tokens.clear();
2846 config.validate().unwrap();
2847 }
2848
2849 #[cfg(unix)]
2850 #[test]
2851 fn security_config_loads_tenant_auth_with_owner_only_permissions() {
2852 use std::os::unix::fs::PermissionsExt;
2853 let directory = tempfile::tempdir().unwrap();
2854 let path = directory.path().join("tenant-token");
2855 std::fs::write(&path, "tenant-secret\n").unwrap();
2856 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2857 let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2858 config.otel_tenant_auth_file = Some(path.to_string_lossy().into_owned());
2859 let header = config.tenant_otel_authorization().unwrap().unwrap();
2860 assert_eq!(header, "Bearer tenant-secret");
2861 assert!(header.is_sensitive());
2862 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2863 assert!(config.tenant_otel_authorization().is_err());
2864 }
2865
2866 #[test]
2874 fn configuration_reference_documents_every_config_field() {
2875 let source = include_str!("config.rs");
2876 let reference = include_str!("../../../docs/reference/CONFIGURATION.md");
2877 let body = source
2878 .split_once("pub struct HarnessConfig {")
2879 .map(|(_, rest)| rest)
2880 .and_then(|rest| rest.split_once("\n}"))
2881 .map(|(body, _)| body)
2882 .unwrap();
2883 let fields: Vec<&str> = body
2884 .lines()
2885 .filter_map(|line| line.trim().strip_prefix("pub "))
2886 .filter_map(|rest| rest.split_once(':'))
2887 .map(|(name, _)| name.trim())
2888 .collect();
2889 assert!(
2890 fields.len() >= 60,
2891 "field extraction broke — found only {} fields",
2892 fields.len()
2893 );
2894 for field in fields {
2895 assert!(
2896 reference.contains(&format!("`{field}`")) || reference.contains(&format!("`[{field}]`")),
2897 "config field `{field}` is not documented in docs/reference/CONFIGURATION.md"
2898 );
2899 }
2900 }
2901
2902 use super::*;
2903
2904 #[test]
2905 fn minimal_toml_parses_with_defaults() {
2906 let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2907 assert_eq!(cfg.config_version, CONFIG_VERSION);
2908 assert_eq!(cfg.default_workflow, "unsigned");
2909 assert_eq!(cfg.session_idle_close_s, 900);
2910 assert!(cfg.compression_enabled);
2911 assert_eq!(cfg.upstream_chat_path, "/v1/chat/completions");
2912 assert_eq!(cfg.upstream_auth_header, "authorization");
2913 assert_eq!(cfg.upstream_auth_scheme, "Bearer");
2914 assert!(cfg.upstream_api_key_env.is_none());
2915 assert!(!cfg.upstream_authorization_passthrough);
2916 }
2917
2918 #[test]
2925 fn validate_refuses_unmatchable_allowed_hosts_entries() {
2926 for (entry, needle) in [
2927 ("localhost:8484", "includes a port"),
2928 ("[::1]:8484", "includes a port"),
2929 (" localhost", "whitespace"),
2930 ("", "empty"),
2931 ] {
2932 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2933 cfg.allowed_hosts = vec![entry.to_owned()];
2934 let error = cfg.validate().unwrap_err();
2935 assert!(
2936 error.contains(needle),
2937 "entry {entry:?}: expected {needle:?} in: {error}"
2938 );
2939 }
2940 for entry in [
2943 "localhost",
2944 "127.0.0.1",
2945 "::1",
2946 "2001:db8::1",
2947 "[::1]",
2948 "agentvisor.internal",
2949 ] {
2950 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2951 cfg.allowed_hosts = vec![entry.to_owned()];
2952 cfg.validate()
2953 .unwrap_or_else(|e| panic!("entry {entry:?} must pass: {e}"));
2954 }
2955 }
2956
2957 #[test]
2958 fn validate_reports_all_errors_at_once() {
2959 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2960 cfg.listen = String::new();
2961 cfg.bridge_backend = "carrier-pigeon".to_owned();
2962 cfg.state_backend = "abacus".to_owned();
2963 let error = cfg.validate().unwrap_err();
2964 assert!(
2965 error.contains("config errors"),
2966 "aggregate header missing: {error}"
2967 );
2968 for needle in ["listen", "bridge_backend", "state_backend"] {
2969 assert!(error.contains(needle), "missing {needle} in: {error}");
2970 }
2971 }
2972
2973 #[test]
2977 fn validate_single_error_keeps_bare_message() {
2978 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2979 cfg.state_backend = "abacus".to_owned();
2980 let error = cfg.validate().unwrap_err();
2981 assert!(
2982 !error.contains("config errors"),
2983 "single error must not aggregate: {error}"
2984 );
2985 assert!(error.contains("state_backend"), "must name the field: {error}");
2986 }
2987
2988 #[test]
2991 fn unsupported_provider_is_refused_at_validation() {
2992 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2993 assert_eq!(cfg.provider, "openai");
2994 cfg.provider = "google".to_owned();
2995 let error = cfg.validate().unwrap_err();
2996 assert!(error.contains("provider"), "must name the field: {error}");
2997 assert!(error.contains("openai"), "must name the supported set: {error}");
2998 cfg.provider = "anthropic".to_owned();
2999 assert!(
3000 cfg.validate().is_ok(),
3001 "anthropic is a supported dialect (S3 step 2)"
3002 );
3003 }
3004
3005 #[test]
3008 fn default_backends_never_report_unsupported_requirements() {
3009 let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3010 assert!(cfg.unsupported_backend_requirements().is_empty());
3011 }
3012
3013 #[test]
3020 fn unsupported_backend_requirements_track_build_features() {
3021 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3022 cfg.bridge_backend = "kafka".to_owned();
3023 cfg.bridge_endpoint = Some("broker:9092".to_owned());
3024 cfg.state_backend = "redis".to_owned();
3025 cfg.state_endpoint = Some("redis://cache:6379".to_owned());
3026 cfg.embedder_backend = "onnx".to_owned();
3027 cfg.onnx_model_path = Some("model.onnx".to_owned());
3028 cfg.onnx_tokenizer_path = Some("tokenizer.json".to_owned());
3029 cfg.vector_backend = "qdrant".to_owned();
3030 cfg.qdrant_url = Some("http://vectors:6333".to_owned());
3031 let missing = cfg.unsupported_backend_requirements();
3032 for (enabled, feature) in [
3033 (cfg!(feature = "kafka"), "kafka"),
3034 (cfg!(feature = "redis"), "redis"),
3035 (cfg!(feature = "onnx"), "onnx"),
3036 (cfg!(feature = "qdrant"), "qdrant"),
3037 ] {
3038 let reported = missing
3039 .iter()
3040 .any(|message| message.contains(&format!("`{feature}`")));
3041 assert_eq!(
3042 reported, !enabled,
3043 "feature {feature}: enabled={enabled} but reported={reported} in {missing:?}"
3044 );
3045 }
3046 }
3047
3048 #[test]
3052 fn backend_missing_companion_fails_with_named_error() {
3053 for (extra, companion) in [
3054 ("bridge_backend = \"kafka\"", "bridge_endpoint"),
3055 ("bridge_backend = \"nats\"", "bridge_endpoint"),
3056 (
3057 "bridge_backend = \"kafka\"\nbridge_endpoint = \"\"",
3058 "bridge_endpoint",
3059 ),
3060 ("state_backend = \"redis\"", "state_endpoint"),
3061 ("vector_backend = \"qdrant\"", "qdrant_url"),
3062 ("embedder_backend = \"onnx\"", "onnx_model_path"),
3063 (
3064 "embedder_backend = \"onnx\"\nonnx_model_path = \"model.onnx\"",
3066 "onnx_tokenizer_path",
3067 ),
3068 ] {
3069 let err =
3070 HarnessConfig::from_toml(&format!("upstream_url = \"https://api\"\n{extra}")).unwrap_err();
3071 assert!(
3072 err.contains(companion) && err.contains("required"),
3073 "{extra}: error must name {companion}: {err}"
3074 );
3075 }
3076 }
3077
3078 #[test]
3082 fn unknown_backend_value_names_legal_set() {
3083 for (field, bogus, legal_set) in [
3084 ("bridge_backend", "carrier-pigeon", "embedded|kafka|nats"),
3085 ("state_backend", "abacus", "memory|redis"),
3086 ("embedder_backend", "vibes", "hash|onnx"),
3087 ("vector_backend", "faiss", "memory|qdrant"),
3088 ] {
3089 let err =
3090 HarnessConfig::from_toml(&format!("upstream_url = \"https://api\"\n{field} = \"{bogus}\""))
3091 .unwrap_err();
3092 assert!(err.contains(field), "must name the field: {err}");
3093 assert!(err.contains(legal_set), "must name the legal set: {err}");
3094 assert!(err.contains(bogus), "must echo the typo: {err}");
3095 }
3096 }
3097
3098 #[test]
3101 fn full_feature_config_resolves_to_typed_backends() {
3102 let cfg = HarnessConfig::from_toml(
3103 r#"upstream_url = "https://api"
3104 bridge_backend = "kafka"
3105 bridge_endpoint = "broker-1:9092,broker-2:9092"
3106 state_backend = "redis"
3107 state_endpoint = "redis://cache:6379"
3108 embedder_backend = "onnx"
3109 onnx_model_path = "models/model.onnx"
3110 onnx_tokenizer_path = "models/tokenizer.json"
3111 vector_backend = "qdrant"
3112 qdrant_url = "http://vectors:6333""#,
3113 )
3114 .unwrap();
3115 let backends = cfg.backends().unwrap();
3116 assert_eq!(
3117 backends.bridge,
3118 BridgeBackend::Kafka {
3119 endpoint: "broker-1:9092,broker-2:9092".to_owned()
3120 }
3121 );
3122 assert_eq!(
3123 backends.state,
3124 StateBackend::Redis {
3125 endpoint: "redis://cache:6379".to_owned()
3126 }
3127 );
3128 assert_eq!(
3129 backends.embedder,
3130 EmbedderBackend::Onnx {
3131 model_path: "models/model.onnx".to_owned(),
3132 tokenizer_path: "models/tokenizer.json".to_owned()
3133 }
3134 );
3135 assert_eq!(
3136 backends.vector,
3137 VectorBackend::Qdrant {
3138 url: "http://vectors:6333".to_owned()
3139 }
3140 );
3141 let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api""#).unwrap();
3143 let backends = cfg.backends().unwrap();
3144 assert_eq!(backends.bridge, BridgeBackend::Embedded);
3145 assert_eq!(backends.state, StateBackend::Memory);
3146 assert_eq!(backends.embedder, EmbedderBackend::Hash);
3147 assert_eq!(backends.vector, VectorBackend::Memory);
3148 }
3149
3150 #[test]
3153 fn backends_accumulates_all_resolution_errors() {
3154 let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api""#).unwrap();
3155 cfg.bridge_backend = "carrier-pigeon".to_owned();
3156 cfg.state_backend = "redis".to_owned(); cfg.vector_backend = "faiss".to_owned();
3158 let errors = cfg.backends().unwrap_err();
3159 assert_eq!(errors.len(), 3, "{errors:?}");
3160 let joined = errors.join("\n");
3161 for needle in ["bridge_backend", "state_endpoint", "vector_backend"] {
3162 assert!(joined.contains(needle), "missing {needle} in {errors:?}");
3163 }
3164 }
3165
3166 #[test]
3169 fn unknown_config_key_is_rejected_with_its_name() {
3170 let err = HarnessConfig::from_toml(
3171 r#"upstream_url = "https://api.openai.com"
3172 idel_timeout_s = 10"#,
3173 )
3174 .unwrap_err();
3175 assert!(err.contains("idel_timeout_s"), "error must name the key: {err}");
3176 }
3177
3178 #[test]
3181 fn upstream_auth_invariants_enforced() {
3182 assert!(HarnessConfig::from_toml(
3184 r#"upstream_url = "https://api.openai.com"
3185 upstream_api_key_env = "OPENAI_API_KEY"
3186 upstream_api_key_file = "/etc/key""#
3187 )
3188 .is_err());
3189 assert!(HarnessConfig::from_toml(
3191 r#"upstream_url = "https://api.openai.com"
3192 upstream_api_key_env = "OPENAI_API_KEY"
3193 upstream_authorization_passthrough = true"#
3194 )
3195 .is_err());
3196 assert!(HarnessConfig::from_toml(
3198 r#"upstream_url = "https://api.openai.com"
3199 upstream_authorization_passthrough = true
3200 require_identity = true
3201 identity_hmac_secret_file = "/run/secrets/hmac""#
3202 )
3203 .is_err());
3204 assert!(HarnessConfig::from_toml(
3206 r#"upstream_url = "https://api.openai.com"
3207 upstream_auth_header = "not a header""#
3208 )
3209 .is_err());
3210 assert!(HarnessConfig::from_toml(
3212 r#"upstream_url = "https://api.openai.com"
3213 upstream_auth_scheme = "Bearer extra""#
3214 )
3215 .is_err());
3216 assert!(HarnessConfig::from_toml(
3218 r#"upstream_url = "https://api.openai.com"
3219 upstream_chat_path = "v1/chat/completions""#
3220 )
3221 .is_err());
3222 assert!(HarnessConfig::from_toml(
3224 r#"upstream_url = "https://api.openai.com"
3225 tool_upstream_bearer_env = "MCP_TOKEN""#
3226 )
3227 .is_err());
3228 let azure = HarnessConfig::from_toml(
3230 r#"upstream_url = "https://res.openai.azure.com"
3231 upstream_chat_path = "/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21"
3232 upstream_api_key_env = "AZURE_OPENAI_API_KEY"
3233 upstream_auth_header = "api-key"
3234 upstream_auth_scheme = """#,
3235 )
3236 .unwrap();
3237 assert_eq!(azure.upstream_auth_scheme, "");
3238 }
3239
3240 #[test]
3241 fn env_overrides_apply_with_documented_precedence() {
3242 let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
3243 let env = |name: &str| -> Option<String> {
3244 match name {
3245 "AV_LISTEN" => Some("0.0.0.0:9999".into()),
3246 "AV_UPSTREAM_URL" => Some("http://env-upstream".into()),
3247 "AV_UPSTREAM_CHAT_PATH" => Some("/openai/v1/chat/completions".into()),
3248 "AV_UPSTREAM_API_KEY" => Some("sk-secret".into()),
3249 "AV_STATE_ENDPOINT" => Some("redis://redis:6379".into()),
3250 _ => None,
3251 }
3252 };
3253 cfg.apply_env_overrides_from(env);
3254 assert_eq!(cfg.listen, "0.0.0.0:9999");
3255 assert_eq!(cfg.upstream_url, "http://env-upstream");
3256 assert_eq!(cfg.upstream_chat_path, "/openai/v1/chat/completions");
3257 assert_eq!(cfg.state_endpoint.as_deref(), Some("redis://redis:6379"));
3258 assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("AV_UPSTREAM_API_KEY"));
3260
3261 let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
3263 cfg.upstream_api_key_env = Some("OPENAI_API_KEY".into());
3264 cfg.apply_env_overrides_from(env);
3265 assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("OPENAI_API_KEY"));
3266
3267 let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
3269 cfg.apply_env_overrides_from(|name| (name == "AV_UPSTREAM_URL").then(String::new));
3270 assert_eq!(cfg.upstream_url, "http://file-upstream");
3271 }
3272
3273 #[test]
3274 fn env_key_file_override_precedence() {
3275 let env = |name: &str| -> Option<String> {
3277 match name {
3278 "AV_UPSTREAM_KEY_FILE" => Some("/run/secrets/api_key".into()),
3279 "AV_UPSTREAM_API_KEY" => Some("sk-secret".into()),
3280 _ => None,
3281 }
3282 };
3283 let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
3284 cfg.apply_env_overrides_from(env);
3285 assert_eq!(cfg.upstream_api_key_file.as_deref(), Some("/run/secrets/api_key"));
3286 assert_eq!(cfg.upstream_api_key_env, None);
3288
3289 let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
3291 cfg.upstream_api_key_env = Some("OPENAI_API_KEY".into());
3292 cfg.apply_env_overrides_from(env);
3293 assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("OPENAI_API_KEY"));
3294 assert_eq!(cfg.upstream_api_key_file, None);
3295
3296 let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
3298 cfg.upstream_authorization_passthrough = true;
3299 cfg.apply_env_overrides_from(env);
3300 assert_eq!(cfg.upstream_api_key_file, None);
3301 assert_eq!(cfg.upstream_api_key_env, None);
3302 }
3303
3304 #[test]
3305 fn builtin_config_validates_once_upstream_is_set() {
3306 let mut cfg = HarnessConfig::builtin().unwrap();
3307 assert!(cfg.validate().is_err(), "must not pass without an upstream");
3308 cfg.apply_env_overrides_from(|name| {
3309 (name == "AV_UPSTREAM_URL").then(|| "http://127.0.0.1:11434".to_owned())
3310 });
3311 cfg.validate().unwrap();
3312 assert_eq!(cfg.bridge_backend, "embedded");
3313 assert_eq!(cfg.state_backend, "memory");
3314 assert_eq!(cfg.embedder_backend, "hash");
3315 assert_eq!(cfg.vector_backend, "memory");
3316 }
3317
3318 #[test]
3319 fn validate_rejects_unbindable_listen_and_zero_intervals() {
3320 let base = || HarnessConfig::for_tests("http://u", "spool", "bridge");
3321
3322 let mut cfg = base();
3323 cfg.listen = String::new();
3324 assert!(cfg.validate().unwrap_err().contains("listen is required"));
3325
3326 cfg = base();
3327 cfg.listen = "no-port-here".into();
3328 assert!(cfg.validate().unwrap_err().contains("host:port"));
3329
3330 cfg = base();
3331 cfg.listen = "127.0.0.1:70000".into();
3332 assert!(cfg.validate().unwrap_err().contains("host:port"));
3333
3334 cfg = base();
3336 cfg.listen = "localhost:8484".into();
3337 cfg.validate().unwrap();
3338 cfg.listen = "[::1]:0".into();
3339 cfg.validate().unwrap();
3340
3341 cfg = base();
3342 cfg.reconcile_tick_s = 0;
3343 assert!(cfg.validate().unwrap_err().contains("reconcile_tick_s"));
3344
3345 cfg = base();
3346 cfg.session_idle_close_s = 0;
3347 assert!(cfg.validate().unwrap_err().contains("session_idle_close_s"));
3348
3349 cfg = base();
3354 cfg.atif_retention_days = Some(0);
3355 assert!(
3356 cfg.validate().unwrap_err().contains("atif_retention_days = 0"),
3357 "atif_retention_days = 0 should be rejected"
3358 );
3359 cfg.atif_retention_days = None;
3360 cfg.validate().unwrap();
3361 cfg.atif_retention_days = Some(1);
3362 cfg.validate().unwrap();
3363
3364 cfg = base();
3369 cfg.budget.max_tokens = Some(0);
3370 assert!(
3371 cfg.validate().unwrap_err().contains("budget.max_tokens"),
3372 "budget.max_tokens = 0 should be rejected"
3373 );
3374 cfg = base();
3375 cfg.budget.max_total_tool_calls = Some(0);
3376 assert!(
3377 cfg.validate()
3378 .unwrap_err()
3379 .contains("budget.max_total_tool_calls"),
3380 "budget.max_total_tool_calls = 0 should be rejected"
3381 );
3382 cfg = base();
3383 cfg.budget.max_payout_usd_micros = Some(0);
3384 assert!(
3385 cfg.validate()
3386 .unwrap_err()
3387 .contains("budget.max_payout_usd_micros"),
3388 "budget.max_payout_usd_micros = 0 should be rejected"
3389 );
3390 cfg = base();
3391 cfg.budget.max_tool_calls = std::collections::BTreeMap::from([("db_write".to_owned(), 0u64)]);
3392 assert!(
3393 cfg.validate()
3394 .unwrap_err()
3395 .contains("max_tool_calls[\"db_write\"]"),
3396 "budget.max_tool_calls[db_write] = 0 should be rejected"
3397 );
3398 cfg = base();
3400 cfg.require_identity = true;
3401 cfg.identity_jwks_url = Some("https://example/.well-known/jwks.json".to_owned());
3402 cfg.principal_budget = Some(av_state::BudgetSpec {
3403 max_tokens: Some(0),
3404 ..av_state::BudgetSpec::default()
3405 });
3406 assert!(
3407 cfg.validate()
3408 .unwrap_err()
3409 .contains("principal_budget.max_tokens"),
3410 "principal_budget.max_tokens = 0 should be rejected"
3411 );
3412 cfg = base();
3414 cfg.budget = av_state::BudgetSpec::default();
3415 cfg.validate().unwrap();
3416
3417 cfg = base();
3418 cfg.breaker.window = 0;
3419 assert!(cfg.validate().unwrap_err().contains("breaker.window"));
3420
3421 cfg = base();
3422 cfg.breaker.delta_epsilon = f32::NAN;
3423 assert!(cfg.validate().unwrap_err().contains("delta_epsilon"));
3424 cfg.breaker.delta_epsilon = -0.5;
3425 assert!(cfg.validate().unwrap_err().contains("delta_epsilon"));
3426 }
3427
3428 #[test]
3438 fn operator_env_vars_are_documented() {
3439 let refs = [
3440 include_str!("../../../docs/reference/CONFIGURATION.md"),
3441 include_str!("../../../docs/reference/OPERATIONS.md"),
3442 include_str!("../../../docs/reference/OPENAI-COMPATIBILITY.md"),
3443 include_str!("../../../docs/reference/LIMITS.md"),
3444 ]
3445 .concat();
3446 for var in [
3447 "AV_CONFIG",
3449 "AV_UPSTREAM_URL",
3450 "AV_SIGNING_SEED_FILE",
3452 "AV_BEARER_TOKEN_FILE",
3454 ] {
3455 assert!(
3456 refs.contains(var),
3457 "operator-facing env var `{var}` is not documented in any \
3458 docs/reference/*.md — register item 25 explicitly names the \
3459 undocumented AV_ env var class as the drift signature."
3460 );
3461 }
3462 }
3463
3464 #[test]
3465 fn user_config_path_is_stable() {
3466 let path = user_config_path_from(std::path::Path::new("/home/pat"));
3467 assert_eq!(
3468 path,
3469 std::path::Path::new("/home/pat/.agentvisor/agentvisor.toml")
3470 );
3471 }
3472
3473 #[test]
3484 fn config_search_paths_never_include_the_example_config() {
3485 for path in CONFIG_SEARCH_PATHS {
3486 assert!(
3487 !path.contains("example"),
3488 "CONFIG_SEARCH_PATHS must not auto-discover a `*example*` file: {path:?}. \
3489 See the doc-comment on CONFIG_SEARCH_PATHS — the example is a template, \
3490 not a config, and auto-loading it defeats the wizard-written per-user file."
3491 );
3492 }
3493 assert_eq!(
3498 user_config_path_from(std::path::Path::new("/h")),
3499 std::path::PathBuf::from("/h/.agentvisor/agentvisor.toml"),
3500 "the wizard-written path is contract; changing it breaks every operator \
3501 whose home already has this file"
3502 );
3503 }
3504
3505 #[test]
3509 fn duplicated_chat_path_segment_detection() {
3510 let cfg = |url: &str| HarnessConfig::for_tests(url, "spool", "bridge");
3511 assert_eq!(
3512 cfg("https://api.openai.com/v1").duplicated_chat_path_segment(),
3513 Some("v1")
3514 );
3515 assert_eq!(
3516 cfg("https://api.openai.com/v1/").duplicated_chat_path_segment(),
3517 Some("v1")
3518 );
3519 assert_eq!(
3520 cfg("http://localhost:8080/v1").duplicated_chat_path_segment(),
3521 Some("v1")
3522 );
3523 assert_eq!(cfg("https://api.openai.com").duplicated_chat_path_segment(), None);
3524 assert_eq!(cfg("http://127.0.0.1:11434").duplicated_chat_path_segment(), None);
3525 let mut gemini = cfg("https://generativelanguage.googleapis.com/v1beta/openai");
3527 gemini.upstream_chat_path = "/chat/completions".into();
3528 assert_eq!(gemini.duplicated_chat_path_segment(), None);
3529 let mut azure = cfg("https://r.openai.azure.com/openai");
3531 azure.upstream_chat_path = "/openai/deployments/d/chat/completions".into();
3532 assert_eq!(azure.duplicated_chat_path_segment(), Some("openai"));
3533 assert_eq!(
3535 cfg("http://10.0.0.5:8000/v1/chat/completions").duplicated_chat_path_segment(),
3536 Some("v1/chat/completions")
3537 );
3538 assert_eq!(
3539 cfg("http://10.0.0.5:8000/v1/chat/completions/").duplicated_chat_path_segment(),
3540 Some("v1/chat/completions")
3541 );
3542 }
3543
3544 #[test]
3545 fn bad_configs_rejected() {
3546 assert!(HarnessConfig::from_toml("").is_err()); assert!(HarnessConfig::from_toml(
3548 r#"upstream_url = "https://api"
3549 config_version = 99"#
3550 )
3551 .is_err());
3552 let bad_workflow = HarnessConfig::from_toml(
3558 r#"upstream_url = "https://api"
3559 default_workflow = "sometimes""#,
3560 )
3561 .unwrap_err();
3562 assert!(
3563 bad_workflow.contains("default_workflow"),
3564 "the default_workflow allowlist must be what refuses this config, \
3565 not the upstream_url scheme check via fallthrough; got {bad_workflow}"
3566 );
3567 let bad_capacity = HarnessConfig::from_toml(
3568 r#"upstream_url = "https://api"
3569 worker_channel_capacity = 0"#,
3570 )
3571 .unwrap_err();
3572 assert!(
3573 bad_capacity.contains("worker_channel_capacity"),
3574 "the worker_channel_capacity bounds check must be what refuses this \
3575 config, not the upstream_url scheme check via fallthrough; got {bad_capacity}"
3576 );
3577 }
3578
3579 #[test]
3583 fn future_version_config_reports_version_not_unknown_field() {
3584 let err = HarnessConfig::from_toml(
3585 r#"config_version = 2
3586 upstream_url = "https://api"
3587 future_option_from_v2 = true"#,
3588 )
3589 .unwrap_err();
3590 assert!(err.contains("unsupported config_version 2"), "{err}");
3591 assert!(!err.contains("future_option_from_v2"), "{err}");
3592 }
3593
3594 #[test]
3598 fn worker_channel_capacity_cap_rejects_oversized_values() {
3599 let err = HarnessConfig::from_toml(&format!(
3600 "upstream_url = \"https://api\"\nworker_channel_capacity = {}",
3601 MAX_WORKER_CHANNEL_CAPACITY + 1
3602 ))
3603 .unwrap_err();
3604 assert!(
3605 err.contains("worker_channel_capacity"),
3606 "err should name the offending field: {err}"
3607 );
3608 }
3609
3610 #[test]
3616 fn upstream_url_without_scheme_is_rejected() {
3617 let err = HarnessConfig::from_toml(r#"upstream_url = "openai.internal""#).unwrap_err();
3618 assert!(err.contains("upstream_url"), "{err}");
3619 assert!(err.contains("http"), "{err}");
3620 }
3621
3622 #[test]
3628 fn upstream_url_non_http_scheme_is_rejected() {
3629 let err = HarnessConfig::from_toml(r#"upstream_url = "file:///etc/passwd""#).unwrap_err();
3630 assert!(err.contains("upstream_url"), "{err}");
3631 let err = HarnessConfig::from_toml(r#"upstream_url = "gopher://x""#).unwrap_err();
3632 assert!(err.contains("upstream_url"), "{err}");
3633 assert!(HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).is_ok());
3635 assert!(HarnessConfig::from_toml(r#"upstream_url = "http://gw.local""#).is_ok());
3636 }
3637
3638 #[test]
3643 fn upstream_url_without_host_is_rejected() {
3644 for url in [r#"upstream_url = "http://""#, r#"upstream_url = "https:///v1""#] {
3645 let err = HarnessConfig::from_toml(url).unwrap_err();
3646 assert!(err.contains("no host"), "{err}");
3647 }
3648 }
3649
3650 #[test]
3662 fn shipped_example_config_parses_and_pins_safe_posture() {
3663 let raw = include_str!("../../../config/harness.example.toml");
3664 let config = HarnessConfig::from_toml(raw)
3665 .unwrap_or_else(|error| panic!("shipped example config must validate: {error}"));
3666 assert_eq!(config.listen, "127.0.0.1:8484", "example must bind loopback");
3667 assert!(!config.dashboard_enabled, "example must ship dashboard-off");
3668 assert_eq!(
3669 config.budget.max_tokens,
3670 Some(200_000),
3671 "example must bind a token budget"
3672 );
3673 assert_eq!(config.default_workflow, "signed");
3674 for (name, raw) in [
3675 (
3676 "container",
3677 include_str!("../../../config/harness.container.toml"),
3678 ),
3679 ("docker", include_str!("../../../config/harness.docker.toml")),
3680 ] {
3681 let config = HarnessConfig::from_toml(raw)
3682 .unwrap_or_else(|error| panic!("shipped {name} config must validate: {error}"));
3683 assert_eq!(
3684 config.default_workflow, "signed",
3685 "{name} config must opt into the signed workflow"
3686 );
3687 }
3688 }
3689
3690 #[test]
3705 fn shipped_tool_schemas_parse_as_json() {
3706 for (name, raw) in [
3707 (
3708 "payout.json",
3709 include_str!("../../../config/tool-schemas/payout.json"),
3710 ),
3711 (
3712 "db_write.json",
3713 include_str!("../../../config/tool-schemas/db_write.json"),
3714 ),
3715 (
3716 "deploy.json",
3717 include_str!("../../../config/tool-schemas/deploy.json"),
3718 ),
3719 (
3720 "merge.json",
3721 include_str!("../../../config/tool-schemas/merge.json"),
3722 ),
3723 ] {
3724 let value: serde_json::Value = serde_json::from_str(raw).unwrap_or_else(|error| {
3725 panic!(
3726 "shipped tool schema {name} must parse as JSON — the daemon calls \
3727 serde_json::from_slice on each .json file under tool_schema_dir at \
3728 boot; a parse failure here CrashLoopBackOffs the pod. Error: {error}"
3729 )
3730 });
3731 assert!(
3737 value.is_object(),
3738 "shipped tool schema {name} must be a JSON object at the top level \
3739 (JSON Schema convention); a bare array/scalar would silently accept \
3740 every tool call"
3741 );
3742 }
3743 }
3744
3745 #[test]
3763 fn shipped_kubernetes_configmap_toml_parses_and_pins_safe_posture() {
3764 let yaml = include_str!("../../../deploy/kubernetes/agentvisor-ai.yaml");
3765 let block_header = "agentvisor.toml: |";
3771 let block_start = yaml
3772 .find(block_header)
3773 .unwrap_or_else(|| panic!("K8s manifest must embed a `{block_header}` block"))
3774 + block_header.len();
3775 let after = &yaml[block_start..];
3776 let after = after.trim_start_matches('\n');
3777 let mut toml = String::new();
3781 for line in after.lines() {
3782 if line.is_empty() || line.trim().is_empty() {
3783 toml.push('\n');
3784 continue;
3785 }
3786 if let Some(stripped) = line.strip_prefix(" ") {
3787 toml.push_str(stripped);
3788 toml.push('\n');
3789 } else {
3790 break;
3791 }
3792 }
3793 assert!(
3794 toml.contains("config_version"),
3795 "extracted ConfigMap TOML looks empty; did the block indent change?"
3796 );
3797
3798 let config = HarnessConfig::from_toml(&toml).unwrap_or_else(|error| {
3799 panic!(
3800 "K8s ConfigMap agentvisor.toml block must parse and validate under \
3801 the same strict loader agentvisord uses; a rename/typo here \
3802 CrashLoopBackOffs every pod at boot. Error: {error}\n\n\
3803 Extracted TOML:\n{toml}"
3804 )
3805 });
3806
3807 assert_eq!(
3811 config.default_workflow, "signed",
3812 "K8s ConfigMap must opt into the signed workflow (register item 19); \
3813 the serde default `unsigned` silently mints zero receipts on clean \
3814 traffic"
3815 );
3816 assert!(
3817 !config.dashboard_enabled,
3818 "K8s ConfigMap must ship dashboard-off — the pod binds 0.0.0.0 for \
3819 cluster network reachability, so any workload with Service access \
3820 could otherwise enumerate sessions/costs/receipts (register item 4)"
3821 );
3822 assert!(config.require_identity, "K8s workloads must authenticate callers");
3823 assert!(
3824 !config.allow_wildcard_bind,
3825 "anonymous wildcard binding must not be enabled"
3826 );
3827 assert_eq!(config.audience, "agentvisor-ai");
3828 assert_eq!(
3829 config.identity_hmac_secret_file.as_deref(),
3830 Some("/etc/agentvisor-ai/identity.hmac")
3831 );
3832 assert_eq!(
3833 config.state_backend, "redis",
3834 "production revocations must survive restart"
3835 );
3836 assert_eq!(
3837 config.state_endpoint.as_deref(),
3838 Some("rediss://redis.example.invalid:6379")
3839 );
3840 assert!(yaml.contains("name: AV_STATE_ENDPOINT"));
3841 assert!(yaml.contains("name: agentvisor-ai-state"));
3842 assert!(yaml.contains("key: redis-url"));
3843 }
3844
3845 #[test]
3846 fn shipped_cloudfoundry_config_requires_identity_and_durable_revocations() {
3847 let config = HarnessConfig::from_toml(include_str!("../../../deploy/cloudfoundry/harness.toml"))
3848 .expect("Cloud Foundry configuration must validate");
3849 assert!(config.require_identity);
3850 assert!(!config.dashboard_enabled);
3851 assert!(!config.allow_wildcard_bind);
3852 assert_eq!(config.audience, "agentvisor-ai");
3853 assert_eq!(config.state_backend, "redis");
3854 assert_eq!(
3855 config.state_endpoint.as_deref(),
3856 Some("rediss://redis.example.invalid:6379")
3857 );
3858 let manifest = include_str!("../../../deploy/cloudfoundry/manifest.yml");
3859 assert!(manifest.contains("AV_STATE_ENDPOINT: ((redis_url))"));
3860 }
3861
3862 #[test]
3872 fn shipped_kubernetes_configmap_total_shutdown_fits_termination_grace() {
3873 let yaml = include_str!("../../../deploy/kubernetes/agentvisor-ai.yaml");
3874 let block_header = "agentvisor.toml: |";
3876 let block_start = yaml.find(block_header).unwrap() + block_header.len();
3877 let after = yaml[block_start..].trim_start_matches('\n');
3878 let mut toml = String::new();
3879 for line in after.lines() {
3880 if line.trim().is_empty() {
3881 toml.push('\n');
3882 continue;
3883 }
3884 if let Some(stripped) = line.strip_prefix(" ") {
3885 toml.push_str(stripped);
3886 toml.push('\n');
3887 } else {
3888 break;
3889 }
3890 }
3891 let config = HarnessConfig::from_toml(&toml).unwrap();
3892 assert_total_shutdown_fits(
3893 "K8s manifest ConfigMap",
3894 &config,
3895 extract_yaml_int(yaml, "terminationGracePeriodSeconds:").unwrap(),
3896 require_prestop_sleep_seconds(yaml),
3904 );
3905 }
3906
3907 #[test]
3914 fn shipped_docker_compose_stop_grace_fits_harness_docker_shutdown() {
3915 let compose = include_str!("../../../docker/docker-compose.yml");
3916 let toml = include_str!("../../../config/harness.docker.toml");
3917 let config = HarnessConfig::from_toml(toml).unwrap();
3918 assert_total_shutdown_fits(
3919 "docker-compose.yml / harness.docker.toml",
3920 &config,
3921 extract_yaml_grace_seconds(compose, "stop_grace_period:").unwrap(),
3922 0,
3924 );
3925 }
3926
3927 #[test]
3928 fn shipped_full_docker_config_disables_unauthenticated_dashboard() {
3929 let config = HarnessConfig::from_toml(include_str!("../../../config/harness.docker.toml")).unwrap();
3930 assert!(config.require_identity, "full-stack callers must authenticate");
3931 assert!(
3932 config.enforce_identity_scopes,
3933 "full-stack callers need operation scopes"
3934 );
3935 assert!(
3936 !config.dashboard_enabled,
3937 "Compose peers can reach the listener without the host loopback port mapping"
3938 );
3939 assert!(
3940 !config.allow_wildcard_bind,
3941 "the authenticated full stack must not bypass the wildcard safety guards"
3942 );
3943 }
3944
3945 #[test]
3950 fn shipped_docker_compose_minimal_stop_grace_fits_container_shutdown() {
3951 let compose = include_str!("../../../docker/docker-compose.minimal.yml");
3952 let toml = include_str!("../../../config/harness.container.toml");
3953 let config = HarnessConfig::from_toml(toml).unwrap();
3954 assert_total_shutdown_fits(
3955 "docker-compose.minimal.yml / harness.container.toml",
3956 &config,
3957 extract_yaml_grace_seconds(compose, "stop_grace_period:").unwrap(),
3958 0,
3959 );
3960 }
3961
3962 fn assert_total_shutdown_fits(
3980 label: &str,
3981 config: &HarnessConfig,
3982 grace_seconds: u64,
3983 pre_signal_seconds: u64,
3984 ) {
3985 let drain = config.effective_drain_timeout().as_secs();
3986 let total = drain
3987 .saturating_add(WORKER_FINALIZE_PHASE_SECS)
3988 .saturating_add(WORKER_FINALIZE_PHASE_SECS)
3989 .saturating_add(OTEL_FLUSH_SECS)
3990 .saturating_add(config.shutdown_ready_drain_s)
3991 .saturating_add(pre_signal_seconds);
3992 assert!(
3993 total <= grace_seconds,
3994 "{label}: worst-case shutdown budget {total}s (drain {drain}s + \
3995 worker {WORKER_FINALIZE_PHASE_SECS}s + finalize {WORKER_FINALIZE_PHASE_SECS}s + \
3996 OTel {OTEL_FLUSH_SECS}s + shutdown_ready_drain_s {}s + pre-signal {}s) \
3997 exceeds the grace period {grace_seconds}s — \
3998 kubelet/dockerd will SIGKILL mid-finalize and drop receipts on \
3999 rolling deploy. Either lower shutdown_drain_timeout_s / \
4000 shutdown_ready_drain_s / the preStop sleep in the config or raise \
4001 the grace period in the deployment manifest.",
4002 config.shutdown_ready_drain_s,
4003 pre_signal_seconds,
4004 );
4005 }
4006
4007 fn extract_yaml_int(yaml: &str, key: &str) -> Option<u64> {
4008 for line in yaml.lines() {
4009 let trimmed = line.trim_start();
4010 if let Some(rest) = trimmed.strip_prefix(key) {
4011 return rest.trim().parse().ok();
4012 }
4013 }
4014 None
4015 }
4016
4017 fn extract_prestop_sleep_seconds(yaml: &str) -> Option<u64> {
4034 let (prestop_line_idx, prestop_indent) = yaml.lines().enumerate().find_map(|(i, line)| {
4039 let trimmed = line.trim_start();
4040 if trimmed.starts_with('#') {
4041 return None;
4042 }
4043 if trimmed.starts_with("preStop:") {
4044 Some((i, line.len() - trimmed.len()))
4045 } else {
4046 None
4047 }
4048 })?;
4049 let mut scope = String::new();
4060 for (i, line) in yaml.lines().enumerate().skip(prestop_line_idx) {
4061 if i == prestop_line_idx {
4062 scope.push_str(line);
4063 scope.push('\n');
4064 continue;
4065 }
4066 let trimmed = line.trim_start();
4067 if trimmed.is_empty() || trimmed.starts_with('#') {
4068 scope.push_str(line);
4069 scope.push('\n');
4070 continue;
4071 }
4072 let indent = line.len() - trimmed.len();
4073 if indent <= prestop_indent {
4074 break;
4075 }
4076 scope.push_str(line);
4077 scope.push('\n');
4078 }
4079 for quote in ['"', '\''] {
4082 let marker = format!("{quote}sleep ");
4083 if let Some(hit) = scope.find(&marker) {
4084 let rest = &scope[hit + marker.len()..];
4085 let n: String = rest.chars().take_while(char::is_ascii_digit).collect();
4086 if let Ok(seconds) = n.parse() {
4087 return Some(seconds);
4088 }
4089 }
4090 }
4091 for line in scope.lines() {
4095 let stripped = line.trim_start().trim_start_matches('-').trim();
4096 if let Some(rest) = stripped.strip_prefix("sleep ") {
4097 let n: String = rest.chars().take_while(char::is_ascii_digit).collect();
4098 if let Ok(seconds) = n.parse() {
4099 return Some(seconds);
4100 }
4101 }
4102 }
4103 None
4104 }
4105
4106 fn require_prestop_sleep_seconds(yaml: &str) -> u64 {
4115 let has_prestop = yaml.lines().any(|line| {
4120 let trimmed = line.trim_start();
4121 !trimmed.starts_with('#') && trimmed.starts_with("preStop:")
4122 });
4123 if !has_prestop {
4124 return 0;
4125 }
4126 extract_prestop_sleep_seconds(yaml).expect(
4127 "shipped K8s manifest has a preStop hook but extract_prestop_sleep_seconds \
4128 could not parse its sleep duration — either update the parser to handle \
4129 the new shape (inline vs block YAML, quoting style), or if preStop no \
4130 longer uses `sleep`, delete this test and its helper. Silently returning \
4131 0 would defeat the pin's own purpose: catching a preStop-time bump that \
4132 pushes total shutdown past the grace period.",
4133 )
4134 }
4135
4136 #[test]
4141 fn extract_prestop_scope_ignores_downstream_sleep_after_the_block() {
4142 let yaml = "\
4148apiVersion: apps/v1
4149kind: Deployment
4150spec:
4151 template:
4152 spec:
4153 containers:
4154 - name: main
4155 lifecycle:
4156 preStop:
4157 exec:
4158 command:
4159 - sh
4160 - -c
4161 - sleep 60
4162 resources:
4163 limits:
4164 cpu: \"2\"
4165 - name: sidecar
4166 command: [\"sh\", \"-c\", \"sleep 3\"]
4167";
4168 assert_eq!(
4169 extract_prestop_sleep_seconds(yaml),
4170 Some(60),
4171 "the parser must scope to the preStop block and ignore \
4172 downstream containers' inline sleep commands"
4173 );
4174 }
4175
4176 #[test]
4177 fn extract_prestop_scope_ignores_downstream_probe_sleep() {
4178 let yaml = "\
4194spec:
4195 containers:
4196 - name: main
4197 lifecycle:
4198 preStop:
4199 exec:
4200 command: ['sh', '-c', 'sleep 3']
4201 livenessProbe:
4202 exec:
4203 command: [\"sh\", \"-c\", \"sleep 300\"]
4204";
4205 assert_eq!(extract_prestop_sleep_seconds(yaml), Some(3));
4206 }
4207
4208 #[test]
4209 fn require_prestop_skips_commented_out_prestop_line() {
4210 let yaml = "\
4214spec:
4215 containers:
4216 - name: main
4217 # NOTE: do not add preStop: to this container, see runbook.
4218 livenessProbe:
4219 exec:
4220 command: [\"sh\", \"-c\", \"sleep 90\"]
4221";
4222 assert_eq!(
4223 require_prestop_sleep_seconds(yaml),
4224 0,
4225 "a comment mentioning preStop: is NOT a real hook; require_prestop \
4226 must return 0, not the downstream probe's sleep"
4227 );
4228 }
4229
4230 fn extract_yaml_grace_seconds(yaml: &str, key: &str) -> Option<u64> {
4234 for line in yaml.lines() {
4235 let trimmed = line.trim_start();
4236 if let Some(rest) = trimmed.strip_prefix(key) {
4237 let raw = rest.trim().trim_end_matches('s');
4238 return raw.parse().ok();
4239 }
4240 }
4241 None
4242 }
4243
4244 #[test]
4253 fn shipped_systemd_unit_stop_timeout_fits_default_shutdown() {
4254 let unit = include_str!("../../../deploy/systemd/agentvisor-ai.service");
4255 let stop_timeout = unit
4256 .lines()
4257 .find_map(|line| line.trim().strip_prefix("TimeoutStopSec="))
4258 .and_then(|raw| raw.trim().trim_end_matches('s').parse::<u64>().ok())
4259 .expect("unit file must set TimeoutStopSec explicitly");
4260 let config =
4262 HarnessConfig::from_toml("config_version = 1\nupstream_url = \"https://api.openai.com\"")
4263 .unwrap();
4264 assert_total_shutdown_fits(
4265 "deploy/systemd/agentvisor-ai.service / avctl-init defaults",
4266 &config,
4267 stop_timeout,
4268 0,
4271 );
4272 }
4273
4274 #[test]
4279 fn empty_tool_upstream_url_is_rejected() {
4280 let err =
4281 HarnessConfig::from_toml("upstream_url = \"https://api\"\ntool_upstream_url = \"\"").unwrap_err();
4282 assert!(err.contains("tool_upstream_url"), "{err}");
4283 assert!(err.contains("omit"), "should point at omitting the field: {err}");
4284 assert!(HarnessConfig::from_toml(
4285 "upstream_url = \"https://api\"\ntool_upstream_url = \"http://tools/mcp\"",
4286 )
4287 .is_ok());
4288 }
4289
4290 #[test]
4303 fn empty_or_whitespace_payout_field_is_rejected() {
4304 for hostile in [
4305 "",
4306 " ",
4307 " ",
4308 "\t",
4309 " amount_usd",
4310 "amount_usd ",
4311 "\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", ] {
4324 let toml = format!("upstream_url = \"https://api.openai.com\"\npayout_field = \"{hostile}\"\n");
4340 let err = match HarnessConfig::from_toml(&toml) {
4341 Err(e) => e,
4342 Ok(_) => panic!("must refuse payout_field = {hostile:?}"),
4343 };
4344 assert!(err.contains("payout_field"), "{hostile:?} => {err}");
4345 }
4346 assert!(HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).is_ok());
4348 assert!(HarnessConfig::from_toml(
4350 r#"upstream_url = "https://api.openai.com"
4351 payout_field = "amount_usd""#,
4352 )
4353 .is_ok());
4354 }
4355
4356 #[test]
4361 fn hostless_tool_upstream_url_is_rejected() {
4362 for url in ["http://", "https:///mcp", "http://?x=1"] {
4363 let err = HarnessConfig::from_toml(&format!(
4364 "upstream_url = \"https://api\"\ntool_upstream_url = \"{url}\""
4365 ))
4366 .unwrap_err();
4367 assert!(
4368 err.contains("tool_upstream_url") && err.contains("no host"),
4369 "{url}: {err}"
4370 );
4371 }
4372 }
4373
4374 #[test]
4382 fn upstream_url_rejects_query_and_fragment() {
4383 for url in ["http://127.0.0.1:9000/#oops", "https://api.example.com/?tenant=1"] {
4384 let err = HarnessConfig::from_toml(&format!("upstream_url = \"{url}\"")).unwrap_err();
4385 assert!(
4386 err.contains("upstream_url") && err.contains("query or fragment"),
4387 "{url}: {err}"
4388 );
4389 }
4390 HarnessConfig::from_toml(
4392 "upstream_url = \"https://api\"\ntool_upstream_url = \"http://tools/mcp?fmt=json\"",
4393 )
4394 .unwrap();
4395 }
4396
4397 #[test]
4403 fn wildcard_bind_with_dashboard_requires_explicit_acknowledgment() {
4404 let err = HarnessConfig::from_toml(
4405 r#"upstream_url = "https://api"
4406 listen = "0.0.0.0:8484"
4407 require_identity = true
4408 identity_hmac_secret_file = "/etc/av/hmac"
4409 dashboard_enabled = true"#,
4410 )
4411 .unwrap_err();
4412 assert!(
4413 err.contains("dashboard") && err.contains("every interface"),
4414 "wildcard + dashboard must refuse: {err}"
4415 );
4416 HarnessConfig::from_toml(
4418 r#"upstream_url = "https://api"
4419 listen = "0.0.0.0:8484"
4420 require_identity = true
4421 identity_hmac_secret_file = "/etc/av/hmac"
4422 dashboard_enabled = true
4423 allow_wildcard_bind = true"#,
4424 )
4425 .unwrap();
4426 HarnessConfig::from_toml(
4427 r#"upstream_url = "https://api"
4428 listen = "0.0.0.0:8484"
4429 require_identity = true
4430 identity_hmac_secret_file = "/etc/av/hmac"
4431 dashboard_enabled = false"#,
4432 )
4433 .unwrap();
4434 }
4435
4436 #[test]
4439 fn seconds_intervals_reject_absurdly_large_values() {
4440 let err = HarnessConfig::from_toml(&format!(
4441 "upstream_url = \"https://api\"\nsession_idle_close_s = {}",
4442 MAX_SECONDS_INTERVAL + 1
4443 ))
4444 .unwrap_err();
4445 assert!(
4446 err.contains("session_idle_close_s"),
4447 "err should name the offending field: {err}"
4448 );
4449 assert!(err.contains("milliseconds"), "hint should mention ms: {err}");
4450 }
4451
4452 #[test]
4456 fn upstream_read_timeout_rejects_zero() {
4457 let err = HarnessConfig::from_toml(
4458 r#"upstream_url = "https://api"
4459 upstream_read_timeout_s = 0"#,
4460 )
4461 .unwrap_err();
4462 assert!(
4463 err.contains("upstream_read_timeout_s"),
4464 "err should name the offending field: {err}"
4465 );
4466 assert!(err.contains("omit"), "err should point at the default: {err}");
4467 assert!(HarnessConfig::from_toml(
4469 r#"upstream_url = "https://api"
4470 upstream_read_timeout_s = 1"#,
4471 )
4472 .is_ok());
4473 }
4474
4475 #[test]
4476 fn mcp_request_timeout_is_positive_and_bounded() {
4477 let default = HarnessConfig::from_toml(r#"upstream_url = "https://api""#).unwrap();
4478 assert_eq!(
4479 default
4480 .mcp_request_timeout_s
4481 .unwrap_or(DEFAULT_MCP_REQUEST_TIMEOUT_S),
4482 60
4483 );
4484 for invalid in [0, MAX_SECONDS_INTERVAL + 1] {
4485 let error = HarnessConfig::from_toml(&format!(
4486 "upstream_url = \"https://api\"\nmcp_request_timeout_s = {invalid}"
4487 ))
4488 .unwrap_err();
4489 assert!(error.contains("mcp_request_timeout_s"), "{error}");
4490 }
4491 for valid in [1, DEFAULT_MCP_REQUEST_TIMEOUT_S, MAX_SECONDS_INTERVAL] {
4492 let config = HarnessConfig::from_toml(&format!(
4493 "upstream_url = \"https://api\"\nmcp_request_timeout_s = {valid}"
4494 ))
4495 .unwrap();
4496 assert_eq!(config.mcp_request_timeout_s, Some(valid));
4497 }
4498 }
4499
4500 #[test]
4505 fn round_30_f1_scope_enforcement_requires_identity_requirement() {
4506 let err = HarnessConfig::from_toml(
4507 r#"upstream_url = "https://api.openai.com"
4508 require_identity = false
4509 enforce_identity_scopes = true"#,
4510 )
4511 .unwrap_err();
4512 assert!(
4513 err.contains("enforce_identity_scopes"),
4514 "err should name the flag: {err}"
4515 );
4516 assert!(
4517 err.contains("require_identity"),
4518 "err should name the correlate: {err}"
4519 );
4520 assert!(HarnessConfig::from_toml(
4522 r#"upstream_url = "https://api.openai.com"
4523 require_identity = false
4524 enforce_identity_scopes = false"#,
4525 )
4526 .is_ok());
4527 }
4528
4529 #[test]
4532 fn drain_timeout_derives_from_upstream_read_timeout() {
4533 let base = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4534 assert_eq!(base.effective_drain_timeout().as_secs(), 65);
4535 let mut derived = base.clone();
4536 derived.upstream_read_timeout_s = Some(300);
4537 assert_eq!(derived.effective_drain_timeout().as_secs(), 305);
4538 derived.mcp_request_timeout_s = Some(600);
4539 assert_eq!(derived.effective_drain_timeout().as_secs(), 605);
4540 let mut short = base.clone();
4542 short.upstream_read_timeout_s = Some(10);
4543 assert_eq!(short.effective_drain_timeout().as_secs(), 65);
4544 short.mcp_request_timeout_s = Some(10);
4545 assert_eq!(short.effective_drain_timeout().as_secs(), 30);
4546 let mut explicit = derived;
4548 explicit.shutdown_drain_timeout_s = Some(110);
4549 assert_eq!(explicit.effective_drain_timeout().as_secs(), 110);
4550 let mut zero = base;
4551 zero.shutdown_drain_timeout_s = Some(0);
4552 assert!(zero.validate().unwrap_err().contains("shutdown_drain_timeout_s"));
4553 }
4554
4555 #[test]
4562 fn wildcard_bind_without_identity_is_refused_unless_explicitly_allowed() {
4563 let err = HarnessConfig::from_toml(
4565 r#"upstream_url = "https://api.openai.com"
4566 listen = "0.0.0.0:8484"
4567 require_identity = false"#,
4568 )
4569 .unwrap_err();
4570 assert!(err.contains("every interface"), "err should name the risk: {err}");
4571 assert!(
4572 err.contains("allow_wildcard_bind"),
4573 "err should name the escape hatch: {err}"
4574 );
4575
4576 let err = HarnessConfig::from_toml(
4578 r#"upstream_url = "https://api.openai.com"
4579 listen = "[::]:8484"
4580 require_identity = false"#,
4581 )
4582 .unwrap_err();
4583 assert!(err.contains("every interface"), "IPv6 wildcard: {err}");
4584
4585 for listen in [
4589 "[::0]:8484",
4590 "[0:0:0:0:0:0:0:0]:8484",
4591 "[0000::]:8484",
4592 "[::ffff:0.0.0.0]:8484",
4595 "[::ffff:0:0]:8484",
4596 ] {
4597 let err = HarnessConfig::from_toml(&format!(
4598 r#"upstream_url = "https://api.openai.com"
4599 listen = "{listen}"
4600 require_identity = false"#,
4601 ))
4602 .unwrap_err();
4603 assert!(
4604 err.contains("every interface"),
4605 "non-canonical wildcard {listen}: {err}"
4606 );
4607 }
4608
4609 HarnessConfig::from_toml(
4611 r#"upstream_url = "https://api.openai.com"
4612 listen = "127.0.0.1:8484"
4613 require_identity = false"#,
4614 )
4615 .unwrap()
4616 .validate()
4617 .unwrap();
4618
4619 HarnessConfig::from_toml(
4621 r#"upstream_url = "https://api.openai.com"
4622 listen = "0.0.0.0:8484"
4623 require_identity = false
4624 allow_wildcard_bind = true"#,
4625 )
4626 .unwrap()
4627 .validate()
4628 .unwrap();
4629
4630 HarnessConfig::from_toml(
4637 r#"upstream_url = "https://api.openai.com"
4638 listen = "0.0.0.0:8484"
4639 require_identity = true
4640 dashboard_enabled = false
4641 identity_jwks_url = "https://idp/keys.json""#,
4642 )
4643 .unwrap()
4644 .validate()
4645 .unwrap();
4646 }
4647
4648 #[test]
4652 fn round_30_f2_url_scheme_allowlist_enforced() {
4653 let err = HarnessConfig::from_toml(
4655 r#"upstream_url = "https://api"
4656 identity_jwks_url = "auth.internal/jwks""#,
4657 )
4658 .unwrap_err();
4659 assert!(err.contains("identity_jwks_url"), "{err}");
4660 assert!(err.contains("http://") || err.contains("https://"), "{err}");
4661 let err = HarnessConfig::from_toml(
4663 r#"upstream_url = "https://api"
4664 identity_jwks_url = "file:///etc/passwd""#,
4665 )
4666 .unwrap_err();
4667 assert!(err.contains("identity_jwks_url"), "{err}");
4668 let err = HarnessConfig::from_toml(
4670 r#"upstream_url = "https://api"
4671 state_backend = "redis"
4672 state_endpoint = "http://cache""#,
4673 )
4674 .unwrap_err();
4675 assert!(err.contains("state_endpoint"), "{err}");
4676 let err = HarnessConfig::from_toml(
4681 r#"upstream_url = "https://api"
4682 state_backend = "redis"
4683 state_endpoint = "redis://a:6379,http://b:6379""#,
4684 )
4685 .unwrap_err();
4686 assert!(err.contains("state_endpoint"), "{err}");
4687 HarnessConfig::from_toml(
4689 r#"upstream_url = "https://api"
4690 state_backend = "redis"
4691 state_endpoint = "rediss://a:6379,rediss://b:6380""#,
4692 )
4693 .unwrap();
4694 for endpoint in [
4695 "redis://a:6379,rediss://b:6380",
4696 "rediss://a:6379/#insecure",
4697 "redis://a:6379,",
4698 "",
4699 "http://user:fixture-secret@cache:6379",
4700 ] {
4701 let err = HarnessConfig::from_toml(&format!(
4702 "upstream_url = \"https://api\"\nstate_backend = \"redis\"\nstate_endpoint = {endpoint:?}"
4703 ))
4704 .unwrap_err();
4705 assert!(err.contains("state_endpoint"), "{err}");
4706 assert!(
4707 !err.contains("fixture-secret"),
4708 "configuration errors must not expose credentials"
4709 );
4710 }
4711 HarnessConfig::from_toml(
4714 r#"upstream_url = "https://api"
4715 state_backend = "redis"
4716 state_endpoint = "redis+unix:/tmp/redis.sock""#,
4717 )
4718 .unwrap();
4719 let err = HarnessConfig::from_toml(
4723 r#"upstream_url = "https://api"
4724 max_request_bytes = 0"#,
4725 )
4726 .unwrap_err();
4727 assert!(err.contains("max_request_bytes"), "{err}");
4728 let err = HarnessConfig::from_toml(
4730 r#"upstream_url = "https://api"
4731 bridge_backend = "nats"
4732 bridge_endpoint = "http://bus""#,
4733 )
4734 .unwrap_err();
4735 assert!(err.contains("bridge_endpoint"), "{err}");
4736 let err = HarnessConfig::from_toml(
4738 r#"upstream_url = "https://api"
4739 vector_backend = "qdrant"
4740 qdrant_url = "vectors.internal:6333""#,
4741 )
4742 .unwrap_err();
4743 assert!(err.contains("qdrant_url"), "{err}");
4744 assert!(HarnessConfig::from_toml(
4746 r#"upstream_url = "https://api"
4747 identity_jwks_url = "https://auth.internal/jwks"
4748 state_backend = "redis"
4749 state_endpoint = "redis://cache:6379"
4750 bridge_backend = "nats"
4751 bridge_endpoint = "nats://bus:4222"
4752 vector_backend = "qdrant"
4753 qdrant_url = "https://vectors.internal:6333""#,
4754 )
4755 .is_ok());
4756 }
4757
4758 #[test]
4763 fn round_31_f1_local_fs_paths_empty_rejected() {
4764 let err = HarnessConfig::from_toml(
4765 r#"upstream_url = "https://api"
4766 atif_spool_dir = """#,
4767 )
4768 .unwrap_err();
4769 assert!(err.contains("atif_spool_dir"), "{err}");
4770 let err = HarnessConfig::from_toml(
4771 r#"upstream_url = "https://api"
4772 bridge_data_dir = """#,
4773 )
4774 .unwrap_err();
4775 assert!(err.contains("bridge_data_dir"), "{err}");
4776 }
4777
4778 #[test]
4783 fn round_31_f2_scope_names_rejected_empty_or_whitespaced() {
4784 let err = HarnessConfig::from_toml(
4786 r#"upstream_url = "https://api"
4787 require_identity = true
4788 identity_hmac_secret_file = "/tmp/secret"
4789 enforce_identity_scopes = true
4790 chat_scope = """#,
4791 )
4792 .unwrap_err();
4793 assert!(err.contains("chat_scope"), "{err}");
4794 let err = HarnessConfig::from_toml(
4796 r#"upstream_url = "https://api"
4797 require_identity = true
4798 identity_hmac_secret_file = "/tmp/secret"
4799 enforce_identity_scopes = true
4800 chat_scope = "chat write""#,
4801 )
4802 .unwrap_err();
4803 assert!(err.contains("chat_scope"), "{err}");
4804 let err = HarnessConfig::from_toml(
4806 r#"upstream_url = "https://api"
4807 require_identity = true
4808 identity_hmac_secret_file = "/tmp/secret"
4809 enforce_identity_scopes = true
4810 session_close_scope = "close\tsession""#,
4811 )
4812 .unwrap_err();
4813 assert!(err.contains("session_close_scope"), "{err}");
4814 assert!(HarnessConfig::from_toml(
4816 r#"upstream_url = "https://api"
4817 require_identity = true
4818 identity_hmac_secret_file = "/tmp/secret"
4819 enforce_identity_scopes = true
4820 chat_scope = "chat:write"
4821 session_close_scope = "session:close"
4822 session_promote_scope = "session:promote""#,
4823 )
4824 .is_ok());
4825 }
4826}
4827
4828#[cfg(any(test, feature = "test-support"))]
4839#[path = "config_testkit.rs"]
4840mod testkit;