Skip to main content

av_harness/
config.rs

1//! Harness configuration (TOML surface, versioned).
2
3use serde::{Deserialize, Serialize};
4
5/// Config format version (evolution surface).
6pub const CONFIG_VERSION: u32 = 1;
7
8/// Upper bound on `worker_channel_capacity`. `tokio::sync::mpsc::channel`
9/// does not preallocate slots (it links chunks lazily under a Semaphore),
10/// but an oversized value still lets the per-shard buffers grow
11/// unboundedly under overload and hides real backpressure signals. 1M
12/// is orders of magnitude above realistic capacity — this bound is
13/// defence-in-depth against a fat-finger, not a hard OOM prevention.
14pub const MAX_WORKER_CHANNEL_CAPACITY: usize = 1_000_000;
15
16/// Upper bound on `max_request_bytes` (512 MiB). A single request body
17/// should never legitimately need more; lifting this defeats the
18/// sandbox payload guard and lets one request pin half a GB of RAM.
19pub const MAX_REQUEST_BYTES_CAP: usize = 512 * 1024 * 1024;
20
21/// Upper bound on `onnx_dimension`. Most sentence-transformer models
22/// have <= 4096 dims (Nomic Embed v1.5 = 768, MiniLM = 384, e5-mistral
23/// = 4096, Ada-002 = 1536). 16k is a comfortable ceiling.
24pub const MAX_ONNX_DIMENSION: usize = 16_384;
25
26/// Upper bound on any `_s` seconds interval. 1 day is already far
27/// beyond any reasonable value; anything larger is almost certainly a
28/// unit-conversion error (someone thought the field was in ms).
29pub const MAX_SECONDS_INTERVAL: u64 = 24 * 60 * 60;
30
31/// Top-level harness configuration.
32///
33/// Unknown keys are rejected (`deny_unknown_fields`) so a typo like
34/// `idel_timeout_s` fails loudly at startup instead of being silently
35/// ignored. Forward compatibility is handled by `config_version` gating,
36/// not by tolerating unrecognized keys.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38#[serde(deny_unknown_fields)]
39pub struct HarnessConfig {
40    /// Config format version.
41    #[serde(default = "default_config_version")]
42    pub config_version: u32,
43    /// Listen address, e.g. `127.0.0.1:8484`.
44    #[serde(default = "default_listen")]
45    pub listen: String,
46    /// Upstream LLM provider base URL (OpenAI-compatible).
47    pub upstream_url: String,
48    /// Optional downstream MCP/REST tool server URL. When absent, `/v1/mcp`
49    /// operates as a policy decision endpoint.
50    #[serde(default)]
51    pub tool_upstream_url: Option<String>,
52    /// Use cleartext HTTP/2 prior knowledge for trusted h2c upstreams.
53    #[serde(default)]
54    pub upstream_http2_prior_knowledge: bool,
55    /// Optional provider read-idle timeout. `None` permits intentionally held streams.
56    #[serde(default)]
57    pub upstream_read_timeout_s: Option<u64>,
58    /// Chat-completions path appended to `upstream_url`. Override for
59    /// providers with non-standard layouts (Azure deployments, Gemini's
60    /// OpenAI-compatible surface).
61    #[serde(default = "default_chat_path")]
62    pub upstream_chat_path: String,
63    /// Name of an environment variable holding the upstream API key. The
64    /// key value itself never appears in this file or on the command line.
65    #[serde(default)]
66    pub upstream_api_key_env: Option<String>,
67    /// File containing the upstream API key (owner-only permissions are
68    /// enforced on Unix). Mutually exclusive with `upstream_api_key_env`.
69    #[serde(default)]
70    pub upstream_api_key_file: Option<String>,
71    /// Header carrying the upstream API key, e.g. `authorization` (OpenAI),
72    /// `api-key` (Azure), or `x-api-key`.
73    #[serde(default = "default_auth_header")]
74    pub upstream_auth_header: String,
75    /// Prefix inserted before the key in the auth header. `"Bearer"` yields
76    /// `Bearer <key>`; an empty string sends the raw key (Azure style).
77    #[serde(default = "default_auth_scheme")]
78    pub upstream_auth_scheme: String,
79    /// Forward each client's own `Authorization` header to the upstream
80    /// instead of injecting a server-side key. Incompatible with
81    /// `require_identity` (the header would carry the NHI token) and with
82    /// the static key options above.
83    #[serde(default)]
84    pub upstream_authorization_passthrough: bool,
85    /// Name of an environment variable holding a bearer token for
86    /// `tool_upstream_url` requests.
87    #[serde(default)]
88    pub tool_upstream_bearer_env: Option<String>,
89    /// File containing a bearer token for `tool_upstream_url` requests
90    /// (owner-only permissions enforced on Unix).
91    #[serde(default)]
92    pub tool_upstream_bearer_file: Option<String>,
93    /// Identity enforcement. When `false` (dev), requests without a token get
94    /// an anonymous identity; when `true`, unauthenticated requests are 401s.
95    #[serde(default)]
96    pub require_identity: bool,
97    /// Deployment audience for NHI tokens.
98    #[serde(default = "default_audience")]
99    pub audience: String,
100    /// Corporate IdP JWKS endpoint for Ed25519 verification keys.
101    #[serde(default)]
102    pub identity_jwks_url: Option<String>,
103    /// JWKS refresh interval in seconds.
104    #[serde(default = "default_jwks_refresh")]
105    pub identity_jwks_refresh_s: u64,
106    /// Optional issuer allowlist (for example Okta or Entra tenant URLs).
107    #[serde(default)]
108    pub identity_allowed_issuers: Vec<String>,
109    /// Optional file containing an HS256 development secret.
110    #[serde(default)]
111    pub identity_hmac_secret_file: Option<String>,
112    /// Key id assigned to the development HMAC secret.
113    #[serde(default = "default_hmac_kid")]
114    pub identity_hmac_kid: String,
115    /// Enforce operation scopes on validated identities.
116    ///
117    /// Round-30 F1: default flipped from `true` to `false` so it
118    /// matches the also-default-`false` posture of
119    /// [`Self::require_identity`]. When `require_identity = false`,
120    /// unauthenticated requests short-circuit to the anonymous
121    /// identity BEFORE the scope gate runs — an operator who reads
122    /// `enforce_identity_scopes = true` in the config would
123    /// reasonably conclude "you need the scope to reach /v1/chat",
124    /// but in the default posture curl-with-no-header still
125    /// proceeds as anonymous. `validate()` now rejects the
126    /// `enforce_identity_scopes = true && require_identity = false`
127    /// combination outright; keeping the two defaults aligned makes
128    /// the shipped `harness.example.toml` and `harness.container.toml`
129    /// pass validate without extra changes. Operators turning on
130    /// identity enforcement in production set both flags to `true`
131    /// explicitly (see `harness.docker.toml`).
132    #[serde(default)]
133    pub enforce_identity_scopes: bool,
134    /// Scope required for chat completion requests.
135    #[serde(default = "default_chat_scope")]
136    pub chat_scope: String,
137    /// Scope required to close sessions.
138    #[serde(default = "default_close_scope")]
139    pub session_close_scope: String,
140    /// Scope required to promote unsigned sessions.
141    #[serde(default = "default_promote_scope")]
142    pub session_promote_scope: String,
143    /// Default workflow when `X-AV-Workflow` is absent: signed workflows are
144    /// opt-in by policy (brief Module G).
145    #[serde(default = "default_workflow")]
146    pub default_workflow: String,
147    /// Tools that require a signed workflow because they have real-world
148    /// consequences.
149    #[serde(default = "default_consequential_tools")]
150    pub consequential_tools: Vec<String>,
151    /// Directory containing one JSON Schema per tool, named `<tool>.json`.
152    #[serde(default = "default_tool_schema_dir")]
153    pub tool_schema_dir: Option<String>,
154    /// Reject tool calls when no matching schema was loaded.
155    #[serde(default = "default_true")]
156    pub require_tool_schema: bool,
157    /// WASM or WAT policy module paths, evaluated in order.
158    #[serde(default = "default_wasm_policies")]
159    pub wasm_policy_paths: Vec<String>,
160    /// Idle seconds after which a session is swept closed.
161    #[serde(default = "default_idle")]
162    pub session_idle_close_s: u64,
163    /// Directory for ATIF trajectory spool files.
164    #[serde(default = "default_spool")]
165    pub atif_spool_dir: String,
166    /// Bridge data directory (embedded broker).
167    #[serde(default = "default_bridge")]
168    pub bridge_data_dir: String,
169    /// Bridge backend: `embedded`, `kafka`, or `nats`.
170    #[serde(default = "default_bridge_backend")]
171    pub bridge_backend: String,
172    /// Declarative topic-schema manifest used by every Bridge backend.
173    #[serde(default = "default_bridge_manifest")]
174    pub bridge_manifest_path: String,
175    /// Kafka broker (`host:port[,host:port]`) or NATS URL (`nats://`/`tls://`)
176    /// for network Bridge backends. Secured endpoints read their material
177    /// from the environment: `AV_KAFKA_CA_FILE` + `AV_KAFKA_SASL_USERNAME`/
178    /// `AV_KAFKA_SASL_PASSWORD` (+ optional `AV_KAFKA_SASL_MECHANISM`:
179    /// `SCRAM-SHA-256` default, `SCRAM-SHA-512`, or `PLAIN`; credentials
180    /// are refused without the CA), and `AV_NATS_CA_FILE` (forces TLS) +
181    /// `AV_NATS_USER`/`AV_NATS_PASSWORD`.
182    #[serde(default)]
183    pub bridge_endpoint: Option<String>,
184    /// State backend: `memory` or `redis`.
185    #[serde(default = "default_state_backend")]
186    pub state_backend: String,
187    /// Redis URL for the distributed state backend. A comma-separated list
188    /// of URLs selects Redis Cluster mode.
189    #[serde(default)]
190    pub state_endpoint: Option<String>,
191    /// Embedding backend: `hash` or `onnx`.
192    #[serde(default = "default_embedder_backend")]
193    pub embedder_backend: String,
194    /// Customer-supplied ONNX model path.
195    #[serde(default)]
196    pub onnx_model_path: Option<String>,
197    /// Hugging Face tokenizer.json paired with the ONNX model.
198    #[serde(default)]
199    pub onnx_tokenizer_path: Option<String>,
200    /// ONNX model output width.
201    #[serde(default = "default_onnx_dimension")]
202    pub onnx_dimension: usize,
203    /// Vector persistence backend: `memory` or `qdrant`.
204    #[serde(default = "default_vector_backend")]
205    pub vector_backend: String,
206    /// Qdrant base URL.
207    #[serde(default)]
208    pub qdrant_url: Option<String>,
209    /// Qdrant collection receiving reasoning vectors.
210    #[serde(default = "default_qdrant_collection")]
211    pub qdrant_collection: String,
212    /// Worker channel capacity (bounded; overflow is counted, never blocking).
213    #[serde(default = "default_channel_cap")]
214    pub worker_channel_capacity: usize,
215    /// Strict per-stage budget assertions (AV_STRICT_BUDGET also enables).
216    #[serde(default)]
217    pub strict_stage_budget: bool,
218    /// Loop breaker configuration.
219    #[serde(default)]
220    pub breaker: av_loopdetect::BreakerConfig,
221    /// Compression configuration.
222    #[serde(default = "default_compression")]
223    pub compression_enabled: bool,
224    /// Token budget per session (compression/velocity accounting).
225    #[serde(default)]
226    pub budget: av_state::BudgetSpec,
227    /// Reconciler tick interval (seconds).
228    #[serde(default = "default_reconcile_tick")]
229    pub reconcile_tick_s: u64,
230    /// Maximum request body size accepted on `/v1/chat/completions` and
231    /// `/mcp`. Defaults to 4 MiB, matching the sandbox's `MAX_PAYLOAD_BYTES`
232    /// so both routes carry the same effective limit — axum's own
233    /// `DefaultBodyLimit::MAX` is 2 MiB by default and would silently
234    /// reject legitimate large-context chat requests before the sandbox
235    /// even saw the payload. Operators serving very-long-context models
236    /// (Claude 200k, GPT-4 128k on maximally-verbose inputs) may need to
237    /// raise this.
238    #[serde(default = "default_max_request_bytes")]
239    pub max_request_bytes: usize,
240
241    /// Whether the built-in read-only operator dashboard is enabled.
242    ///
243    /// When true (the default), the harness serves:
244    ///   * `GET /dashboard` — an HTML/CSS/JS single-page dashboard,
245    ///   * `GET /dashboard/{style.css,app.js}` — the bundled assets,
246    ///   * `GET /api/v1/dashboard/{stats,sessions,sessions/:id}` — read-only
247    ///     JSON that mirrors the in-memory session registry.
248    ///
249    /// The endpoints are unauthenticated: they expose the same data that
250    /// already lands on disk (receipts) and in `/metrics`. Front the
251    /// harness with the same ingress control you use for `/metrics` if
252    /// this is a concern, or set this to `false` to disable them.
253    #[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/// Where the effective configuration came from.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub enum ConfigSource {
357    /// Loaded from a TOML file on disk.
358    File(std::path::PathBuf),
359    /// Built-in defaults (zero-config mode; requires `AV_UPSTREAM_URL`).
360    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
372/// Well-known config locations probed in order when `AV_CONFIG` is unset.
373/// Project-local files (current directory) beat the per-user file the
374/// `avctl` setup wizard writes (see [`user_config_path`]).
375pub const CONFIG_SEARCH_PATHS: [&str; 3] = [
376    "agentvisor.toml",
377    "config/harness.toml",
378    "config/harness.example.toml",
379];
380
381/// Per-user config file inside `home`, as written by the `avctl` wizard.
382pub fn user_config_path_from(home: &std::path::Path) -> std::path::PathBuf {
383    home.join(".agentvisor").join("agentvisor.toml")
384}
385
386/// Per-user config file (`~/.agentvisor/agentvisor.toml`), if the home
387/// directory is known.
388pub fn user_config_path() -> Option<std::path::PathBuf> {
389    #[allow(deprecated)] // undeprecated in Rust 1.86; MSRV is 1.88
390    std::env::home_dir().map(|home| user_config_path_from(&home))
391}
392
393/// Resolve the configuration source without reading it.
394///
395/// Order: `AV_CONFIG` (must exist — a typo must never silently fall
396/// through to another file), then [`CONFIG_SEARCH_PATHS`], then the
397/// per-user wizard file, then built-in defaults driven by
398/// `AV_UPSTREAM_URL`.
399pub fn resolve_config_source() -> Result<ConfigSource, String> {
400    if let Some(path) = std::env::var_os("AV_CONFIG") {
401        // Empty means unset: compose files commonly render
402        // `AV_CONFIG: ${AV_CONFIG:-}` which must not become a hard error.
403        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
428/// Load, apply environment overrides, and validate the effective config.
429pub 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    /// Parse from TOML, validating the version and structural sanity.
458    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    /// Parse from TOML without validating, so environment overrides can be
466    /// applied first (`main` validates after [`Self::apply_env_overrides`]).
467    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    /// Pre-pass on the loosely-parsed document so a config written for a
473    /// newer format version reports "unsupported config_version N" instead
474    /// of tripping the strict unknown-field rejection on whatever new key
475    /// appears first.
476    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    /// A config of pure built-in defaults for zero-config startup. The
490    /// caller must supply `upstream_url` (typically `AV_UPSTREAM_URL`)
491    /// before validation.
492    pub fn builtin() -> Result<Self, String> {
493        toml::from_str("upstream_url = \"\"").map_err(|e| format!("built-in config: {e}"))
494    }
495
496    /// True when `bridge_manifest_path` is the compiled-in default, i.e.
497    /// the operator never chose a manifest. Only then may the binary fall
498    /// back to its embedded manifest when the file is absent; an explicit
499    /// path that is missing must stay a hard error.
500    pub fn uses_default_manifest_path(&self) -> bool {
501        self.bridge_manifest_path == default_bridge_manifest()
502    }
503
504    /// True when `tool_schema_dir` is the compiled-in default (see
505    /// [`Self::uses_default_manifest_path`] for the fallback rationale).
506    pub fn uses_default_tool_schema_dir(&self) -> bool {
507        self.tool_schema_dir == default_tool_schema_dir()
508    }
509
510    /// True when `path` is the compiled-in default WASM policy entry (see
511    /// [`Self::uses_default_manifest_path`] for the fallback rationale).
512    pub fn is_default_policy_path(path: &str) -> bool {
513        default_wasm_policies().iter().any(|entry| entry == path)
514    }
515
516    /// Detect the classic footgun of `upstream_url` already ending with the
517    /// first segment of `upstream_chat_path` (for example a base URL of
518    /// `https://api.openai.com/v1` joined with `/v1/chat/completions`
519    /// produces `/v1/v1/...` and a confusing provider 404). Returns the
520    /// duplicated segment for warning messages.
521    pub fn duplicated_chat_path_segment(&self) -> Option<&str> {
522        // The worst variant first: the base URL embeds the entire chat path
523        // (a pasted full endpoint URL), so the join repeats all of it.
524        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        // A bare scheme+host has no path segments; ignore the host itself.
543        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    /// Apply `AV_*` environment overrides from the process environment.
550    /// Environment beats file for these scalars (12-factor container
551    /// deployments override without editing mounted files). Key *values*
552    /// are still never read here — only `AV_UPSTREAM_API_KEY` presence
553    /// selects itself as the key source.
554    pub fn apply_env_overrides(&mut self) {
555        self.apply_env_overrides_from(|name| std::env::var(name).ok());
556    }
557
558    /// Testable core of [`Self::apply_env_overrides`].
559    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        // Empty string is meaningful here: raw-key (schemeless) headers.
574        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        // Docker/Kubernetes secrets arrive as mounted files; let those
587        // deployments point at one without editing config. File beats the
588        // AV_UPSTREAM_API_KEY convenience below but never a config-file
589        // key source (validate() rejects env+file ambiguity anyway).
590        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        // Convenience: exporting AV_UPSTREAM_API_KEY selects itself as the
599        // key source unless the file already chose one (file wins so a
600        // stray environment variable cannot silently replace a configured
601        // source; validate() still rejects genuinely ambiguous configs).
602        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    /// Structural validation.
612    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        // Shape-only check: hostnames are resolved at bind time, but a missing
623        // or non-numeric port would otherwise pass validation and only fail at
624        // server startup, defeating pre-flight `avctl config-validate`/doctor.
625        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        // Round-30 F1: reject the silent-anonymous-bypass posture.
720        // `enforce_identity_scopes = true` looks like it's guarding
721        // routes with `chat_scope` / `session_close_scope` / `tool:*`
722        // — but the scope check lives INSIDE the `(Some bearer, Some
723        // validator)` arm of `Pipeline::resolve_identity`. When
724        // `require_identity = false` (the shipped default), a request
725        // with no `Authorization` header short-circuits to the
726        // anonymous fallback and never sees the scope gate. The
727        // operator reads `enforce_identity_scopes = true` in
728        // `agentvisor.toml` and reasonably concludes "you need the
729        // chat scope to reach /v1/chat/completions" — in fact curl
730        // with no header proceeds as `anonymous`, producing the
731        // exact repudiation vector round-15 F3's Bearer-case fix
732        // documented. Refuse the combo so operators either turn
733        // enforcement off (making the posture explicit) or turn
734        // identity on.
735        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        // `tokio::time::interval(Duration::from_secs(0))` panics. Guard
748        // against a value that squeaks past the > 0 check via overflow
749        // arithmetic elsewhere by requiring a minimum plausible cadence
750        // — a JWKS refresh below 30 s hammers the IdP and offers no
751        // real benefit at NHI TTLs measured in minutes.
752        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        // Round-31 F2: scope names must be visible-ASCII non-empty
763        // tokens. An empty `chat_scope = ""` under
764        // `enforce_identity_scopes = true` + `require_identity =
765        // true` used to silently reduce the check to
766        // `identity.scopes.contains("")` — some IdPs emit empty
767        // scope entries after tokenizing a stray whitespace claim,
768        // so tokens without any real scope would satisfy the gate.
769        // Also reject whitespace/control bytes (OAuth scope
770        // tokens must not be re-tokenizable at any layer).
771        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        // Round-31 F1: refuse empty local-fs path fields. If an
798        // operator overrides `atif_spool_dir = ""` in TOML (e.g. by
799        // accidentally interpolating an unset env variable through a
800        // template), every ATIF spool op used to run on Path::new("")
801        // whose joins degrade to CWD-relative writes — receipts land
802        // in the process CWD instead of the expected volume, recovery
803        // scans miss them on the next boot. Same shape for
804        // `bridge_data_dir` (embedded broker segments in CWD).
805        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        // Upper bounds so a fat-finger in TOML cannot OOM the process
863        // before the runtime feels the misconfiguration. Values are
864        // deliberately loose: they only reject genuinely absurd numbers.
865        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        // 1 day is already unreasonably long for either an idle window
884        // or a JWKS refresh cadence; anything larger is almost certainly
885        // a unit-conversion error (someone thought the field was in ms).
886        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        // Shape-only URL check: reject `upstream_url` that lacks a
902        // scheme, so an operator setting e.g. `upstream_url =
903        // "openai.internal"` (missing `https://`) does not silently
904        // concatenate into a broken url. Full url::Url::parse is
905        // deferred to reqwest at request time.
906        //
907        // Round-38 F1: tighten to a strict http/https allowlist so
908        // this matches the round-30 F2 posture applied to
909        // `identity_jwks_url`, `qdrant_url`, etc. The old
910        // `contains("://")` check accepted `file:///etc/passwd`,
911        // `gopher://…`, and other schemes even though the error text
912        // claimed "must be http:// or https://" — a config-injection
913        // primitive or a templating typo (`${UPSTREAM:-file:///…}`)
914        // used to pass `avctl config-validate` and only fail at
915        // request time. Now every URL field's shape is preflighted
916        // by the same rule.
917        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        // Round-30 F2: extend the scheme allowlist to every URL
933        // field. `identity_jwks_url`, `qdrant_url`, `bridge_endpoint`
934        // (when NATS), and `state_endpoint` (when Redis) all used to
935        // be handed to their respective clients without preflight.
936        // A typo like `identity_jwks_url = "auth.internal/jwks"`
937        // (missing scheme) would pass `avctl config-validate` and
938        // only fail at first fetch — after the process was already
939        // serving traffic with an empty validator (every token ->
940        // UnknownKid). A hostile config-injection
941        // `identity_jwks_url = "file:///etc/passwd"` used to be
942        // just as invisible. Reject shape early.
943        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        // Kafka bridge_endpoint is a `host:port[,host:port]` bootstrap
981        // list, not a URL — no scheme check applies. rdkafka rejects
982        // malformed values on connect.
983        Ok(())
984    }
985
986    /// A config suitable for tests (temp dirs supplied by the caller).
987    ///
988    /// # Round-35 F3 — TESTS AND BENCHES ONLY
989    ///
990    /// The defaults here are DELIBERATELY permissive: `require_identity
991    /// = false`, `require_tool_schema = false`, `enforce_identity_
992    /// scopes = false`, `strict_stage_budget = false`. That posture is
993    /// safe for a test harness but MUST NOT be shipped into a
994    /// production boot path. This function is `#[doc(hidden)]` so it
995    /// does not appear in the public API surface (rustdoc, editor
996    /// completion) and cannot be discovered by a future "smoke boot"
997    /// helper looking for a quick config constructor. Any production
998    /// caller must build a `HarnessConfig` explicitly from
999    /// [`Self::from_toml`] so the deliberate posture flags are
1000    /// operator-visible in the config file.
1001    #[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    /// A typo'd key must fail loudly (naming the offender) instead of being
1082    /// silently ignored; `config_version` handles forward compatibility.
1083    #[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    /// The auth surface must reject every ambiguous or unsafe combination
1094    /// loudly at startup instead of picking one silently.
1095    #[test]
1096    fn upstream_auth_invariants_enforced() {
1097        // Both key sources set: ambiguous.
1098        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        // Passthrough plus static key: ambiguous.
1105        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        // Passthrough would forward the NHI token upstream.
1112        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        // Invalid header name.
1120        assert!(HarnessConfig::from_toml(
1121            r#"upstream_url = "https://api.openai.com"
1122               upstream_auth_header = "not a header""#
1123        )
1124        .is_err());
1125        // Scheme with embedded space.
1126        assert!(HarnessConfig::from_toml(
1127            r#"upstream_url = "https://api.openai.com"
1128               upstream_auth_scheme = "Bearer extra""#
1129        )
1130        .is_err());
1131        // Chat path must be absolute.
1132        assert!(HarnessConfig::from_toml(
1133            r#"upstream_url = "https://api.openai.com"
1134               upstream_chat_path = "v1/chat/completions""#
1135        )
1136        .is_err());
1137        // Tool bearer without a tool upstream is a misconfiguration.
1138        assert!(HarnessConfig::from_toml(
1139            r#"upstream_url = "https://api.openai.com"
1140               tool_upstream_bearer_env = "MCP_TOKEN""#
1141        )
1142        .is_err());
1143        // Azure-style raw key header is valid.
1144        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        // AV_UPSTREAM_API_KEY presence selects itself as the key source...
1174        assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("AV_UPSTREAM_API_KEY"));
1175
1176        // ...but never displaces an explicitly configured source.
1177        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        // Empty environment values are ignored rather than blanking fields.
1183        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        // AV_UPSTREAM_KEY_FILE selects the mounted-secret file...
1191        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        // ...and beats the AV_UPSTREAM_API_KEY self-selection (no ambiguity).
1202        assert_eq!(cfg.upstream_api_key_env, None);
1203
1204        // But never displaces a config-file key source.
1205        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        // And is ignored entirely in passthrough mode.
1212        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        // Hostnames and OS-assigned port 0 stay legal (bind resolves them).
1250        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    /// The `/v1` suffix footgun must be detected exactly: flagged when the
1285    /// base URL already ends with the first chat-path segment, silent for
1286    /// bare hosts, ports, and provider paths that do not overlap.
1287    #[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        // Gemini-style base path that does not repeat the chat path.
1305        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        // Azure-style custom path with a matching base suffix still flags.
1309        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        // A pasted full endpoint URL embeds the entire chat path.
1313        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()); // missing upstream
1326        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    /// A config from a newer format version must be refused by its declared
1344    /// `config_version` — not by whichever unknown key the strict parser
1345    /// happens to trip on first.
1346    #[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    /// Cap on `worker_channel_capacity` rejects the fat-finger config
1359    /// before it reaches the runtime (defence-in-depth; tokio's mpsc
1360    /// does not preallocate — see the `MAX_WORKER_CHANNEL_CAPACITY` doc).
1361    #[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    /// `upstream_url` without a scheme is rejected at load — otherwise
1375    /// the request-time concat would silently misroute to a bogus host.
1376    /// Round-38 F1 tightened the shape check from `contains("://")` to
1377    /// the strict `http://` / `https://` allowlist, matching the
1378    /// round-30 F2 posture on every other URL field.
1379    #[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    /// Round-38 F1: schemes other than http/https are rejected. The
1387    /// prior `contains("://")` shape check accepted `file:///…` and
1388    /// other schemes even though the error text claimed http/https;
1389    /// a config-injection primitive could have pointed the harness
1390    /// at `file:///etc/passwd`. `avctl config-validate` now refuses.
1391    #[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        // http and https are the only accepted schemes.
1398        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    /// A seconds interval > 1 day is almost certainly a unit-conversion
1403    /// error (someone thought the field was in milliseconds).
1404    #[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    /// Round-30 F1: refuse `enforce_identity_scopes = true` while
1419    /// `require_identity = false`. The combo silently falls through
1420    /// to the anonymous identity on unauthenticated requests,
1421    /// making the scope config a fig leaf.
1422    #[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        // Both `false` = clean dev posture, still passes.
1439        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    /// Round-30 F2: refuse URL fields that omit the scheme or use a
1448    /// scheme the client library will not accept. Preflight beats a
1449    /// runtime failure after the process is already serving traffic.
1450    #[test]
1451    fn round_30_f2_url_scheme_allowlist_enforced() {
1452        // JWKS URL: missing scheme.
1453        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        // JWKS URL: file scheme is a config-injection surface.
1461        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        // Redis state_endpoint: bad scheme.
1468        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        // NATS bridge_endpoint: bad scheme.
1476        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        // Qdrant: missing scheme.
1484        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        // Legit values pass.
1492        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    /// Round-31 F1: empty `atif_spool_dir` / `bridge_data_dir` are
1506    /// rejected. Without the check they default to `Path::new("")`,
1507    /// making every spool op write to the process CWD instead of the
1508    /// expected volume.
1509    #[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    /// Round-31 F2: identity scope names must be visible ASCII, no
1526    /// whitespace, no empty strings. Some IdPs tokenize an empty
1527    /// "scope" claim into an empty string; without this check the
1528    /// gate becomes `scopes.contains("")` and any token satisfies it.
1529    #[test]
1530    fn round_31_f2_scope_names_rejected_empty_or_whitespaced() {
1531        // Empty chat_scope.
1532        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        // Whitespace in scope.
1542        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        // Control byte in scope.
1552        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        // A well-formed scope passes.
1562        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}