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_REQUEST_BYTES_CAP: usize = 512 * 1024 * 1024;
20
21pub const MAX_ONNX_DIMENSION: usize = 16_384;
25
26pub const MAX_SECONDS_INTERVAL: u64 = 24 * 60 * 60;
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct HarnessConfig {
40 #[serde(default = "default_config_version")]
42 pub config_version: u32,
43 #[serde(default = "default_listen")]
45 pub listen: String,
46 pub upstream_url: String,
48 #[serde(default)]
51 pub tool_upstream_url: Option<String>,
52 #[serde(default)]
54 pub upstream_http2_prior_knowledge: bool,
55 #[serde(default)]
57 pub upstream_read_timeout_s: Option<u64>,
58 #[serde(default = "default_chat_path")]
62 pub upstream_chat_path: String,
63 #[serde(default)]
66 pub upstream_api_key_env: Option<String>,
67 #[serde(default)]
70 pub upstream_api_key_file: Option<String>,
71 #[serde(default = "default_auth_header")]
74 pub upstream_auth_header: String,
75 #[serde(default = "default_auth_scheme")]
78 pub upstream_auth_scheme: String,
79 #[serde(default)]
84 pub upstream_authorization_passthrough: bool,
85 #[serde(default)]
88 pub tool_upstream_bearer_env: Option<String>,
89 #[serde(default)]
92 pub tool_upstream_bearer_file: Option<String>,
93 #[serde(default)]
96 pub require_identity: bool,
97 #[serde(default = "default_audience")]
99 pub audience: String,
100 #[serde(default)]
102 pub identity_jwks_url: Option<String>,
103 #[serde(default = "default_jwks_refresh")]
105 pub identity_jwks_refresh_s: u64,
106 #[serde(default)]
108 pub identity_allowed_issuers: Vec<String>,
109 #[serde(default)]
111 pub identity_hmac_secret_file: Option<String>,
112 #[serde(default = "default_hmac_kid")]
114 pub identity_hmac_kid: String,
115 #[serde(default)]
133 pub enforce_identity_scopes: bool,
134 #[serde(default = "default_chat_scope")]
136 pub chat_scope: String,
137 #[serde(default = "default_close_scope")]
139 pub session_close_scope: String,
140 #[serde(default = "default_promote_scope")]
142 pub session_promote_scope: String,
143 #[serde(default = "default_workflow")]
146 pub default_workflow: String,
147 #[serde(default = "default_consequential_tools")]
150 pub consequential_tools: Vec<String>,
151 #[serde(default = "default_tool_schema_dir")]
153 pub tool_schema_dir: Option<String>,
154 #[serde(default = "default_true")]
156 pub require_tool_schema: bool,
157 #[serde(default = "default_wasm_policies")]
159 pub wasm_policy_paths: Vec<String>,
160 #[serde(default = "default_idle")]
162 pub session_idle_close_s: u64,
163 #[serde(default = "default_spool")]
165 pub atif_spool_dir: String,
166 #[serde(default = "default_bridge")]
168 pub bridge_data_dir: String,
169 #[serde(default = "default_bridge_backend")]
171 pub bridge_backend: String,
172 #[serde(default = "default_bridge_manifest")]
174 pub bridge_manifest_path: String,
175 #[serde(default)]
183 pub bridge_endpoint: Option<String>,
184 #[serde(default = "default_state_backend")]
186 pub state_backend: String,
187 #[serde(default)]
190 pub state_endpoint: Option<String>,
191 #[serde(default = "default_embedder_backend")]
193 pub embedder_backend: String,
194 #[serde(default)]
196 pub onnx_model_path: Option<String>,
197 #[serde(default)]
199 pub onnx_tokenizer_path: Option<String>,
200 #[serde(default = "default_onnx_dimension")]
202 pub onnx_dimension: usize,
203 #[serde(default = "default_vector_backend")]
205 pub vector_backend: String,
206 #[serde(default)]
208 pub qdrant_url: Option<String>,
209 #[serde(default = "default_qdrant_collection")]
211 pub qdrant_collection: String,
212 #[serde(default = "default_channel_cap")]
214 pub worker_channel_capacity: usize,
215 #[serde(default)]
217 pub strict_stage_budget: bool,
218 #[serde(default)]
220 pub breaker: av_loopdetect::BreakerConfig,
221 #[serde(default = "default_compression")]
223 pub compression_enabled: bool,
224 #[serde(default)]
226 pub budget: av_state::BudgetSpec,
227 #[serde(default = "default_reconcile_tick")]
229 pub reconcile_tick_s: u64,
230 #[serde(default = "default_max_request_bytes")]
239 pub max_request_bytes: usize,
240
241 #[serde(default = "default_dashboard_enabled")]
254 pub dashboard_enabled: bool,
255}
256
257fn default_config_version() -> u32 {
258 CONFIG_VERSION
259}
260fn default_listen() -> String {
261 "127.0.0.1:8484".to_owned()
262}
263fn default_audience() -> String {
264 "agentvisor-ai".to_owned()
265}
266fn default_chat_path() -> String {
267 "/v1/chat/completions".to_owned()
268}
269fn default_auth_header() -> String {
270 "authorization".to_owned()
271}
272fn default_auth_scheme() -> String {
273 "Bearer".to_owned()
274}
275fn default_jwks_refresh() -> u64 {
276 300
277}
278fn default_hmac_kid() -> String {
279 "dev-hmac".to_owned()
280}
281fn default_chat_scope() -> String {
282 "chat:write".to_owned()
283}
284fn default_close_scope() -> String {
285 "session:close".to_owned()
286}
287fn default_promote_scope() -> String {
288 "session:promote".to_owned()
289}
290fn default_workflow() -> String {
291 crate::session::Workflow::Unsigned.as_str().to_owned()
292}
293fn default_consequential_tools() -> Vec<String> {
294 ["db_write", "payout", "merge", "deploy"]
295 .into_iter()
296 .map(str::to_owned)
297 .collect()
298}
299fn default_tool_schema_dir() -> Option<String> {
300 Some("config/tool-schemas".to_owned())
301}
302fn default_true() -> bool {
303 true
304}
305fn default_wasm_policies() -> Vec<String> {
306 vec!["config/policies/payload_limit.wat".to_owned()]
307}
308fn default_idle() -> u64 {
309 900
310}
311fn default_spool() -> String {
312 "spool/atif".to_owned()
313}
314fn default_bridge() -> String {
315 "data/bridge".to_owned()
316}
317fn default_bridge_backend() -> String {
318 "embedded".to_owned()
319}
320fn default_bridge_manifest() -> String {
321 "manifests/bridge.example.yaml".to_owned()
322}
323fn default_state_backend() -> String {
324 "memory".to_owned()
325}
326fn default_embedder_backend() -> String {
327 "hash".to_owned()
328}
329fn default_onnx_dimension() -> usize {
330 384
331}
332fn default_vector_backend() -> String {
333 "memory".to_owned()
334}
335fn default_qdrant_collection() -> String {
336 "agent_steps".to_owned()
337}
338fn default_channel_cap() -> usize {
339 32_768
340}
341fn default_compression() -> bool {
342 true
343}
344fn default_reconcile_tick() -> u64 {
345 5
346}
347fn default_max_request_bytes() -> usize {
348 4 * 1024 * 1024
349}
350fn default_dashboard_enabled() -> bool {
351 true
352}
353
354#[derive(Debug, Clone, PartialEq, Eq)]
356pub enum ConfigSource {
357 File(std::path::PathBuf),
359 BuiltIn,
361}
362
363impl std::fmt::Display for ConfigSource {
364 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
365 match self {
366 Self::File(path) => write!(f, "{}", path.display()),
367 Self::BuiltIn => f.write_str("built-in defaults (zero-config)"),
368 }
369 }
370}
371
372pub const CONFIG_SEARCH_PATHS: [&str; 3] = [
376 "agentvisor.toml",
377 "config/harness.toml",
378 "config/harness.example.toml",
379];
380
381pub fn user_config_path_from(home: &std::path::Path) -> std::path::PathBuf {
383 home.join(".agentvisor").join("agentvisor.toml")
384}
385
386pub fn user_config_path() -> Option<std::path::PathBuf> {
389 #[allow(deprecated)] std::env::home_dir().map(|home| user_config_path_from(&home))
391}
392
393pub fn resolve_config_source() -> Result<ConfigSource, String> {
400 if let Some(path) = std::env::var_os("AV_CONFIG") {
401 if !path.is_empty() {
404 let path = std::path::PathBuf::from(path);
405 if !path.is_file() {
406 return Err(format!(
407 "AV_CONFIG points to {} which does not exist or is not a file",
408 path.display()
409 ));
410 }
411 return Ok(ConfigSource::File(path));
412 }
413 }
414 for candidate in CONFIG_SEARCH_PATHS {
415 let path = std::path::Path::new(candidate);
416 if path.is_file() {
417 return Ok(ConfigSource::File(path.to_path_buf()));
418 }
419 }
420 if let Some(path) = user_config_path() {
421 if path.is_file() {
422 return Ok(ConfigSource::File(path));
423 }
424 }
425 Ok(ConfigSource::BuiltIn)
426}
427
428pub fn load_config() -> Result<(HarnessConfig, ConfigSource), String> {
430 let source = resolve_config_source()?;
431 let mut config = match &source {
432 ConfigSource::File(path) => {
433 let text = std::fs::read_to_string(path)
434 .map_err(|error| format!("read harness config {}: {error}", path.display()))?;
435 HarnessConfig::from_toml_unvalidated(&text)
436 .map_err(|error| format!("{}: {error}", path.display()))?
437 }
438 ConfigSource::BuiltIn => HarnessConfig::builtin()?,
439 };
440 config.apply_env_overrides();
441 if config.upstream_url.is_empty() && source == ConfigSource::BuiltIn {
442 return Err(format!(
443 "no configuration found and AV_UPSTREAM_URL is not set.\n\
444 Quick start (pick one):\n\
445 \x20 avctl # guided setup\n\
446 \x20 avctl init --preset openai # write an annotated agentvisor.toml\n\
447 \x20 AV_UPSTREAM_URL=http://127.0.0.1:11434 agentvisord # zero-config\n\
448 Searched: $AV_CONFIG, {}, ~/.agentvisor/agentvisor.toml",
449 CONFIG_SEARCH_PATHS.join(", ")
450 ));
451 }
452 config.validate().map_err(|error| format!("{source}: {error}"))?;
453 Ok((config, source))
454}
455
456impl HarnessConfig {
457 pub fn from_toml(s: &str) -> Result<Self, String> {
459 Self::check_declared_version(s)?;
460 let cfg: Self = toml::from_str(s).map_err(|e| format!("config parse: {e}"))?;
461 cfg.validate()?;
462 Ok(cfg)
463 }
464
465 pub fn from_toml_unvalidated(s: &str) -> Result<Self, String> {
468 Self::check_declared_version(s)?;
469 toml::from_str(s).map_err(|e| format!("config parse: {e}"))
470 }
471
472 fn check_declared_version(s: &str) -> Result<(), String> {
477 let loose: toml::Value = toml::from_str(s).map_err(|e| format!("config parse: {e}"))?;
478 if let Some(version) = loose.get("config_version") {
479 let declared = version.as_integer().unwrap_or(-1);
480 if declared != i64::from(CONFIG_VERSION) {
481 return Err(format!(
482 "unsupported config_version {version} (this build supports {CONFIG_VERSION})",
483 ));
484 }
485 }
486 Ok(())
487 }
488
489 pub fn builtin() -> Result<Self, String> {
493 toml::from_str("upstream_url = \"\"").map_err(|e| format!("built-in config: {e}"))
494 }
495
496 pub fn uses_default_manifest_path(&self) -> bool {
501 self.bridge_manifest_path == default_bridge_manifest()
502 }
503
504 pub fn uses_default_tool_schema_dir(&self) -> bool {
507 self.tool_schema_dir == default_tool_schema_dir()
508 }
509
510 pub fn is_default_policy_path(path: &str) -> bool {
513 default_wasm_policies().iter().any(|entry| entry == path)
514 }
515
516 pub fn duplicated_chat_path_segment(&self) -> Option<&str> {
522 if !self.upstream_chat_path.is_empty()
525 && self
526 .upstream_url
527 .trim_end_matches('/')
528 .ends_with(&self.upstream_chat_path)
529 {
530 return Some(self.upstream_chat_path.trim_start_matches('/'));
531 }
532 let first_segment = self
533 .upstream_chat_path
534 .trim_start_matches('/')
535 .split('/')
536 .next()?;
537 if first_segment.is_empty() {
538 return None;
539 }
540 let base = self.upstream_url.trim_end_matches('/');
541 let last_segment = base.rsplit('/').next()?;
542 if last_segment.contains('.') || last_segment.contains(':') || base.ends_with("//") {
544 return None;
545 }
546 (last_segment == first_segment).then_some(first_segment)
547 }
548
549 pub fn apply_env_overrides(&mut self) {
555 self.apply_env_overrides_from(|name| std::env::var(name).ok());
556 }
557
558 pub fn apply_env_overrides_from(&mut self, get: impl Fn(&str) -> Option<String>) {
560 let non_empty = |value: String| if value.is_empty() { None } else { Some(value) };
561 if let Some(listen) = get("AV_LISTEN").and_then(non_empty) {
562 self.listen = listen;
563 }
564 if let Some(url) = get("AV_UPSTREAM_URL").and_then(non_empty) {
565 self.upstream_url = url;
566 }
567 if let Some(path) = get("AV_UPSTREAM_CHAT_PATH").and_then(non_empty) {
568 self.upstream_chat_path = path;
569 }
570 if let Some(header) = get("AV_UPSTREAM_AUTH_HEADER").and_then(non_empty) {
571 self.upstream_auth_header = header;
572 }
573 if let Some(scheme) = get("AV_UPSTREAM_AUTH_SCHEME") {
575 self.upstream_auth_scheme = scheme;
576 }
577 if let Some(endpoint) = get("AV_STATE_ENDPOINT").and_then(non_empty) {
578 self.state_endpoint = Some(endpoint);
579 }
580 if let Some(endpoint) = get("AV_BRIDGE_ENDPOINT").and_then(non_empty) {
581 self.bridge_endpoint = Some(endpoint);
582 }
583 if let Some(url) = get("AV_QDRANT_URL").and_then(non_empty) {
584 self.qdrant_url = Some(url);
585 }
586 if self.upstream_api_key_env.is_none()
591 && self.upstream_api_key_file.is_none()
592 && !self.upstream_authorization_passthrough
593 {
594 if let Some(path) = get("AV_UPSTREAM_KEY_FILE").and_then(non_empty) {
595 self.upstream_api_key_file = Some(path);
596 }
597 }
598 if self.upstream_api_key_env.is_none()
603 && self.upstream_api_key_file.is_none()
604 && !self.upstream_authorization_passthrough
605 && get("AV_UPSTREAM_API_KEY").and_then(non_empty).is_some()
606 {
607 self.upstream_api_key_env = Some("AV_UPSTREAM_API_KEY".to_owned());
608 }
609 }
610
611 pub fn validate(&self) -> Result<(), String> {
613 if self.config_version != CONFIG_VERSION {
614 return Err(format!(
615 "unsupported config_version {} (this build supports {CONFIG_VERSION})",
616 self.config_version
617 ));
618 }
619 if self.listen.is_empty() {
620 return Err("listen is required (host:port, e.g. 127.0.0.1:8484)".into());
621 }
622 match self.listen.rsplit_once(':') {
626 Some((host, port)) if !host.is_empty() && port.parse::<u16>().is_ok() => {}
627 _ => {
628 return Err(format!(
629 "listen {:?} must be host:port with a port in 0-65535",
630 self.listen
631 ));
632 }
633 }
634 if self.reconcile_tick_s == 0 {
635 return Err("reconcile_tick_s must be greater than zero".into());
636 }
637 if self.session_idle_close_s == 0 {
638 return Err(
639 "session_idle_close_s must be greater than zero (0 would close every open session at each reconcile tick)"
640 .into(),
641 );
642 }
643 if self.upstream_url.is_empty() {
644 return Err("upstream_url is required".into());
645 }
646 if !self.upstream_chat_path.starts_with('/') {
647 return Err(format!(
648 "upstream_chat_path must start with '/', got {:?}",
649 self.upstream_chat_path
650 ));
651 }
652 if self.upstream_api_key_env.as_deref().is_some_and(str::is_empty) {
653 return Err("upstream_api_key_env must not be empty when set".into());
654 }
655 if self.upstream_api_key_file.as_deref().is_some_and(str::is_empty) {
656 return Err("upstream_api_key_file must not be empty when set".into());
657 }
658 if self.upstream_api_key_env.is_some() && self.upstream_api_key_file.is_some() {
659 return Err(
660 "set only one of upstream_api_key_env or upstream_api_key_file (ambiguous key source)".into(),
661 );
662 }
663 let has_static_key = self.upstream_api_key_env.is_some() || self.upstream_api_key_file.is_some();
664 if self.upstream_authorization_passthrough && has_static_key {
665 return Err(
666 "upstream_authorization_passthrough conflicts with upstream_api_key_env/file: choose one auth mode"
667 .into(),
668 );
669 }
670 if self.upstream_authorization_passthrough && self.require_identity {
671 return Err(
672 "upstream_authorization_passthrough cannot be combined with require_identity: the \
673 Authorization header carries the NHI token, which must never be sent upstream"
674 .into(),
675 );
676 }
677 if axum::http::HeaderName::try_from(self.upstream_auth_header.as_str()).is_err() {
678 return Err(format!(
679 "upstream_auth_header {:?} is not a valid HTTP header name",
680 self.upstream_auth_header
681 ));
682 }
683 if self
684 .upstream_auth_scheme
685 .bytes()
686 .any(|byte| !(0x21..=0x7e).contains(&byte))
687 {
688 return Err(
689 "upstream_auth_scheme must contain only visible ASCII with no spaces (use \"\" for a raw key)"
690 .into(),
691 );
692 }
693 if self.tool_upstream_bearer_env.is_some() && self.tool_upstream_bearer_file.is_some() {
694 return Err(
695 "set only one of tool_upstream_bearer_env or tool_upstream_bearer_file (ambiguous token source)"
696 .into(),
697 );
698 }
699 if (self.tool_upstream_bearer_env.is_some() || self.tool_upstream_bearer_file.is_some())
700 && self.tool_upstream_url.as_deref().is_none_or(str::is_empty)
701 {
702 return Err("tool_upstream_bearer_env/file requires tool_upstream_url to be set".into());
703 }
704 if crate::session::Workflow::parse(&self.default_workflow).is_none() {
705 return Err(format!(
706 "default_workflow must be signed|unsigned, got {:?}",
707 self.default_workflow
708 ));
709 }
710 if self.require_identity
711 && self.identity_jwks_url.as_deref().is_none_or(str::is_empty)
712 && self
713 .identity_hmac_secret_file
714 .as_deref()
715 .is_none_or(str::is_empty)
716 {
717 return Err("require_identity=true needs identity_jwks_url or identity_hmac_secret_file".into());
718 }
719 if self.enforce_identity_scopes && !self.require_identity {
736 return Err(
737 "enforce_identity_scopes=true has no effect while require_identity=false: \
738 unauthenticated requests fall through to the anonymous identity and bypass \
739 the scope gate entirely. Either set require_identity=true, or set \
740 enforce_identity_scopes=false to make the posture explicit."
741 .into(),
742 );
743 }
744 if self.identity_jwks_refresh_s == 0 {
745 return Err("identity_jwks_refresh_s must be greater than zero".into());
746 }
747 if self.identity_jwks_refresh_s < 30 {
753 return Err(format!(
754 "identity_jwks_refresh_s {} is too aggressive; a value below 30 s hammers the IdP \
755 without benefit given NHI TTLs measured in minutes",
756 self.identity_jwks_refresh_s
757 ));
758 }
759 if self.identity_hmac_kid.is_empty() {
760 return Err("identity_hmac_kid must not be empty".into());
761 }
762 for (field, value) in [
772 ("chat_scope", &self.chat_scope),
773 ("session_close_scope", &self.session_close_scope),
774 ("session_promote_scope", &self.session_promote_scope),
775 ] {
776 if value.is_empty() {
777 return Err(format!("{field} must not be empty"));
778 }
779 if value.bytes().any(|byte| !(0x21..=0x7e).contains(&byte)) {
780 return Err(format!(
781 "{field} {value:?} must be visible ASCII with no whitespace or control bytes"
782 ));
783 }
784 }
785 if self.worker_channel_capacity == 0 {
786 return Err("worker_channel_capacity must be > 0".into());
787 }
788 if !matches!(self.bridge_backend.as_str(), "embedded" | "kafka" | "nats") {
789 return Err(format!(
790 "bridge_backend must be embedded|kafka|nats, got {:?}",
791 self.bridge_backend
792 ));
793 }
794 if self.bridge_manifest_path.is_empty() {
795 return Err("bridge_manifest_path is required".into());
796 }
797 if self.atif_spool_dir.is_empty() {
806 return Err("atif_spool_dir must not be empty".into());
807 }
808 if self.bridge_data_dir.is_empty() {
809 return Err("bridge_data_dir must not be empty".into());
810 }
811 if self.bridge_backend != "embedded" && self.bridge_endpoint.as_deref().is_none_or(str::is_empty) {
812 return Err("bridge_endpoint is required for kafka and nats backends".into());
813 }
814 if !matches!(self.state_backend.as_str(), "memory" | "redis") {
815 return Err(format!(
816 "state_backend must be memory|redis, got {:?}",
817 self.state_backend
818 ));
819 }
820 if self.state_backend == "redis" && self.state_endpoint.as_deref().is_none_or(str::is_empty) {
821 return Err("state_endpoint is required for the redis backend".into());
822 }
823 if !matches!(self.embedder_backend.as_str(), "hash" | "onnx") {
824 return Err(format!(
825 "embedder_backend must be hash|onnx, got {:?}",
826 self.embedder_backend
827 ));
828 }
829 if self.embedder_backend == "onnx"
830 && (self.onnx_model_path.as_deref().is_none_or(str::is_empty)
831 || self.onnx_tokenizer_path.as_deref().is_none_or(str::is_empty))
832 {
833 return Err("onnx_model_path and onnx_tokenizer_path are required for the onnx backend".into());
834 }
835 if self.onnx_dimension == 0 {
836 return Err("onnx_dimension must be greater than zero".into());
837 }
838 if !matches!(self.vector_backend.as_str(), "memory" | "qdrant") {
839 return Err(format!(
840 "vector_backend must be memory|qdrant, got {:?}",
841 self.vector_backend
842 ));
843 }
844 if self.vector_backend == "qdrant" && self.qdrant_url.as_deref().is_none_or(str::is_empty) {
845 return Err("qdrant_url is required for the qdrant vector backend".into());
846 }
847 if self.qdrant_collection.is_empty() {
848 return Err("qdrant_collection must not be empty".into());
849 }
850 if self.breaker.window == 0 {
851 return Err(
852 "breaker.window must be greater than zero (0 trips on token count alone, ignoring semantic similarity)"
853 .into(),
854 );
855 }
856 if !self.breaker.delta_epsilon.is_finite() || self.breaker.delta_epsilon <= 0.0 {
857 return Err(format!(
858 "breaker.delta_epsilon must be a finite number greater than zero, got {}",
859 self.breaker.delta_epsilon
860 ));
861 }
862 if self.worker_channel_capacity > MAX_WORKER_CHANNEL_CAPACITY {
866 return Err(format!(
867 "worker_channel_capacity {} exceeds the safety cap of {} — oversized channels hide real backpressure and let per-shard buffers grow unboundedly under overload",
868 self.worker_channel_capacity, MAX_WORKER_CHANNEL_CAPACITY
869 ));
870 }
871 if self.max_request_bytes > MAX_REQUEST_BYTES_CAP {
872 return Err(format!(
873 "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",
874 self.max_request_bytes, MAX_REQUEST_BYTES_CAP
875 ));
876 }
877 if self.onnx_dimension > MAX_ONNX_DIMENSION {
878 return Err(format!(
879 "onnx_dimension {} exceeds the safety cap of {} — most sentence-transformer models are <= 4096",
880 self.onnx_dimension, MAX_ONNX_DIMENSION
881 ));
882 }
883 let mut interval_fields: Vec<(&'static str, u64)> = vec![
887 ("reconcile_tick_s", self.reconcile_tick_s),
888 ("session_idle_close_s", self.session_idle_close_s),
889 ("identity_jwks_refresh_s", self.identity_jwks_refresh_s),
890 ];
891 if let Some(read_timeout) = self.upstream_read_timeout_s {
892 interval_fields.push(("upstream_read_timeout_s", read_timeout));
893 }
894 for (field, value) in interval_fields {
895 if value > MAX_SECONDS_INTERVAL {
896 return Err(format!(
897 "{field} = {value} exceeds the safety cap of {MAX_SECONDS_INTERVAL} seconds (1 day) — did you mean milliseconds?"
898 ));
899 }
900 }
901 if !(self.upstream_url.starts_with("http://") || self.upstream_url.starts_with("https://")) {
918 return Err(format!(
919 "upstream_url must be http:// or https://, got {:?}",
920 self.upstream_url
921 ));
922 }
923 if let Some(tool_upstream) = &self.tool_upstream_url {
924 if !tool_upstream.is_empty()
925 && !(tool_upstream.starts_with("http://") || tool_upstream.starts_with("https://"))
926 {
927 return Err(format!(
928 "tool_upstream_url must be http:// or https://, got {tool_upstream:?}"
929 ));
930 }
931 }
932 if let Some(url) = self
944 .identity_jwks_url
945 .as_deref()
946 .filter(|value| !value.is_empty())
947 {
948 if !(url.starts_with("http://") || url.starts_with("https://")) {
949 return Err(format!(
950 "identity_jwks_url must be http:// or https://, got {url:?}"
951 ));
952 }
953 }
954 if self.vector_backend == "qdrant" {
955 if let Some(url) = self.qdrant_url.as_deref().filter(|value| !value.is_empty()) {
956 if !(url.starts_with("http://") || url.starts_with("https://")) {
957 return Err(format!("qdrant_url must be http:// or https://, got {url:?}"));
958 }
959 }
960 }
961 if self.state_backend == "redis" {
962 if let Some(url) = self.state_endpoint.as_deref().filter(|value| !value.is_empty()) {
963 if !(url.starts_with("redis://") || url.starts_with("rediss://") || url.starts_with("unix:"))
964 {
965 return Err(format!(
966 "state_endpoint (redis backend) must be redis://, rediss:// or unix:, got {url:?}"
967 ));
968 }
969 }
970 }
971 if self.bridge_backend == "nats" {
972 if let Some(url) = self.bridge_endpoint.as_deref().filter(|value| !value.is_empty()) {
973 if !(url.starts_with("nats://") || url.starts_with("tls://")) {
974 return Err(format!(
975 "bridge_endpoint (nats backend) must be nats:// or tls://, got {url:?}"
976 ));
977 }
978 }
979 }
980 Ok(())
984 }
985
986 #[doc(hidden)]
1002 pub fn for_tests(upstream_url: &str, spool: &str, bridge: &str) -> Self {
1003 Self {
1004 config_version: CONFIG_VERSION,
1005 listen: "127.0.0.1:0".into(),
1006 upstream_url: upstream_url.to_owned(),
1007 tool_upstream_url: None,
1008 upstream_http2_prior_knowledge: false,
1009 upstream_read_timeout_s: None,
1010 upstream_chat_path: default_chat_path(),
1011 upstream_api_key_env: None,
1012 upstream_api_key_file: None,
1013 upstream_auth_header: default_auth_header(),
1014 upstream_auth_scheme: default_auth_scheme(),
1015 upstream_authorization_passthrough: false,
1016 tool_upstream_bearer_env: None,
1017 tool_upstream_bearer_file: None,
1018 require_identity: false,
1019 audience: default_audience(),
1020 identity_jwks_url: None,
1021 identity_jwks_refresh_s: default_jwks_refresh(),
1022 identity_allowed_issuers: Vec::new(),
1023 identity_hmac_secret_file: None,
1024 identity_hmac_kid: default_hmac_kid(),
1025 enforce_identity_scopes: false,
1026 chat_scope: default_chat_scope(),
1027 session_close_scope: default_close_scope(),
1028 session_promote_scope: default_promote_scope(),
1029 default_workflow: "unsigned".into(),
1030 consequential_tools: default_consequential_tools(),
1031 tool_schema_dir: None,
1032 require_tool_schema: false,
1033 wasm_policy_paths: Vec::new(),
1034 session_idle_close_s: 900,
1035 atif_spool_dir: spool.to_owned(),
1036 bridge_data_dir: bridge.to_owned(),
1037 bridge_backend: "embedded".into(),
1038 bridge_manifest_path: default_bridge_manifest(),
1039 bridge_endpoint: None,
1040 state_backend: "memory".into(),
1041 state_endpoint: None,
1042 embedder_backend: "hash".into(),
1043 onnx_model_path: None,
1044 onnx_tokenizer_path: None,
1045 onnx_dimension: default_onnx_dimension(),
1046 vector_backend: "memory".into(),
1047 qdrant_url: None,
1048 qdrant_collection: default_qdrant_collection(),
1049 worker_channel_capacity: 1024,
1050 strict_stage_budget: false,
1051 breaker: av_loopdetect::BreakerConfig::default(),
1052 compression_enabled: true,
1053 budget: av_state::BudgetSpec::default(),
1054 reconcile_tick_s: 1,
1055 max_request_bytes: default_max_request_bytes(),
1056 dashboard_enabled: default_dashboard_enabled(),
1057 }
1058 }
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
1064
1065 use super::*;
1066
1067 #[test]
1068 fn minimal_toml_parses_with_defaults() {
1069 let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
1070 assert_eq!(cfg.config_version, CONFIG_VERSION);
1071 assert_eq!(cfg.default_workflow, "unsigned");
1072 assert_eq!(cfg.session_idle_close_s, 900);
1073 assert!(cfg.compression_enabled);
1074 assert_eq!(cfg.upstream_chat_path, "/v1/chat/completions");
1075 assert_eq!(cfg.upstream_auth_header, "authorization");
1076 assert_eq!(cfg.upstream_auth_scheme, "Bearer");
1077 assert!(cfg.upstream_api_key_env.is_none());
1078 assert!(!cfg.upstream_authorization_passthrough);
1079 }
1080
1081 #[test]
1084 fn unknown_config_key_is_rejected_with_its_name() {
1085 let err = HarnessConfig::from_toml(
1086 r#"upstream_url = "https://api.openai.com"
1087 idel_timeout_s = 10"#,
1088 )
1089 .unwrap_err();
1090 assert!(err.contains("idel_timeout_s"), "error must name the key: {err}");
1091 }
1092
1093 #[test]
1096 fn upstream_auth_invariants_enforced() {
1097 assert!(HarnessConfig::from_toml(
1099 r#"upstream_url = "https://api.openai.com"
1100 upstream_api_key_env = "OPENAI_API_KEY"
1101 upstream_api_key_file = "/etc/key""#
1102 )
1103 .is_err());
1104 assert!(HarnessConfig::from_toml(
1106 r#"upstream_url = "https://api.openai.com"
1107 upstream_api_key_env = "OPENAI_API_KEY"
1108 upstream_authorization_passthrough = true"#
1109 )
1110 .is_err());
1111 assert!(HarnessConfig::from_toml(
1113 r#"upstream_url = "https://api.openai.com"
1114 upstream_authorization_passthrough = true
1115 require_identity = true
1116 identity_hmac_secret_file = "/run/secrets/hmac""#
1117 )
1118 .is_err());
1119 assert!(HarnessConfig::from_toml(
1121 r#"upstream_url = "https://api.openai.com"
1122 upstream_auth_header = "not a header""#
1123 )
1124 .is_err());
1125 assert!(HarnessConfig::from_toml(
1127 r#"upstream_url = "https://api.openai.com"
1128 upstream_auth_scheme = "Bearer extra""#
1129 )
1130 .is_err());
1131 assert!(HarnessConfig::from_toml(
1133 r#"upstream_url = "https://api.openai.com"
1134 upstream_chat_path = "v1/chat/completions""#
1135 )
1136 .is_err());
1137 assert!(HarnessConfig::from_toml(
1139 r#"upstream_url = "https://api.openai.com"
1140 tool_upstream_bearer_env = "MCP_TOKEN""#
1141 )
1142 .is_err());
1143 let azure = HarnessConfig::from_toml(
1145 r#"upstream_url = "https://res.openai.azure.com"
1146 upstream_chat_path = "/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21"
1147 upstream_api_key_env = "AZURE_OPENAI_API_KEY"
1148 upstream_auth_header = "api-key"
1149 upstream_auth_scheme = """#,
1150 )
1151 .unwrap();
1152 assert_eq!(azure.upstream_auth_scheme, "");
1153 }
1154
1155 #[test]
1156 fn env_overrides_apply_with_documented_precedence() {
1157 let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
1158 let env = |name: &str| -> Option<String> {
1159 match name {
1160 "AV_LISTEN" => Some("0.0.0.0:9999".into()),
1161 "AV_UPSTREAM_URL" => Some("http://env-upstream".into()),
1162 "AV_UPSTREAM_CHAT_PATH" => Some("/openai/v1/chat/completions".into()),
1163 "AV_UPSTREAM_API_KEY" => Some("sk-secret".into()),
1164 "AV_STATE_ENDPOINT" => Some("redis://redis:6379".into()),
1165 _ => None,
1166 }
1167 };
1168 cfg.apply_env_overrides_from(env);
1169 assert_eq!(cfg.listen, "0.0.0.0:9999");
1170 assert_eq!(cfg.upstream_url, "http://env-upstream");
1171 assert_eq!(cfg.upstream_chat_path, "/openai/v1/chat/completions");
1172 assert_eq!(cfg.state_endpoint.as_deref(), Some("redis://redis:6379"));
1173 assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("AV_UPSTREAM_API_KEY"));
1175
1176 let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
1178 cfg.upstream_api_key_env = Some("OPENAI_API_KEY".into());
1179 cfg.apply_env_overrides_from(env);
1180 assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("OPENAI_API_KEY"));
1181
1182 let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
1184 cfg.apply_env_overrides_from(|name| (name == "AV_UPSTREAM_URL").then(String::new));
1185 assert_eq!(cfg.upstream_url, "http://file-upstream");
1186 }
1187
1188 #[test]
1189 fn env_key_file_override_precedence() {
1190 let env = |name: &str| -> Option<String> {
1192 match name {
1193 "AV_UPSTREAM_KEY_FILE" => Some("/run/secrets/api_key".into()),
1194 "AV_UPSTREAM_API_KEY" => Some("sk-secret".into()),
1195 _ => None,
1196 }
1197 };
1198 let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
1199 cfg.apply_env_overrides_from(env);
1200 assert_eq!(cfg.upstream_api_key_file.as_deref(), Some("/run/secrets/api_key"));
1201 assert_eq!(cfg.upstream_api_key_env, None);
1203
1204 let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
1206 cfg.upstream_api_key_env = Some("OPENAI_API_KEY".into());
1207 cfg.apply_env_overrides_from(env);
1208 assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("OPENAI_API_KEY"));
1209 assert_eq!(cfg.upstream_api_key_file, None);
1210
1211 let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
1213 cfg.upstream_authorization_passthrough = true;
1214 cfg.apply_env_overrides_from(env);
1215 assert_eq!(cfg.upstream_api_key_file, None);
1216 assert_eq!(cfg.upstream_api_key_env, None);
1217 }
1218
1219 #[test]
1220 fn builtin_config_validates_once_upstream_is_set() {
1221 let mut cfg = HarnessConfig::builtin().unwrap();
1222 assert!(cfg.validate().is_err(), "must not pass without an upstream");
1223 cfg.apply_env_overrides_from(|name| {
1224 (name == "AV_UPSTREAM_URL").then(|| "http://127.0.0.1:11434".to_owned())
1225 });
1226 cfg.validate().unwrap();
1227 assert_eq!(cfg.bridge_backend, "embedded");
1228 assert_eq!(cfg.state_backend, "memory");
1229 assert_eq!(cfg.embedder_backend, "hash");
1230 assert_eq!(cfg.vector_backend, "memory");
1231 }
1232
1233 #[test]
1234 fn validate_rejects_unbindable_listen_and_zero_intervals() {
1235 let base = || HarnessConfig::for_tests("http://u", "spool", "bridge");
1236
1237 let mut cfg = base();
1238 cfg.listen = String::new();
1239 assert!(cfg.validate().unwrap_err().contains("listen is required"));
1240
1241 cfg = base();
1242 cfg.listen = "no-port-here".into();
1243 assert!(cfg.validate().unwrap_err().contains("host:port"));
1244
1245 cfg = base();
1246 cfg.listen = "127.0.0.1:70000".into();
1247 assert!(cfg.validate().unwrap_err().contains("host:port"));
1248
1249 cfg = base();
1251 cfg.listen = "localhost:8484".into();
1252 cfg.validate().unwrap();
1253 cfg.listen = "[::1]:0".into();
1254 cfg.validate().unwrap();
1255
1256 cfg = base();
1257 cfg.reconcile_tick_s = 0;
1258 assert!(cfg.validate().unwrap_err().contains("reconcile_tick_s"));
1259
1260 cfg = base();
1261 cfg.session_idle_close_s = 0;
1262 assert!(cfg.validate().unwrap_err().contains("session_idle_close_s"));
1263
1264 cfg = base();
1265 cfg.breaker.window = 0;
1266 assert!(cfg.validate().unwrap_err().contains("breaker.window"));
1267
1268 cfg = base();
1269 cfg.breaker.delta_epsilon = f32::NAN;
1270 assert!(cfg.validate().unwrap_err().contains("delta_epsilon"));
1271 cfg.breaker.delta_epsilon = -0.5;
1272 assert!(cfg.validate().unwrap_err().contains("delta_epsilon"));
1273 }
1274
1275 #[test]
1276 fn user_config_path_is_stable() {
1277 let path = user_config_path_from(std::path::Path::new("/home/pat"));
1278 assert_eq!(
1279 path,
1280 std::path::Path::new("/home/pat/.agentvisor/agentvisor.toml")
1281 );
1282 }
1283
1284 #[test]
1288 fn duplicated_chat_path_segment_detection() {
1289 let cfg = |url: &str| HarnessConfig::for_tests(url, "spool", "bridge");
1290 assert_eq!(
1291 cfg("https://api.openai.com/v1").duplicated_chat_path_segment(),
1292 Some("v1")
1293 );
1294 assert_eq!(
1295 cfg("https://api.openai.com/v1/").duplicated_chat_path_segment(),
1296 Some("v1")
1297 );
1298 assert_eq!(
1299 cfg("http://localhost:8080/v1").duplicated_chat_path_segment(),
1300 Some("v1")
1301 );
1302 assert_eq!(cfg("https://api.openai.com").duplicated_chat_path_segment(), None);
1303 assert_eq!(cfg("http://127.0.0.1:11434").duplicated_chat_path_segment(), None);
1304 let mut gemini = cfg("https://generativelanguage.googleapis.com/v1beta/openai");
1306 gemini.upstream_chat_path = "/chat/completions".into();
1307 assert_eq!(gemini.duplicated_chat_path_segment(), None);
1308 let mut azure = cfg("https://r.openai.azure.com/openai");
1310 azure.upstream_chat_path = "/openai/deployments/d/chat/completions".into();
1311 assert_eq!(azure.duplicated_chat_path_segment(), Some("openai"));
1312 assert_eq!(
1314 cfg("http://10.0.0.5:8000/v1/chat/completions").duplicated_chat_path_segment(),
1315 Some("v1/chat/completions")
1316 );
1317 assert_eq!(
1318 cfg("http://10.0.0.5:8000/v1/chat/completions/").duplicated_chat_path_segment(),
1319 Some("v1/chat/completions")
1320 );
1321 }
1322
1323 #[test]
1324 fn bad_configs_rejected() {
1325 assert!(HarnessConfig::from_toml("").is_err()); assert!(HarnessConfig::from_toml(
1327 r#"upstream_url = "x"
1328 config_version = 99"#
1329 )
1330 .is_err());
1331 assert!(HarnessConfig::from_toml(
1332 r#"upstream_url = "x"
1333 default_workflow = "sometimes""#
1334 )
1335 .is_err());
1336 assert!(HarnessConfig::from_toml(
1337 r#"upstream_url = "x"
1338 worker_channel_capacity = 0"#
1339 )
1340 .is_err());
1341 }
1342
1343 #[test]
1347 fn future_version_config_reports_version_not_unknown_field() {
1348 let err = HarnessConfig::from_toml(
1349 r#"config_version = 2
1350 upstream_url = "https://api"
1351 future_option_from_v2 = true"#,
1352 )
1353 .unwrap_err();
1354 assert!(err.contains("unsupported config_version 2"), "{err}");
1355 assert!(!err.contains("future_option_from_v2"), "{err}");
1356 }
1357
1358 #[test]
1362 fn worker_channel_capacity_cap_rejects_oversized_values() {
1363 let err = HarnessConfig::from_toml(&format!(
1364 "upstream_url = \"https://api\"\nworker_channel_capacity = {}",
1365 MAX_WORKER_CHANNEL_CAPACITY + 1
1366 ))
1367 .unwrap_err();
1368 assert!(
1369 err.contains("worker_channel_capacity"),
1370 "err should name the offending field: {err}"
1371 );
1372 }
1373
1374 #[test]
1380 fn upstream_url_without_scheme_is_rejected() {
1381 let err = HarnessConfig::from_toml(r#"upstream_url = "openai.internal""#).unwrap_err();
1382 assert!(err.contains("upstream_url"), "{err}");
1383 assert!(err.contains("http"), "{err}");
1384 }
1385
1386 #[test]
1392 fn upstream_url_non_http_scheme_is_rejected() {
1393 let err = HarnessConfig::from_toml(r#"upstream_url = "file:///etc/passwd""#).unwrap_err();
1394 assert!(err.contains("upstream_url"), "{err}");
1395 let err = HarnessConfig::from_toml(r#"upstream_url = "gopher://x""#).unwrap_err();
1396 assert!(err.contains("upstream_url"), "{err}");
1397 assert!(HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).is_ok());
1399 assert!(HarnessConfig::from_toml(r#"upstream_url = "http://gw.local""#).is_ok());
1400 }
1401
1402 #[test]
1405 fn seconds_intervals_reject_absurdly_large_values() {
1406 let err = HarnessConfig::from_toml(&format!(
1407 "upstream_url = \"https://api\"\nsession_idle_close_s = {}",
1408 MAX_SECONDS_INTERVAL + 1
1409 ))
1410 .unwrap_err();
1411 assert!(
1412 err.contains("session_idle_close_s"),
1413 "err should name the offending field: {err}"
1414 );
1415 assert!(err.contains("milliseconds"), "hint should mention ms: {err}");
1416 }
1417
1418 #[test]
1423 fn round_30_f1_scope_enforcement_requires_identity_requirement() {
1424 let err = HarnessConfig::from_toml(
1425 r#"upstream_url = "https://api.openai.com"
1426 require_identity = false
1427 enforce_identity_scopes = true"#,
1428 )
1429 .unwrap_err();
1430 assert!(
1431 err.contains("enforce_identity_scopes"),
1432 "err should name the flag: {err}"
1433 );
1434 assert!(
1435 err.contains("require_identity"),
1436 "err should name the correlate: {err}"
1437 );
1438 assert!(HarnessConfig::from_toml(
1440 r#"upstream_url = "https://api.openai.com"
1441 require_identity = false
1442 enforce_identity_scopes = false"#,
1443 )
1444 .is_ok());
1445 }
1446
1447 #[test]
1451 fn round_30_f2_url_scheme_allowlist_enforced() {
1452 let err = HarnessConfig::from_toml(
1454 r#"upstream_url = "https://api"
1455 identity_jwks_url = "auth.internal/jwks""#,
1456 )
1457 .unwrap_err();
1458 assert!(err.contains("identity_jwks_url"), "{err}");
1459 assert!(err.contains("http://") || err.contains("https://"), "{err}");
1460 let err = HarnessConfig::from_toml(
1462 r#"upstream_url = "https://api"
1463 identity_jwks_url = "file:///etc/passwd""#,
1464 )
1465 .unwrap_err();
1466 assert!(err.contains("identity_jwks_url"), "{err}");
1467 let err = HarnessConfig::from_toml(
1469 r#"upstream_url = "https://api"
1470 state_backend = "redis"
1471 state_endpoint = "http://cache""#,
1472 )
1473 .unwrap_err();
1474 assert!(err.contains("state_endpoint"), "{err}");
1475 let err = HarnessConfig::from_toml(
1477 r#"upstream_url = "https://api"
1478 bridge_backend = "nats"
1479 bridge_endpoint = "http://bus""#,
1480 )
1481 .unwrap_err();
1482 assert!(err.contains("bridge_endpoint"), "{err}");
1483 let err = HarnessConfig::from_toml(
1485 r#"upstream_url = "https://api"
1486 vector_backend = "qdrant"
1487 qdrant_url = "vectors.internal:6333""#,
1488 )
1489 .unwrap_err();
1490 assert!(err.contains("qdrant_url"), "{err}");
1491 assert!(HarnessConfig::from_toml(
1493 r#"upstream_url = "https://api"
1494 identity_jwks_url = "https://auth.internal/jwks"
1495 state_backend = "redis"
1496 state_endpoint = "redis://cache:6379"
1497 bridge_backend = "nats"
1498 bridge_endpoint = "nats://bus:4222"
1499 vector_backend = "qdrant"
1500 qdrant_url = "https://vectors.internal:6333""#,
1501 )
1502 .is_ok());
1503 }
1504
1505 #[test]
1510 fn round_31_f1_local_fs_paths_empty_rejected() {
1511 let err = HarnessConfig::from_toml(
1512 r#"upstream_url = "https://api"
1513 atif_spool_dir = """#,
1514 )
1515 .unwrap_err();
1516 assert!(err.contains("atif_spool_dir"), "{err}");
1517 let err = HarnessConfig::from_toml(
1518 r#"upstream_url = "https://api"
1519 bridge_data_dir = """#,
1520 )
1521 .unwrap_err();
1522 assert!(err.contains("bridge_data_dir"), "{err}");
1523 }
1524
1525 #[test]
1530 fn round_31_f2_scope_names_rejected_empty_or_whitespaced() {
1531 let err = HarnessConfig::from_toml(
1533 r#"upstream_url = "https://api"
1534 require_identity = true
1535 identity_hmac_secret_file = "/tmp/secret"
1536 enforce_identity_scopes = true
1537 chat_scope = """#,
1538 )
1539 .unwrap_err();
1540 assert!(err.contains("chat_scope"), "{err}");
1541 let err = HarnessConfig::from_toml(
1543 r#"upstream_url = "https://api"
1544 require_identity = true
1545 identity_hmac_secret_file = "/tmp/secret"
1546 enforce_identity_scopes = true
1547 chat_scope = "chat write""#,
1548 )
1549 .unwrap_err();
1550 assert!(err.contains("chat_scope"), "{err}");
1551 let err = HarnessConfig::from_toml(
1553 r#"upstream_url = "https://api"
1554 require_identity = true
1555 identity_hmac_secret_file = "/tmp/secret"
1556 enforce_identity_scopes = true
1557 session_close_scope = "close\tsession""#,
1558 )
1559 .unwrap_err();
1560 assert!(err.contains("session_close_scope"), "{err}");
1561 assert!(HarnessConfig::from_toml(
1563 r#"upstream_url = "https://api"
1564 require_identity = true
1565 identity_hmac_secret_file = "/tmp/secret"
1566 enforce_identity_scopes = true
1567 chat_scope = "chat:write"
1568 session_close_scope = "session:close"
1569 session_promote_scope = "session:promote""#,
1570 )
1571 .is_ok());
1572 }
1573}