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 `mcp_concurrency` (R71). Each admitted MCP call
17/// can buffer up to `MAX_TOOL_RESPONSE_BYTES = 16 MiB` in
18/// `read_limited_tool_response` (routes.rs), so the cap × 16 MiB
19/// bounds worst-case MCP resident memory. 4 096 × 16 MiB = 64 GiB
20/// worst case — a soft ceiling: operators typically want much less,
21/// and a fat-finger config like `mcp_concurrency = 100_000` would
22/// otherwise silently commit the runtime to a memory pressure model
23/// no real host survives. Defence-in-depth like
24/// `MAX_WORKER_CHANNEL_CAPACITY`.
25pub const MAX_MCP_CONCURRENCY: usize = 4_096;
26
27/// Upper bound on `max_request_bytes` (512 MiB). A single request body
28/// should never legitimately need more; lifting this defeats the
29/// sandbox payload guard and lets one request pin half a GB of RAM.
30pub const MAX_REQUEST_BYTES_CAP: usize = 512 * 1024 * 1024;
31
32/// Upper bound on `onnx_dimension`. Most sentence-transformer models
33/// have <= 4096 dims (Nomic Embed v1.5 = 768, MiniLM = 384, e5-mistral
34/// = 4096, Ada-002 = 1536). 16k is a comfortable ceiling.
35pub const MAX_ONNX_DIMENSION: usize = 16_384;
36
37/// Upper bound on any `_s` seconds interval. 1 day is already far
38/// beyond any reasonable value; anything larger is almost certainly a
39/// unit-conversion error (someone thought the field was in ms).
40pub const MAX_SECONDS_INTERVAL: u64 = 24 * 60 * 60;
41
42/// Per-phase timeout applied to both the worker `wait_idle` drain and
43/// the `finalize_sessions` sweep in `finish_shutdown`. Exposed on the
44/// public surface so the deployment-manifest pin tests below can
45/// compute the total shutdown budget from the SAME source of truth
46/// `main.rs` uses — a bump here that isn't matched by a bump in the
47/// K8s / compose grace periods trips CI at build time.
48pub const WORKER_FINALIZE_PHASE_SECS: u64 = 30;
49
50/// Deadline the OpenTelemetry provider gets to flush pending spans on
51/// shutdown when the `otel` feature is enabled. Same "single source of
52/// truth" purpose as [`WORKER_FINALIZE_PHASE_SECS`].
53pub const OTEL_FLUSH_SECS: u64 = 5;
54
55/// Default upstream read-idle timeout. Successful reads restart this
56/// timer; it is not a total deadline for streamed chat responses.
57pub const DEFAULT_UPSTREAM_READ_TIMEOUT_S: u64 = 60;
58
59/// Total deadline for an outbound MCP request, including its response body.
60pub const DEFAULT_MCP_REQUEST_TIMEOUT_S: u64 = 60;
61
62/// Top-level harness configuration.
63///
64/// Unknown keys are rejected (`deny_unknown_fields`) so a typo like
65/// `idel_timeout_s` fails loudly at startup instead of being silently
66/// ignored. Forward compatibility is handled by `config_version` gating,
67/// not by tolerating unrecognized keys.
68#[derive(Debug, Clone, Serialize, Deserialize)]
69#[serde(deny_unknown_fields)]
70pub struct HarnessConfig {
71    /// Config format version.
72    #[serde(default = "default_config_version")]
73    pub config_version: u32,
74    /// Listen address, e.g. `127.0.0.1:8484`.
75    #[serde(default = "default_listen")]
76    pub listen: String,
77    /// Upstream LLM provider base URL (OpenAI-compatible).
78    pub upstream_url: String,
79    /// Optional downstream MCP/REST tool server URL. When absent, `/v1/mcp`
80    /// operates as a policy decision endpoint.
81    #[serde(default)]
82    pub tool_upstream_url: Option<String>,
83    /// Use cleartext HTTP/2 prior knowledge for trusted h2c upstreams.
84    #[serde(default)]
85    pub upstream_http2_prior_knowledge: bool,
86    /// Optional upstream read-idle timeout override (default 60 s).
87    /// Successful reads restart this timer. It does not bound total
88    /// streamed-chat duration. Capped at one day like every `_s` interval.
89    #[serde(default)]
90    pub upstream_read_timeout_s: Option<u64>,
91    /// Total outbound MCP deadline, including response-body reads. The
92    /// default is 60 s; this also bounds tool work after caller cancellation.
93    /// This does not impose a total deadline on streamed chat responses.
94    #[serde(default)]
95    pub mcp_request_timeout_s: Option<u64>,
96    /// Graceful-shutdown drain budget in seconds. When unset the
97    /// effective budget is `max(30, max(upstream_read_timeout_s,
98    /// mcp_request_timeout_s) + 5)`, using each field's default when unset.
99    /// This covers one read-idle window or MCP network deadline with
100    /// padding; long chat streams and audit work may exceed it.
101    /// Kubernetes users must keep `terminationGracePeriodSeconds`
102    /// above this value or the kubelet SIGKILLs mid-drain.
103    #[serde(default)]
104    pub shutdown_drain_timeout_s: Option<u64>,
105    /// Readiness-controlled pre-drain window in seconds (default 0).
106    /// On SIGTERM/SIGINT the harness flips `/readyz` to 503 and then
107    /// KEEPS ACCEPTING connections for this long before the graceful
108    /// drain begins, so an external load balancer polling `/readyz`
109    /// can stop routing before the listener closes. Without it the
110    /// listener stops accepting the instant the signal lands and a
111    /// fresh readiness probe sees connection-refused, never the 503.
112    /// Set it to your LB's readiness poll interval plus one
113    /// reconciliation. Counts against the orchestrator's kill grace
114    /// period ON TOP of the drain budget.
115    ///
116    /// Kubernetes note: a preStop `sleep` hook can theoretically
117    /// provide the same window BEFORE SIGTERM lands, but only if
118    /// the runtime image contains a shell + `sleep`. The shipped
119    /// manifest at `deploy/kubernetes/agentvisor-ai.yaml` uses a
120    /// distroless base (chainguard/glibc-dynamic) that has neither,
121    /// so it sets this key instead and omits the preStop hook.
122    /// Bare-VM, systemd, and docker-compose deployments also need
123    /// this key because they have no preStop equivalent at all.
124    #[serde(default)]
125    pub shutdown_ready_drain_s: u64,
126    /// Chat-completions path appended to `upstream_url`. Override for
127    /// providers with non-standard layouts (Azure deployments, Gemini's
128    /// OpenAI-compatible surface).
129    #[serde(default = "default_chat_path")]
130    pub upstream_chat_path: String,
131    /// The upstream provider's wire dialect.
132    /// Selects the `ProviderAdapter` that parses response bodies and
133    /// SSE chunks. `"openai"` (the default) also fits vLLM, LiteLLM,
134    /// Groq, Together, DeepSeek, OpenRouter, Ollama, LM Studio,
135    /// llama.cpp, xAI, Mistral and Azure OpenAI.
136    #[serde(default = "default_provider")]
137    pub provider: String,
138    /// Name of an environment variable holding the upstream API key. The
139    /// key value itself never appears in this file or on the command line.
140    #[serde(default)]
141    pub upstream_api_key_env: Option<String>,
142    /// File containing the upstream API key (owner-only permissions are
143    /// enforced on Unix). Mutually exclusive with `upstream_api_key_env`.
144    #[serde(default)]
145    pub upstream_api_key_file: Option<String>,
146    /// Header carrying the upstream API key, e.g. `authorization` (OpenAI),
147    /// `api-key` (Azure), or `x-api-key`.
148    #[serde(default = "default_auth_header")]
149    pub upstream_auth_header: String,
150    /// Prefix inserted before the key in the auth header. `"Bearer"` yields
151    /// `Bearer <key>`; an empty string sends the raw key (Azure style).
152    #[serde(default = "default_auth_scheme")]
153    pub upstream_auth_scheme: String,
154    /// Forward each client's own `Authorization` header to the upstream
155    /// instead of injecting a server-side key. Incompatible with
156    /// `require_identity` (the header would carry the NHI token) and with
157    /// the static key options above.
158    #[serde(default)]
159    pub upstream_authorization_passthrough: bool,
160    /// Accept and DISCARD a client `Authorization` header when no identity
161    /// validator is configured. This is the "hero snippet" mode: every
162    /// stock OpenAI SDK requires an `api_key` and sends
163    /// `Authorization: Bearer <it>` unconditionally, but a dev harness
164    /// with no validator used to hard-401 that request — making the
165    /// documented one-line quickstart fail on request one. With this
166    /// flag the header is treated as if absent: the request proceeds
167    /// anonymously, the header is NEVER forwarded upstream (the
168    /// server-side key is injected instead), and nothing is recorded
169    /// from it. Refused in combination with `require_identity = true`
170    /// (the validator must see the header) and with
171    /// `upstream_authorization_passthrough = true` (contradictory:
172    /// cannot both discard and forward). `avctl init` writes this for
173    /// dev presets so the README quickstart works out of the box.
174    #[serde(default)]
175    pub ignore_client_authorization: bool,
176    /// Name of an environment variable holding a bearer token for
177    /// `tool_upstream_url` requests.
178    #[serde(default)]
179    pub tool_upstream_bearer_env: Option<String>,
180    /// File containing a bearer token for `tool_upstream_url` requests
181    /// (owner-only permissions enforced on Unix).
182    #[serde(default)]
183    pub tool_upstream_bearer_file: Option<String>,
184    /// Identity enforcement. When `false` (dev), requests without a token get
185    /// an anonymous identity; when `true`, unauthenticated requests are 401s.
186    #[serde(default)]
187    pub require_identity: bool,
188    /// Explicit opt-in for binding to a wildcard address (`0.0.0.0` / `[::]`)
189    /// while `require_identity = false`. Set this only when a network layer
190    /// outside the harness — a container port map, a service mesh, an
191    /// ingress ACL — controls who can reach the listener. Startup validation
192    /// otherwise refuses the combination so a bare-metal `cargo run` on a
193    /// developer laptop cannot silently expose an anonymous provider proxy
194    /// to a corporate LAN.
195    #[serde(default)]
196    pub allow_wildcard_bind: bool,
197    /// Deployment audience for NHI tokens.
198    #[serde(default = "default_audience")]
199    pub audience: String,
200    /// Corporate IdP JWKS endpoint for Ed25519 verification keys.
201    #[serde(default)]
202    pub identity_jwks_url: Option<String>,
203    /// JWKS refresh interval in seconds.
204    #[serde(default = "default_jwks_refresh")]
205    pub identity_jwks_refresh_s: u64,
206    /// Optional issuer allowlist (for example Okta or Entra tenant URLs).
207    #[serde(default)]
208    pub identity_allowed_issuers: Vec<String>,
209    /// Optional file containing an HS256 development secret.
210    #[serde(default)]
211    pub identity_hmac_secret_file: Option<String>,
212    /// Key id assigned to the development HMAC secret.
213    #[serde(default = "default_hmac_kid")]
214    pub identity_hmac_kid: String,
215    /// Maximum delegation depth for NHI token chains. Controls both
216    /// `parent_token` chain links and `act` claim nesting. Defaults to 4.
217    #[serde(default = "default_max_delegation_depth")]
218    pub max_delegation_depth: usize,
219    /// Regular expression every validated token's `sub` must match, so
220    /// each agent identity is attributable to a human principal (for
221    /// example `^user:`). Tokens whose subject does not match are refused
222    /// with 401. Unset (the default) accepts any subject.
223    #[serde(default)]
224    pub identity_human_subject_pattern: Option<String>,
225
226    /// Enforce operation scopes on validated identities.
227    ///
228    /// Default flipped from `true` to `false` so it
229    /// matches the also-default-`false` posture of
230    /// [`Self::require_identity`]. When `require_identity = false`,
231    /// unauthenticated requests short-circuit to the anonymous
232    /// identity BEFORE the scope gate runs — an operator who reads
233    /// `enforce_identity_scopes = true` in the config would
234    /// reasonably conclude "you need the scope to reach /v1/chat",
235    /// but in the default posture curl-with-no-header still
236    /// proceeds as anonymous. `validate()` now rejects the
237    /// `enforce_identity_scopes = true && require_identity = false`
238    /// combination outright; keeping the two defaults aligned makes
239    /// the shipped `harness.example.toml` and `harness.container.toml`
240    /// pass validate without extra changes. Operators turning on
241    /// identity enforcement in production set both flags to `true`
242    /// explicitly (see `harness.docker.toml`).
243    #[serde(default)]
244    pub enforce_identity_scopes: bool,
245    /// Scope required for chat completion requests.
246    #[serde(default = "default_chat_scope")]
247    pub chat_scope: String,
248    /// Scope required to close sessions.
249    #[serde(default = "default_close_scope")]
250    pub session_close_scope: String,
251    /// Scope required to promote unsigned sessions.
252    #[serde(default = "default_promote_scope")]
253    pub session_promote_scope: String,
254    /// Default workflow when `X-AV-Workflow` is absent: signed workflows are
255    /// opt-in by policy (brief Module G).
256    #[serde(default = "default_workflow")]
257    pub default_workflow: String,
258    /// Tools that require a signed workflow because they have real-world
259    /// consequences.
260    #[serde(default = "default_consequential_tools")]
261    pub consequential_tools: Vec<String>,
262    /// Directory containing one JSON Schema per tool, named `<tool>.json`.
263    #[serde(default = "default_tool_schema_dir")]
264    pub tool_schema_dir: Option<String>,
265    /// Reject tool calls when no matching schema was loaded.
266    #[serde(default = "default_true")]
267    pub require_tool_schema: bool,
268    /// Tool-call argument field carrying a payout amount in USD, charged
269    /// against `budget.max_payout_usd_micros`. This was a
270    /// hardcoded `"amount_usd"` buried in the sandbox constructor — a
271    /// tool using `amount`, `value`, or `total_usd` silently bypassed
272    /// the payout cap with no warning. Set this to match YOUR tool
273    /// schema; the cap only fires on calls that carry this exact field.
274    #[serde(default = "default_payout_field")]
275    pub payout_field: String,
276    /// WASM or WAT policy module paths, evaluated in order.
277    #[serde(default = "default_wasm_policies")]
278    pub wasm_policy_paths: Vec<String>,
279    /// Idle seconds after which a session is swept closed.
280    #[serde(default = "default_idle")]
281    pub session_idle_close_s: u64,
282    /// Directory for ATIF trajectory spool files.
283    #[serde(default = "default_spool")]
284    pub atif_spool_dir: String,
285    /// Bridge data directory (embedded broker).
286    #[serde(default = "default_bridge")]
287    pub bridge_data_dir: String,
288    /// Bridge backend: `embedded`, `kafka`, or `nats`.
289    #[serde(default = "default_bridge_backend")]
290    pub bridge_backend: String,
291    /// Declarative topic-schema manifest used by every Bridge backend.
292    #[serde(default = "default_bridge_manifest")]
293    pub bridge_manifest_path: String,
294    /// Kafka broker (`host:port[,host:port]`) or NATS URL (`nats://`/`tls://`)
295    /// for network Bridge backends. Secured endpoints read their material
296    /// from the environment: `AV_KAFKA_CA_FILE` + `AV_KAFKA_SASL_USERNAME`/
297    /// `AV_KAFKA_SASL_PASSWORD` (+ optional `AV_KAFKA_SASL_MECHANISM`:
298    /// `SCRAM-SHA-256` default, `SCRAM-SHA-512`, or `PLAIN`; credentials
299    /// are refused without the CA), and `AV_NATS_CA_FILE` (forces TLS) +
300    /// `AV_NATS_USER`/`AV_NATS_PASSWORD`.
301    #[serde(default)]
302    pub bridge_endpoint: Option<String>,
303    /// State backend: `memory` or `redis`.
304    #[serde(default = "default_state_backend")]
305    pub state_backend: String,
306    /// Redis URL for the distributed state backend. A comma-separated list
307    /// of URLs selects Redis Cluster mode.
308    #[serde(default)]
309    pub state_endpoint: Option<String>,
310    /// Embedding backend: `hash` or `onnx`.
311    #[serde(default = "default_embedder_backend")]
312    pub embedder_backend: String,
313    /// Customer-supplied ONNX model path.
314    #[serde(default)]
315    pub onnx_model_path: Option<String>,
316    /// Hugging Face tokenizer.json paired with the ONNX model.
317    #[serde(default)]
318    pub onnx_tokenizer_path: Option<String>,
319    /// ONNX model output width.
320    #[serde(default = "default_onnx_dimension")]
321    pub onnx_dimension: usize,
322    /// Vector persistence backend: `memory` or `qdrant`.
323    #[serde(default = "default_vector_backend")]
324    pub vector_backend: String,
325    /// Qdrant base URL.
326    #[serde(default)]
327    pub qdrant_url: Option<String>,
328    /// Qdrant collection receiving reasoning vectors.
329    #[serde(default = "default_qdrant_collection")]
330    pub qdrant_collection: String,
331    /// Worker channel capacity (bounded; overflow is counted, never blocking).
332    #[serde(default = "default_channel_cap")]
333    pub worker_channel_capacity: usize,
334    /// Max concurrent `mcp_call_inner` executions. Each call buffers
335    /// up to `MAX_TOOL_RESPONSE_BYTES = 16 MiB` (routes.rs), so the
336    /// admission cap × 16 MiB bounds worst-case MCP resident memory.
337    /// R70 F1 (landed R71): without this cap a slow-tool-upstream
338    /// attack + N concurrent MCP callers stacked N × 16 MiB in RAM
339    /// unbounded — the mcp_inflight tracker was a shutdown-drain
340    /// COUNTER, not a limiter.
341    ///
342    /// Default 128 (~2 GiB worst case). The `MAX_MCP_CONCURRENCY`
343    /// cap below refuses any config value above 4 096 for the same
344    /// class-safety reason as `MAX_WORKER_CHANNEL_CAPACITY`.
345    #[serde(default = "default_mcp_concurrency")]
346    pub mcp_concurrency: usize,
347    /// Strict per-stage budget assertions (AV_STRICT_BUDGET also enables).
348    #[serde(default)]
349    pub strict_stage_budget: bool,
350    /// Loop breaker configuration.
351    #[serde(default)]
352    pub breaker: av_loopdetect::BreakerConfig,
353    /// Compression configuration.
354    #[serde(default = "default_compression")]
355    pub compression_enabled: bool,
356    /// Token budget per session (compression/velocity accounting).
357    #[serde(default)]
358    pub budget: av_state::BudgetSpec,
359    /// Optional principal-scoped budget layered on top of [`Self::budget`].
360    ///
361    /// The default `[budget]` counters are keyed on the request's session
362    /// id — a header the caller chooses (see [`Self::require_identity`]).
363    /// A client that rotates `X-AV-Session` per request lands on a virgin
364    /// counter every time, so `budget.max_tokens` and friends never bind.
365    /// Setting `[principal_budget]` layers a second budget keyed on the
366    /// authenticated principal (the JWT `sub` / `instance_uid`, or the
367    /// HMAC key id); the counters accumulate across every session belonging
368    /// to that principal, so header rotation debits the same ledger.
369    ///
370    /// Startup validation refuses this section when
371    /// `require_identity = false` unless `allow_anonymous_principal_budget
372    /// = true` is also set: without a stable principal every request folds
373    /// into a single `"anonymous"` bucket, which is only useful in a
374    /// single-tenant appliance deployment where that is the desired
375    /// behaviour.
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub principal_budget: Option<av_state::BudgetSpec>,
378    /// Opt-in for `[principal_budget]` while `require_identity = false`.
379    /// See the field-level doc on [`Self::principal_budget`].
380    #[serde(default)]
381    pub allow_anonymous_principal_budget: bool,
382    /// Reconciler tick interval (seconds).
383    #[serde(default = "default_reconcile_tick")]
384    pub reconcile_tick_s: u64,
385    /// Days after which sealed ATIF trajectories and their `.atif-auth`
386    /// provenance sidecars are pruned from the spool. `None` (the shipping
387    /// default) preserves prior behaviour — the ATIF spool grows without
388    /// bound, matching an on-prem deployment that manages retention with
389    /// an external cron. Setting a positive value enables a periodic
390    /// prune sweep that removes pairs older than the window, keeping the
391    /// reconciler's per-tick scan cost bounded even for high-throughput
392    /// deployments.
393    #[serde(default, skip_serializing_if = "Option::is_none")]
394    pub atif_retention_days: Option<u32>,
395    /// Maximum request body size accepted on `/v1/chat/completions` and
396    /// `/mcp`. Defaults to 4 MiB, matching the sandbox's `MAX_PAYLOAD_BYTES`
397    /// so both routes carry the same effective limit — axum's own
398    /// `DefaultBodyLimit::MAX` is 2 MiB by default and would silently
399    /// reject legitimate large-context chat requests before the sandbox
400    /// even saw the payload. Operators serving very-long-context models
401    /// (Claude 200k, GPT-4 128k on maximally-verbose inputs) may need to
402    /// raise this.
403    #[serde(default = "default_max_request_bytes")]
404    pub max_request_bytes: usize,
405
406    /// Whether the built-in read-only operator dashboard is enabled.
407    ///
408    /// When true (the default), the harness serves:
409    ///   * `GET /dashboard` — an HTML/CSS/JS single-page dashboard,
410    ///   * `GET /dashboard/{style.css,app.js}` — the bundled assets,
411    ///   * `GET /api/v1/dashboard/{stats,sessions,sessions/:id}` — read-only
412    ///     JSON that mirrors the in-memory session registry.
413    ///
414    /// The endpoints are unauthenticated: they expose the same data that
415    /// already lands on disk (receipts) and in `/metrics`. Front the
416    /// harness with the same ingress control you use for `/metrics` if
417    /// this is a concern, or set this to `false` to disable them.
418    #[serde(default = "default_dashboard_enabled")]
419    pub dashboard_enabled: bool,
420
421    /// Host-header allowlist (DNS-rebinding defense).
422    ///
423    /// Empty (the default) disables the check — correct for
424    /// loopback-only binds and for deployments whose ingress already
425    /// enforces Host. When non-empty, every request whose `Host`
426    /// header (port stripped) is not in this list is refused with 403
427    /// BEFORE any handler runs. DNS rebinding lets a hostile page's
428    /// JavaScript reach a loopback-adjacent service under a hostname
429    /// the attacker controls; browser CSRF protections don't apply
430    /// because the resolved IP is ours while the Host is theirs —
431    /// pinning the expected hostnames closes it. Example:
432    /// `allowed_hosts = ["localhost", "127.0.0.1", "agentvisor.internal"]`.
433    #[serde(default)]
434    pub allowed_hosts: Vec<String>,
435
436    // ── Pillar 4: per-backend routing ────────────────────────────────
437    /// Named backends that MCP tool calls can be routed to. Each entry
438    /// defines a name, URL, and auth policy. When absent, the single
439    /// `tool_upstream_url` is used as an implicit "default" backend.
440    #[serde(default)]
441    pub backends: Vec<BackendConfig>,
442
443    /// Refuse to connect to any tool backend (including `tool_upstream_url`)
444    /// whose address is not private: loopback, RFC 1918, shared (RFC 6598),
445    /// link-local, or IPv6 unique-local. Host names are resolved and checked
446    /// on every connection; IP literals are checked at boot. The LLM
447    /// upstream and the AuthZEN PDP are not affected.
448    #[serde(default)]
449    pub require_private_backends: bool,
450
451    /// Wire protocol for the implicit backend built from
452    /// `tool_upstream_url`. Defaults to `json_rpc` (one plain JSON-RPC POST
453    /// per call, the historical behavior); set `mcp` when that upstream is
454    /// an MCP Streamable HTTP server. `[[backends]]` entries choose their
455    /// own `transport` and default to `mcp`.
456    #[serde(default = "default_tool_upstream_transport")]
457    pub tool_upstream_transport: BackendTransport,
458
459    /// Browser origins allowed to call the MCP endpoints. The MCP
460    /// Streamable HTTP transport requires servers to refuse requests whose
461    /// `Origin` header is present and not allowed (DNS-rebinding defense).
462    /// Agents run server-side and send no `Origin`, so the default (empty)
463    /// refuses every request that carries one. Entries are exact origins,
464    /// for example `https://console.example.com`.
465    #[serde(default)]
466    pub mcp_allowed_origins: Vec<String>,
467
468    // ── Pillar 2: intent mapping / AuthZEN ───────────────────────────
469    /// Tool-name → business-intent mapping. An unmapped tool is refused
470    /// with `UNMAPPED_TOOL` when `require_intent_mapping` is true.
471    #[serde(default)]
472    pub intent_map: std::collections::BTreeMap<String, String>,
473
474    /// When true, every tool call must have an entry in `intent_map` or
475    /// it is refused with `UNMAPPED_TOOL`. Defaults to false for
476    /// backward compatibility.
477    #[serde(default)]
478    pub require_intent_mapping: bool,
479
480    /// Active mission constraints. A mission narrows the static policy:
481    /// only the listed intents are permitted while the mission is active.
482    /// When absent, all mapped intents are permitted.
483    #[serde(default)]
484    pub mission: Option<MissionConfig>,
485
486    /// Per-agent missions (TOML `[[missions]]`). Each entry names the
487    /// agents, charters, or human principals it applies to. A call must
488    /// satisfy the global `[mission]` and every mission that selects its
489    /// caller, so missions only ever narrow the static policy.
490    #[serde(default)]
491    pub missions: Vec<MissionConfig>,
492
493    /// Intent token TTL in seconds. Defaults to 60 (the AuthZEN
494    /// recommendation for short-lived per-call tokens).
495    #[serde(default = "default_intent_token_ttl")]
496    pub intent_token_ttl_s: u64,
497
498    /// Platform workload identity (TOML `[workload_identity]`): accept RFC
499    /// 7523 assertions signed with a Cloud Foundry instance identity at
500    /// `/v1/token` and issue agent tokens to the registered workloads.
501    #[serde(default)]
502    pub workload_identity: Option<WorkloadIdentityConfig>,
503
504    /// Workloads that may obtain agent tokens (TOML `[[workload_identities]]`).
505    /// Each names the human principal its agents act for.
506    #[serde(default)]
507    pub workload_identities: Vec<WorkloadMapping>,
508
509    /// External AuthZEN policy decision point consulted on every tool call
510    /// after the local intent and mission checks permit it (TOML
511    /// `[authzen]`). Both must permit. Unreachable PDP → 503.
512    #[serde(default)]
513    pub authzen: Option<AuthzenConfig>,
514
515    // ── Pillar 3: token exchange ─────────────────────────────────────
516    /// Enable the RFC 8693 token exchange endpoint at
517    /// `/v1/token`. When true, the harness signs exchanged tokens
518    /// with a dedicated Ed25519 key (separate from the receipt signer).
519    #[serde(default)]
520    pub token_exchange_enabled: bool,
521
522    /// File containing the Ed25519 signing seed for token exchange.
523    /// Must be distinct from the receipt signing seed. Owner-only
524    /// permissions enforced on Unix.
525    #[serde(default)]
526    pub token_exchange_seed_file: Option<String>,
527
528    /// TTL in seconds for exchanged tokens. Defaults to 300 (five
529    /// minutes). Capped at 900 to prevent indefinite delegation.
530    #[serde(default = "default_exchange_ttl")]
531    pub token_exchange_ttl_s: u64,
532
533    /// Named operator credentials authorized for administrative revocation.
534    /// Only SHA-256 digests are stored in configuration.
535    #[serde(default)]
536    pub operator_tokens: Vec<OperatorTokenConfig>,
537
538    /// Separate backend credentials for the token introspection endpoint.
539    #[serde(default)]
540    pub introspection_tokens: Vec<IntrospectionTokenConfig>,
541
542    /// Maximum revocation audit events per minute. Defaults to 600.
543    #[serde(default = "default_revocation_audit_per_minute")]
544    pub revocation_audit_per_minute: u32,
545
546    // ── Pillar 6: OTEL per-tenant ────────────────────────────────────
547    /// Per-tenant OTEL exporter endpoint. When set, full-fidelity
548    /// traces are shipped to the customer's own collector.
549    #[serde(default)]
550    pub otel_tenant_endpoint: Option<String>,
551
552    /// File containing the bearer token for the tenant OTEL endpoint.
553    /// Owner-only permissions enforced on Unix.
554    #[serde(default)]
555    pub otel_tenant_auth_file: Option<String>,
556
557    /// What the tenant OTEL endpoint receives beyond request metadata.
558    /// `redacted` (the default) also exports every audited step as an
559    /// OpenTelemetry GenAI span with its content (prompts, outputs, tool
560    /// arguments and results), after redaction; `off` exports metadata
561    /// spans only. Content never goes to the operational collector.
562    #[serde(default)]
563    pub otel_tenant_content: TenantContent,
564
565    /// Sensitive-data redaction patterns (regex). Applied to event
566    /// payloads and ATIF step fields before journal write.
567    #[serde(default)]
568    pub redaction_patterns: Vec<String>,
569
570    /// JSON pointer paths to always redact in event payloads and structured
571    /// ATIF fields. Setting either paths or patterns also enables builtins.
572    #[serde(default)]
573    pub redaction_paths: Vec<String>,
574}
575
576/// Operator credential for administrative revocation.
577#[derive(Debug, Clone, Serialize, Deserialize)]
578#[serde(deny_unknown_fields)]
579pub struct OperatorTokenConfig {
580    /// Unique operator name recorded in administrative audit events.
581    pub name: String,
582    /// SHA-256 of the bearer token, encoded as 64 hexadecimal characters.
583    pub sha256: String,
584}
585
586/// Backend credential for introspection, separate from upstream credentials.
587#[derive(Debug, Clone, Serialize, Deserialize)]
588#[serde(deny_unknown_fields)]
589pub struct IntrospectionTokenConfig {
590    /// Configured backend name whose tokens this credential may inspect.
591    pub backend: String,
592    /// SHA-256 of the bearer token, encoded as 64 hexadecimal characters.
593    pub sha256: String,
594}
595
596fn default_revocation_audit_per_minute() -> u32 {
597    600
598}
599
600/// Per-backend routing configuration (pillar 4).
601#[derive(Debug, Clone, Serialize, Deserialize)]
602#[serde(deny_unknown_fields)]
603pub struct BackendConfig {
604    /// Unique backend name (used in intent mapping and metrics).
605    pub name: String,
606    /// Base URL of the backend MCP server.
607    pub url: String,
608    /// Auth policy for this backend.
609    #[serde(default = "default_backend_auth")]
610    pub auth: BackendAuth,
611    /// Tools routed to this backend. When empty, this backend serves
612    /// as the default for unmapped tools.
613    #[serde(default)]
614    pub tools: Vec<String>,
615    /// Wire protocol spoken to this backend. `mcp` (the default) is the
616    /// MCP Streamable HTTP transport: an `initialize` handshake per agent
617    /// session, `MCP-Session-Id` and `MCP-Protocol-Version` headers, and
618    /// SSE responses. `json_rpc` sends one plain JSON-RPC POST per call.
619    #[serde(default)]
620    pub transport: BackendTransport,
621}
622
623/// Wire protocol spoken to a tool backend.
624#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
625#[serde(rename_all = "snake_case")]
626pub enum BackendTransport {
627    /// MCP Streamable HTTP (protocol versions 2024-11-05 through 2025-11-25).
628    #[default]
629    Mcp,
630    /// One plain JSON-RPC 2.0 POST per call, no MCP session handshake.
631    JsonRpc,
632}
633
634fn default_tool_upstream_transport() -> BackendTransport {
635    BackendTransport::JsonRpc
636}
637
638/// An allowed browser origin is `scheme://host[:port]` with no path,
639/// query, fragment, or credentials (RFC 6454 serialization).
640pub(crate) fn validate_origin(origin: &str) -> Result<(), String> {
641    let parsed = reqwest::Url::parse(origin).map_err(|_| "not a valid origin".to_owned())?;
642    if !matches!(parsed.scheme(), "http" | "https") || parsed.host_str().is_none_or(str::is_empty) {
643        return Err("must be an http:// or https:// origin with a host".to_owned());
644    }
645    if !parsed.username().is_empty()
646        || parsed.password().is_some()
647        || parsed.query().is_some()
648        || parsed.fragment().is_some()
649        || origin.trim_end_matches('/') != parsed.origin().ascii_serialization()
650    {
651        return Err("must be exactly scheme://host[:port], without path, query, or credentials".to_owned());
652    }
653    Ok(())
654}
655
656/// Authentication mode for a backend.
657#[derive(Debug, Clone, Default, Serialize, Deserialize)]
658#[serde(rename_all = "snake_case")]
659pub enum BackendAuth {
660    /// No authentication.
661    #[default]
662    None,
663    /// Static bearer token from an environment variable.
664    StaticEnv(String),
665    /// Static bearer token from a file (owner-only permissions enforced).
666    StaticFile(String),
667    /// Exchange the inbound NHI token for a backend-scoped token via
668    /// the harness's token exchange service. The exchanged token
669    /// carries `aud` set to this backend's name.
670    Exchange,
671    /// Forward the caller's own validated bearer token to this backend,
672    /// and to no other. Requires `require_identity = true`. The backend
673    /// receives a token whose audience is the gateway, so it could replay
674    /// that token against the gateway; use it only for backends you trust
675    /// as much as the gateway, and prefer `exchange` otherwise.
676    ForwardToken,
677}
678
679/// Mission configuration: narrows the static policy for a time window.
680#[derive(Debug, Clone, Serialize, Deserialize)]
681#[serde(deny_unknown_fields)]
682pub struct MissionConfig {
683    /// Mission identifier.
684    pub id: String,
685    /// Permitted intents during this mission. Tool calls whose mapped
686    /// intent is not in this list are refused with `MISSION_DENIED`.
687    pub allowed_intents: Vec<String>,
688    /// Mission expiry as epoch seconds. After this time, all tool calls
689    /// are refused with `MISSION_EXPIRED`.
690    pub expires_at: u64,
691    /// `[[missions]]` only: agents (token `instance_uid`) this mission
692    /// applies to.
693    #[serde(default, skip_serializing_if = "Vec::is_empty")]
694    pub agents: Vec<String>,
695    /// `[[missions]]` only: agent charters this mission applies to.
696    #[serde(default, skip_serializing_if = "Vec::is_empty")]
697    pub charters: Vec<String>,
698    /// `[[missions]]` only: human principals (token `sub`) this mission
699    /// applies to.
700    #[serde(default, skip_serializing_if = "Vec::is_empty")]
701    pub subjects: Vec<String>,
702}
703
704impl MissionConfig {
705    /// True when at least one selector is set (a per-agent mission).
706    pub fn has_selectors(&self) -> bool {
707        !self.agents.is_empty() || !self.charters.is_empty() || !self.subjects.is_empty()
708    }
709
710    /// True when this per-agent mission applies to the caller: every set
711    /// selector must match. A mission without selectors selects nobody
712    /// here (the global `[mission]` applies to everyone by itself).
713    pub fn selects(&self, instance_uid: &str, charter: &str, subject: Option<&str>) -> bool {
714        self.has_selectors()
715            && (self.agents.is_empty() || self.agents.iter().any(|agent| agent == instance_uid))
716            && (self.charters.is_empty() || self.charters.iter().any(|name| name == charter))
717            && (self.subjects.is_empty() || subject.is_some_and(|sub| self.subjects.iter().any(|s| s == sub)))
718    }
719}
720
721/// `[workload_identity]` settings.
722#[derive(Debug, Clone, Serialize, Deserialize)]
723#[serde(deny_unknown_fields)]
724pub struct WorkloadIdentityConfig {
725    /// PEM bundle of the platform's instance-identity root CA certificates.
726    pub ca_file: String,
727    /// Lifetime of issued agent tokens in seconds (60 to 900, default 300).
728    #[serde(default = "default_workload_token_ttl")]
729    pub token_ttl_s: u64,
730}
731
732fn default_workload_token_ttl() -> u64 {
733    300
734}
735
736/// One `[[workload_identities]]` entry.
737#[derive(Debug, Clone, Serialize, Deserialize)]
738#[serde(deny_unknown_fields)]
739pub struct WorkloadMapping {
740    /// Unique name (metrics and logs).
741    pub name: String,
742    /// Cloud Foundry app GUID the instance must belong to.
743    #[serde(default)]
744    pub cf_app_guid: Option<String>,
745    /// Cloud Foundry space GUID the instance must belong to.
746    #[serde(default)]
747    pub cf_space_guid: Option<String>,
748    /// Cloud Foundry organization GUID the instance must belong to.
749    #[serde(default)]
750    pub cf_org_guid: Option<String>,
751    /// The human principal the workload's agents act for (token `sub`).
752    pub sub: String,
753    /// Agent charter recorded in the token and every audit event.
754    pub charter: String,
755    /// Agent version recorded in the token.
756    #[serde(default = "default_workload_version")]
757    pub version: String,
758    /// Scopes granted to the workload's agents.
759    pub scopes: Vec<String>,
760}
761
762fn default_workload_version() -> String {
763    "1".to_owned()
764}
765
766impl WorkloadMapping {
767    /// True when every set selector matches the proven identity.
768    pub fn selects(&self, identity: &av_identity::CfInstanceIdentity) -> bool {
769        let matches =
770            |expected: &Option<String>, actual: &str| expected.as_deref().is_none_or(|value| value == actual);
771        (self.cf_app_guid.is_some() || self.cf_space_guid.is_some() || self.cf_org_guid.is_some())
772            && matches(&self.cf_app_guid, &identity.app_guid)
773            && matches(&self.cf_space_guid, &identity.space_guid)
774            && matches(&self.cf_org_guid, &identity.org_guid)
775    }
776}
777
778/// Content exported to the tenant OTEL endpoint.
779#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
780#[serde(rename_all = "snake_case")]
781pub enum TenantContent {
782    /// Full step content as GenAI spans, redacted before export.
783    #[default]
784    Redacted,
785    /// Request metadata spans only.
786    Off,
787}
788
789/// External AuthZEN policy decision point
790/// ([Authorization API 1.0](https://openid.net/specs/authorization-api-1_0.html)).
791#[derive(Debug, Clone, Serialize, Deserialize)]
792#[serde(deny_unknown_fields)]
793pub struct AuthzenConfig {
794    /// Access Evaluation endpoint, e.g.
795    /// `https://pdp.example.com/access/v1/evaluation`.
796    pub evaluation_endpoint: String,
797    /// Optional Access Evaluations (batch) endpoint, e.g.
798    /// `https://pdp.example.com/access/v1/evaluations`, used to filter
799    /// `tools/list` in one request. Without it, `tools/list` sends one
800    /// evaluation per tool.
801    #[serde(default)]
802    pub evaluations_endpoint: Option<String>,
803    /// File holding a bearer token for the PDP (owner-only permissions).
804    #[serde(default)]
805    pub auth_file: Option<String>,
806    /// Per-request timeout in milliseconds (1 to 10 000, default 1 000).
807    /// A timeout or error refuses the call with 503 (fail closed).
808    #[serde(default = "default_authzen_timeout_ms")]
809    pub timeout_ms: u64,
810}
811
812fn default_authzen_timeout_ms() -> u64 {
813    1_000
814}
815
816fn default_max_delegation_depth() -> usize {
817    4
818}
819fn default_intent_token_ttl() -> u64 {
820    60
821}
822fn default_exchange_ttl() -> u64 {
823    300
824}
825fn default_backend_auth() -> BackendAuth {
826    BackendAuth::None
827}
828fn default_config_version() -> u32 {
829    CONFIG_VERSION
830}
831fn default_listen() -> String {
832    "127.0.0.1:8484".to_owned()
833}
834fn default_audience() -> String {
835    "agentvisor-ai".to_owned()
836}
837fn default_chat_path() -> String {
838    "/v1/chat/completions".to_owned()
839}
840fn default_auth_header() -> String {
841    "authorization".to_owned()
842}
843fn default_auth_scheme() -> String {
844    "Bearer".to_owned()
845}
846fn default_jwks_refresh() -> u64 {
847    300
848}
849fn default_hmac_kid() -> String {
850    "dev-hmac".to_owned()
851}
852fn default_chat_scope() -> String {
853    "chat:write".to_owned()
854}
855fn default_close_scope() -> String {
856    "session:close".to_owned()
857}
858fn default_promote_scope() -> String {
859    "session:promote".to_owned()
860}
861fn default_workflow() -> String {
862    crate::session::Workflow::Unsigned.as_str().to_owned()
863}
864fn default_consequential_tools() -> Vec<String> {
865    ["db_write", "payout", "merge", "deploy"]
866        .into_iter()
867        .map(str::to_owned)
868        .collect()
869}
870fn default_tool_schema_dir() -> Option<String> {
871    Some("config/tool-schemas".to_owned())
872}
873fn default_true() -> bool {
874    true
875}
876fn default_payout_field() -> String {
877    "amount_usd".to_owned()
878}
879fn default_wasm_policies() -> Vec<String> {
880    vec!["config/policies/payload_limit.wat".to_owned()]
881}
882fn default_idle() -> u64 {
883    900
884}
885fn default_spool() -> String {
886    "spool/atif".to_owned()
887}
888fn default_bridge() -> String {
889    "data/bridge".to_owned()
890}
891fn default_bridge_backend() -> String {
892    "embedded".to_owned()
893}
894fn default_provider() -> String {
895    "openai".to_owned()
896}
897fn default_bridge_manifest() -> String {
898    "manifests/bridge.example.yaml".to_owned()
899}
900fn default_state_backend() -> String {
901    "memory".to_owned()
902}
903fn default_embedder_backend() -> String {
904    "hash".to_owned()
905}
906fn default_onnx_dimension() -> usize {
907    384
908}
909fn default_vector_backend() -> String {
910    "memory".to_owned()
911}
912fn default_qdrant_collection() -> String {
913    "agent_steps".to_owned()
914}
915fn default_channel_cap() -> usize {
916    32_768
917}
918fn default_mcp_concurrency() -> usize {
919    128
920}
921fn default_compression() -> bool {
922    true
923}
924fn default_reconcile_tick() -> u64 {
925    5
926}
927fn default_max_request_bytes() -> usize {
928    4 * 1024 * 1024
929}
930fn default_dashboard_enabled() -> bool {
931    true
932}
933
934/// Typed bridge backend selection, resolved from the flat TOML fields.
935///
936/// The TOML wire format deliberately keeps the shipped flat keys
937/// (`bridge_backend = "kafka"` + `bridge_endpoint = …`): this is a
938/// public tool with configs in the field, and a `config_version` bump
939/// that strands them is a hazard. Everything downstream of parsing
940/// works with these enums instead — each variant carries its required
941/// companions, so "kafka without an endpoint" is unrepresentable once
942/// resolved. The per-selector accessors on [`HarnessConfig`]
943/// ([`HarnessConfig::bridge`] and friends) are the SINGLE site that
944/// owns the legal-value vocabulary and the required-companion rules;
945/// `validate()` and the daemon's backend factories both delegate to
946/// them (previously the four selectors were `String`s whose
947/// legal values were enumerated twice, with companion rules spread
948/// across four more sites).
949#[derive(Debug, Clone, PartialEq, Eq)]
950pub enum BridgeBackend {
951    /// Embedded broker persisting under `bridge_data_dir`.
952    Embedded,
953    /// Kafka/Redpanda; `endpoint` is the `host:port[,host:port]`
954    /// bootstrap list (not a URL — no scheme check applies).
955    Kafka {
956        /// Bootstrap list, `host:port[,host:port]`.
957        endpoint: String,
958    },
959    /// NATS JetStream; `endpoint` is a `nats://` or `tls://` URL.
960    Nats {
961        /// `nats://` or `tls://` URL.
962        endpoint: String,
963    },
964}
965
966/// Typed state backend selection. See [`BridgeBackend`] for why the
967/// TOML surface stays flat while the resolved form is an enum.
968#[derive(Debug, Clone, PartialEq, Eq)]
969pub enum StateBackend {
970    /// Process-local, non-durable counters.
971    Memory,
972    /// Redis / Redis Cluster; `endpoint` is one URL or a
973    /// comma-separated list (cluster mode).
974    Redis {
975        /// Redis URL, or a comma-separated list for cluster mode.
976        endpoint: String,
977    },
978}
979
980/// Typed embedding backend selection. See [`BridgeBackend`].
981#[derive(Debug, Clone, PartialEq, Eq)]
982pub enum EmbedderBackend {
983    /// Deterministic hash embedder (no model files).
984    Hash,
985    /// Customer-supplied ONNX model plus its Hugging Face tokenizer.
986    Onnx {
987        /// Path to the ONNX model file.
988        model_path: String,
989        /// Path to the paired Hugging Face `tokenizer.json`.
990        tokenizer_path: String,
991    },
992}
993
994/// Typed vector persistence backend selection. See [`BridgeBackend`].
995#[derive(Debug, Clone, PartialEq, Eq)]
996pub enum VectorBackend {
997    /// No persistence (in-memory no-op sink).
998    Memory,
999    /// Qdrant; `url` is the http(s) base URL.
1000    Qdrant {
1001        /// Qdrant base URL (http:// or https://).
1002        url: String,
1003    },
1004}
1005
1006/// All four backend selections resolved into their typed forms.
1007#[derive(Debug, Clone, PartialEq, Eq)]
1008pub struct ResolvedBackends {
1009    /// Resolved bridge (event bus) backend.
1010    pub bridge: BridgeBackend,
1011    /// Resolved state store backend.
1012    pub state: StateBackend,
1013    /// Resolved embedding backend.
1014    pub embedder: EmbedderBackend,
1015    /// Resolved vector persistence backend.
1016    pub vector: VectorBackend,
1017}
1018
1019/// Where the effective configuration came from.
1020#[derive(Debug, Clone, PartialEq, Eq)]
1021pub enum ConfigSource {
1022    /// Loaded from a TOML file on disk.
1023    File(std::path::PathBuf),
1024    /// Built-in defaults (zero-config mode; requires `AV_UPSTREAM_URL`).
1025    BuiltIn,
1026}
1027
1028impl std::fmt::Display for ConfigSource {
1029    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1030        match self {
1031            Self::File(path) => write!(f, "{}", path.display()),
1032            Self::BuiltIn => f.write_str("built-in defaults (zero-config)"),
1033        }
1034    }
1035}
1036
1037/// Well-known config locations probed in order when `AV_CONFIG` is unset.
1038/// Project-local files (current directory) beat the per-user file the
1039/// `avctl` setup wizard writes (see [`user_config_path`]).
1040///
1041/// Note: `config/harness.example.toml` is deliberately NOT in this list.
1042/// The example config is a template intended to be *copied* to a real
1043/// `agentvisor.toml` or `config/harness.toml`; auto-discovering it from
1044/// a working checkout silently defeats the wizard-written per-user file
1045/// (developers who ran `avctl init` still saw the example's settings
1046/// because the example outranked the wizard file).
1047pub const CONFIG_SEARCH_PATHS: [&str; 2] = ["agentvisor.toml", "config/harness.toml"];
1048
1049/// Per-user config file inside `home`, as written by the `avctl` wizard.
1050pub fn user_config_path_from(home: &std::path::Path) -> std::path::PathBuf {
1051    home.join(".agentvisor").join("agentvisor.toml")
1052}
1053
1054/// Per-user config file (`~/.agentvisor/agentvisor.toml`), if the home
1055/// directory is known.
1056pub fn user_config_path() -> Option<std::path::PathBuf> {
1057    #[allow(deprecated)] // undeprecated in Rust 1.86; MSRV is 1.88
1058    std::env::home_dir().map(|home| user_config_path_from(&home))
1059}
1060
1061/// Resolve the configuration source without reading it.
1062///
1063/// Order: `AV_CONFIG` (must exist — a typo must never silently fall
1064/// through to another file), then [`CONFIG_SEARCH_PATHS`], then the
1065/// per-user wizard file, then built-in defaults driven by
1066/// `AV_UPSTREAM_URL`.
1067pub fn resolve_config_source() -> Result<ConfigSource, String> {
1068    if let Some(path) = std::env::var_os("AV_CONFIG") {
1069        // Empty means unset: compose files commonly render
1070        // `AV_CONFIG: ${AV_CONFIG:-}` which must not become a hard error.
1071        if !path.is_empty() {
1072            let path = std::path::PathBuf::from(path);
1073            if !path.is_file() {
1074                return Err(format!(
1075                    "AV_CONFIG points to {} which does not exist or is not a file",
1076                    path.display()
1077                ));
1078            }
1079            return Ok(ConfigSource::File(path));
1080        }
1081    }
1082    for candidate in CONFIG_SEARCH_PATHS {
1083        let path = std::path::Path::new(candidate);
1084        if path.is_file() {
1085            return Ok(ConfigSource::File(path.to_path_buf()));
1086        }
1087    }
1088    if let Some(path) = user_config_path() {
1089        if path.is_file() {
1090            return Ok(ConfigSource::File(path));
1091        }
1092    }
1093    Ok(ConfigSource::BuiltIn)
1094}
1095
1096/// Load, apply environment overrides, and validate the effective config.
1097pub fn load_config() -> Result<(HarnessConfig, ConfigSource), String> {
1098    load_config_with_override(None)
1099}
1100
1101/// [`load_config`] with an explicit config path (e.g. from `--config`)
1102/// taking precedence over `$AV_CONFIG` and the search paths. The explicit
1103/// path must exist — a dangling operator-supplied path is a hard error,
1104/// never a silent fallback to a different config.
1105pub fn load_config_with_override(
1106    explicit: Option<std::path::PathBuf>,
1107) -> Result<(HarnessConfig, ConfigSource), String> {
1108    let source = match explicit {
1109        Some(path) => {
1110            if !path.is_file() {
1111                return Err(format!(
1112                    "--config points to {} which does not exist or is not a file",
1113                    path.display()
1114                ));
1115            }
1116            ConfigSource::File(path)
1117        }
1118        None => resolve_config_source()?,
1119    };
1120    let mut config = match &source {
1121        ConfigSource::File(path) => {
1122            // Same MAX_CONTROL_BYTES cap as `avctl config-validate`, so
1123            // the offline validator and the daemon reach the same verdict
1124            // on the same file (and a mispointed path at a huge file
1125            // cannot balloon boot memory).
1126            let text = av_core::fsutil::read_capped_string(
1127                std::path::Path::new(path),
1128                av_core::fsutil::MAX_CONTROL_BYTES,
1129            )
1130            .map_err(|error| format!("read harness config {}: {error}", path.display()))?;
1131            HarnessConfig::from_toml_unvalidated(&text)
1132                .map_err(|error| format!("{}: {error}", path.display()))?
1133        }
1134        ConfigSource::BuiltIn => HarnessConfig::builtin()?,
1135    };
1136    config.apply_env_overrides();
1137    if config.upstream_url.is_empty() && source == ConfigSource::BuiltIn {
1138        return Err(format!(
1139            "no configuration found and AV_UPSTREAM_URL is not set.\n\
1140             Quick start (pick one):\n\
1141             \x20 avctl                              # guided setup\n\
1142             \x20 avctl init --preset openai        # write an annotated agentvisor.toml\n\
1143             \x20 AV_UPSTREAM_URL=http://127.0.0.1:11434 agentvisord   # zero-config\n\
1144             Searched: $AV_CONFIG, {}, ~/.agentvisor/agentvisor.toml",
1145            CONFIG_SEARCH_PATHS.join(", ")
1146        ));
1147    }
1148    config.validate().map_err(|error| format!("{source}: {error}"))?;
1149    Ok((config, source))
1150}
1151
1152impl HarnessConfig {
1153    /// Read the optional tenant telemetry bearer token using the same
1154    /// owner-only secret-file checks as backend credentials.
1155    pub fn tenant_otel_authorization(&self) -> Result<Option<axum::http::HeaderValue>, String> {
1156        let token = crate::pipeline::read_secret(
1157            None,
1158            self.otel_tenant_auth_file.as_deref(),
1159            "tenant OTEL bearer token",
1160        )
1161        .map_err(|error| error.to_string())?;
1162        token
1163            .map(|token| {
1164                let mut header =
1165                    axum::http::HeaderValue::from_str(&format!("Bearer {token}")).map_err(|_| {
1166                        "tenant OTEL bearer token cannot be represented as an HTTP header".to_owned()
1167                    })?;
1168                header.set_sensitive(true);
1169                Ok(header)
1170            })
1171            .transpose()
1172    }
1173
1174    /// Parse from TOML, validating the version and structural sanity.
1175    pub fn from_toml(s: &str) -> Result<Self, String> {
1176        Self::check_declared_version(s)?;
1177        let cfg: Self = toml::from_str(s).map_err(|e| format!("config parse: {e}"))?;
1178        cfg.validate()?;
1179        Ok(cfg)
1180    }
1181
1182    /// Parse from TOML without validating, so environment overrides can be
1183    /// applied first (`main` validates after [`Self::apply_env_overrides`]).
1184    pub fn from_toml_unvalidated(s: &str) -> Result<Self, String> {
1185        Self::check_declared_version(s)?;
1186        toml::from_str(s).map_err(|e| format!("config parse: {e}"))
1187    }
1188
1189    /// Pre-pass on the loosely-parsed document so a config written for a
1190    /// newer format version reports "unsupported config_version N" instead
1191    /// of tripping the strict unknown-field rejection on whatever new key
1192    /// appears first.
1193    fn check_declared_version(s: &str) -> Result<(), String> {
1194        let loose: toml::Value = toml::from_str(s).map_err(|e| format!("config parse: {e}"))?;
1195        if let Some(version) = loose.get("config_version") {
1196            let declared = version.as_integer().unwrap_or(-1);
1197            if declared != i64::from(CONFIG_VERSION) {
1198                return Err(format!(
1199                    "unsupported config_version {version} (this build supports {CONFIG_VERSION})",
1200                ));
1201            }
1202        }
1203        Ok(())
1204    }
1205
1206    /// A config of pure built-in defaults for zero-config startup. The
1207    /// caller must supply `upstream_url` (typically `AV_UPSTREAM_URL`)
1208    /// before validation.
1209    pub fn builtin() -> Result<Self, String> {
1210        toml::from_str("upstream_url = \"\"").map_err(|e| format!("built-in config: {e}"))
1211    }
1212
1213    /// True when `bridge_manifest_path` is the compiled-in default, i.e.
1214    /// the operator never chose a manifest. Only then may the binary fall
1215    /// back to its embedded manifest when the file is absent; an explicit
1216    /// path that is missing must stay a hard error.
1217    pub fn uses_default_manifest_path(&self) -> bool {
1218        self.bridge_manifest_path == default_bridge_manifest()
1219    }
1220
1221    /// True when `tool_schema_dir` is the compiled-in default (see
1222    /// [`Self::uses_default_manifest_path`] for the fallback rationale).
1223    pub fn uses_default_tool_schema_dir(&self) -> bool {
1224        self.tool_schema_dir == default_tool_schema_dir()
1225    }
1226
1227    /// True when `path` is the compiled-in default WASM policy entry (see
1228    /// [`Self::uses_default_manifest_path`] for the fallback rationale).
1229    pub fn is_default_policy_path(path: &str) -> bool {
1230        default_wasm_policies().iter().any(|entry| entry == path)
1231    }
1232
1233    /// Effective graceful-shutdown drain budget. Explicit
1234    /// `shutdown_drain_timeout_s` wins; otherwise allow the greater of one
1235    /// upstream read-idle window and the total MCP network deadline, plus
1236    /// five seconds, with a 30-second floor. Long streamed chat responses
1237    /// and audit work are still subject to this shutdown deadline.
1238    pub fn effective_drain_timeout(&self) -> std::time::Duration {
1239        let read_timeout_s = self
1240            .upstream_read_timeout_s
1241            .unwrap_or(DEFAULT_UPSTREAM_READ_TIMEOUT_S);
1242        let mcp_timeout_s = self
1243            .mcp_request_timeout_s
1244            .unwrap_or(DEFAULT_MCP_REQUEST_TIMEOUT_S);
1245        let seconds = self
1246            .shutdown_drain_timeout_s
1247            .unwrap_or_else(|| read_timeout_s.max(mcp_timeout_s).saturating_add(5).max(30));
1248        std::time::Duration::from_secs(seconds)
1249    }
1250
1251    /// Detect the classic footgun of `upstream_url` already ending with the
1252    /// first segment of `upstream_chat_path` (for example a base URL of
1253    /// `https://api.openai.com/v1` joined with `/v1/chat/completions`
1254    /// produces `/v1/v1/...` and a confusing provider 404). Returns the
1255    /// duplicated segment for warning messages.
1256    pub fn duplicated_chat_path_segment(&self) -> Option<&str> {
1257        // The worst variant first: the base URL embeds the entire chat path
1258        // (a pasted full endpoint URL), so the join repeats all of it.
1259        if !self.upstream_chat_path.is_empty()
1260            && self
1261                .upstream_url
1262                .trim_end_matches('/')
1263                .ends_with(&self.upstream_chat_path)
1264        {
1265            return Some(self.upstream_chat_path.trim_start_matches('/'));
1266        }
1267        let first_segment = self
1268            .upstream_chat_path
1269            .trim_start_matches('/')
1270            .split('/')
1271            .next()?;
1272        if first_segment.is_empty() {
1273            return None;
1274        }
1275        let base = self.upstream_url.trim_end_matches('/');
1276        let last_segment = base.rsplit('/').next()?;
1277        // A bare scheme+host has no path segments; ignore the host itself.
1278        if last_segment.contains('.') || last_segment.contains(':') || base.ends_with("//") {
1279            return None;
1280        }
1281        (last_segment == first_segment).then_some(first_segment)
1282    }
1283
1284    /// Apply `AV_*` environment overrides from the process environment.
1285    /// Environment beats file for these scalars (12-factor container
1286    /// deployments override without editing mounted files). Key *values*
1287    /// are still never read here — only `AV_UPSTREAM_API_KEY` presence
1288    /// selects itself as the key source.
1289    pub fn apply_env_overrides(&mut self) {
1290        self.apply_env_overrides_from(|name| std::env::var(name).ok());
1291    }
1292
1293    /// Testable core of [`Self::apply_env_overrides`].
1294    pub fn apply_env_overrides_from(&mut self, get: impl Fn(&str) -> Option<String>) {
1295        let non_empty = |value: String| if value.is_empty() { None } else { Some(value) };
1296        if let Some(listen) = get("AV_LISTEN").and_then(non_empty) {
1297            self.listen = listen;
1298        }
1299        if let Some(url) = get("AV_UPSTREAM_URL").and_then(non_empty) {
1300            self.upstream_url = url;
1301        }
1302        if let Some(path) = get("AV_UPSTREAM_CHAT_PATH").and_then(non_empty) {
1303            self.upstream_chat_path = path;
1304        }
1305        if let Some(header) = get("AV_UPSTREAM_AUTH_HEADER").and_then(non_empty) {
1306            self.upstream_auth_header = header;
1307        }
1308        // Empty string is meaningful here: raw-key (schemeless) headers.
1309        if let Some(scheme) = get("AV_UPSTREAM_AUTH_SCHEME") {
1310            self.upstream_auth_scheme = scheme;
1311        }
1312        if let Some(endpoint) = get("AV_STATE_ENDPOINT").and_then(non_empty) {
1313            self.state_endpoint = Some(endpoint);
1314        }
1315        if let Some(endpoint) = get("AV_BRIDGE_ENDPOINT").and_then(non_empty) {
1316            self.bridge_endpoint = Some(endpoint);
1317        }
1318        if let Some(url) = get("AV_QDRANT_URL").and_then(non_empty) {
1319            self.qdrant_url = Some(url);
1320        }
1321        // Docker/Kubernetes secrets arrive as mounted files; let those
1322        // deployments point at one without editing config. File beats the
1323        // AV_UPSTREAM_API_KEY convenience below but never a config-file
1324        // key source (validate() rejects env+file ambiguity anyway).
1325        if self.upstream_api_key_env.is_none()
1326            && self.upstream_api_key_file.is_none()
1327            && !self.upstream_authorization_passthrough
1328        {
1329            if let Some(path) = get("AV_UPSTREAM_KEY_FILE").and_then(non_empty) {
1330                self.upstream_api_key_file = Some(path);
1331            }
1332        }
1333        // Convenience: exporting AV_UPSTREAM_API_KEY selects itself as the
1334        // key source unless the file already chose one (file wins so a
1335        // stray environment variable cannot silently replace a configured
1336        // source; validate() still rejects genuinely ambiguous configs).
1337        if self.upstream_api_key_env.is_none()
1338            && self.upstream_api_key_file.is_none()
1339            && !self.upstream_authorization_passthrough
1340            && get("AV_UPSTREAM_API_KEY").and_then(non_empty).is_some()
1341        {
1342            self.upstream_api_key_env = Some("AV_UPSTREAM_API_KEY".to_owned());
1343        }
1344    }
1345
1346    /// A required companion field for a non-default backend: present and
1347    /// non-empty, or the caller's named error.
1348    fn required_companion<'a>(value: Option<&'a str>, error: &str) -> Result<&'a str, String> {
1349        value.filter(|v| !v.is_empty()).ok_or_else(|| error.to_owned())
1350    }
1351
1352    /// Resolve `bridge_backend` + `bridge_endpoint` into the typed
1353    /// [`BridgeBackend`]. This is the single site owning the legal-value
1354    /// vocabulary and the required-companion rule for the bridge
1355    /// selector; `validate()` and the daemon's bridge factory both
1356    /// delegate here, so "kafka without an endpoint" cannot survive
1357    /// parsing.
1358    pub fn bridge(&self) -> Result<BridgeBackend, String> {
1359        const ENDPOINT_REQUIRED: &str = "bridge_endpoint is required for kafka and nats backends";
1360        match self.bridge_backend.as_str() {
1361            "embedded" => Ok(BridgeBackend::Embedded),
1362            "kafka" => Ok(BridgeBackend::Kafka {
1363                endpoint: Self::required_companion(self.bridge_endpoint.as_deref(), ENDPOINT_REQUIRED)?
1364                    .to_owned(),
1365            }),
1366            "nats" => Ok(BridgeBackend::Nats {
1367                endpoint: Self::required_companion(self.bridge_endpoint.as_deref(), ENDPOINT_REQUIRED)?
1368                    .to_owned(),
1369            }),
1370            other => Err(format!(
1371                "bridge_backend must be embedded|kafka|nats, got {other:?}"
1372            )),
1373        }
1374    }
1375
1376    /// Resolve `state_backend` + `state_endpoint` into the typed
1377    /// [`StateBackend`] (see [`Self::bridge`] for the single-parse-site
1378    /// rationale).
1379    pub fn state(&self) -> Result<StateBackend, String> {
1380        match self.state_backend.as_str() {
1381            "memory" => Ok(StateBackend::Memory),
1382            "redis" => Ok(StateBackend::Redis {
1383                endpoint: Self::required_companion(
1384                    self.state_endpoint.as_deref(),
1385                    "state_endpoint is required for the redis backend",
1386                )?
1387                .to_owned(),
1388            }),
1389            other => Err(format!("state_backend must be memory|redis, got {other:?}")),
1390        }
1391    }
1392
1393    /// Resolve `embedder_backend` + the ONNX companion paths into the
1394    /// typed [`EmbedderBackend`] (see [`Self::bridge`]).
1395    pub fn embedder(&self) -> Result<EmbedderBackend, String> {
1396        const PATHS_REQUIRED: &str =
1397            "onnx_model_path and onnx_tokenizer_path are required for the onnx backend";
1398        match self.embedder_backend.as_str() {
1399            "hash" => Ok(EmbedderBackend::Hash),
1400            "onnx" => Ok(EmbedderBackend::Onnx {
1401                model_path: Self::required_companion(self.onnx_model_path.as_deref(), PATHS_REQUIRED)?
1402                    .to_owned(),
1403                tokenizer_path: Self::required_companion(
1404                    self.onnx_tokenizer_path.as_deref(),
1405                    PATHS_REQUIRED,
1406                )?
1407                .to_owned(),
1408            }),
1409            other => Err(format!("embedder_backend must be hash|onnx, got {other:?}")),
1410        }
1411    }
1412
1413    /// Resolve `vector_backend` + `qdrant_url` into the typed
1414    /// [`VectorBackend`] (see [`Self::bridge`]).
1415    pub fn vector(&self) -> Result<VectorBackend, String> {
1416        match self.vector_backend.as_str() {
1417            "memory" => Ok(VectorBackend::Memory),
1418            "qdrant" => Ok(VectorBackend::Qdrant {
1419                url: Self::required_companion(
1420                    self.qdrant_url.as_deref(),
1421                    "qdrant_url is required for the qdrant vector backend",
1422                )?
1423                .to_owned(),
1424            }),
1425            other => Err(format!("vector_backend must be memory|qdrant, got {other:?}")),
1426        }
1427    }
1428
1429    /// Resolve all four backend selectors at once. Errors accumulate —
1430    /// one entry per selector that fails to resolve — matching
1431    /// `validate()`'s report-everything behaviour.
1432    pub fn backends(&self) -> Result<ResolvedBackends, Vec<String>> {
1433        let mut errors = Vec::new();
1434        let bridge = self.bridge().map_err(|error| errors.push(error)).ok();
1435        let state = self.state().map_err(|error| errors.push(error)).ok();
1436        let embedder = self.embedder().map_err(|error| errors.push(error)).ok();
1437        let vector = self.vector().map_err(|error| errors.push(error)).ok();
1438        match (bridge, state, embedder, vector) {
1439            (Some(bridge), Some(state), Some(embedder), Some(vector)) => Ok(ResolvedBackends {
1440                bridge,
1441                state,
1442                embedder,
1443                vector,
1444            }),
1445            _ => Err(errors),
1446        }
1447    }
1448
1449    /// Backends this configuration selects that the *current build*
1450    /// cannot run because the required cargo feature was compiled out
1451    /// (`avctl config-validate` once reported "valid" for
1452    /// `bridge_backend = "kafka"` on a default-features binary; the
1453    /// daemon then hard-failed at boot — neither pre-flight tool knew
1454    /// what the binary could actually run). Returns one message per
1455    /// unsatisfiable selection; empty means every configured backend
1456    /// is compiled in. `validate()` deliberately does NOT fold this
1457    /// in: shape validity and build capability are different
1458    /// questions (a config can be valid for a `--features full`
1459    /// daemon while the avctl doing the pre-flight was built lean).
1460    /// Selections that fail to resolve into their typed backend are
1461    /// skipped here — `validate()` already refuses them, and every
1462    /// caller (daemon boot, doctor, config-validate) validates first.
1463    #[must_use]
1464    pub fn unsupported_backend_requirements(&self) -> Vec<String> {
1465        let mut missing = Vec::new();
1466        let mut require = |enabled: bool, field: &str, value: &str, feature: &str| {
1467            if !enabled {
1468                missing.push(format!(
1469                    "{field} = {value:?} requires the `{feature}` cargo feature, \
1470                     which this build was compiled without (rebuild with \
1471                     --features {feature} or full)"
1472                ));
1473            }
1474        };
1475        match self.bridge() {
1476            Ok(BridgeBackend::Kafka { .. }) => {
1477                require(cfg!(feature = "kafka"), "bridge_backend", "kafka", "kafka");
1478            }
1479            Ok(BridgeBackend::Nats { .. }) => {
1480                require(cfg!(feature = "nats"), "bridge_backend", "nats", "nats");
1481            }
1482            Ok(BridgeBackend::Embedded) | Err(_) => {}
1483        }
1484        if matches!(self.state(), Ok(StateBackend::Redis { .. })) {
1485            require(cfg!(feature = "redis"), "state_backend", "redis", "redis");
1486        }
1487        if matches!(self.embedder(), Ok(EmbedderBackend::Onnx { .. })) {
1488            require(cfg!(feature = "onnx"), "embedder_backend", "onnx", "onnx");
1489        }
1490        if matches!(self.vector(), Ok(VectorBackend::Qdrant { .. })) {
1491            require(cfg!(feature = "qdrant"), "vector_backend", "qdrant", "qdrant");
1492        }
1493        if self.otel_tenant_endpoint.is_some() {
1494            require(cfg!(feature = "otel"), "otel_tenant_endpoint", "<set>", "otel");
1495        }
1496        missing
1497    }
1498
1499    /// Structural validation. Returns the single violation verbatim
1500    /// (historical error shape) or, for multiple violations, a
1501    /// numbered aggregate naming all of them.
1502    pub fn validate(&self) -> Result<(), String> {
1503        if self.config_version != CONFIG_VERSION {
1504            // Short-circuit: a config written for a different format
1505            // version makes every other check meaningless noise.
1506            return Err(format!(
1507                "unsupported config_version {} (this build supports {CONFIG_VERSION})",
1508                self.config_version
1509            ));
1510        }
1511        let mut errors = Vec::new();
1512        self.collect_validation_errors(&mut errors);
1513        if errors.len() > 1 {
1514            return Err(format!(
1515                "{} config errors:\n  - {}",
1516                errors.len(),
1517                errors.join("\n  - ")
1518            ));
1519        }
1520        match errors.pop() {
1521            None => Ok(()),
1522            Some(error) => Err(error),
1523        }
1524    }
1525
1526    /// Structural validation, one push per violation:
1527    /// every check reports independently so a single `avctl
1528    /// config-validate` run surfaces the complete list — previously
1529    /// only the first of 60+ checks was reported per round-trip.
1530    /// Checks after a failed one still run; they operate on owned
1531    /// string/number fields, so a prior violation can at worst add a
1532    /// redundant message, never a panic.
1533    fn collect_validation_errors(&self, errors: &mut Vec<String>) {
1534        if self.listen.is_empty() {
1535            errors.push("listen is required (host:port, e.g. 127.0.0.1:8484)".into());
1536        }
1537        // Shape-only check: hostnames are resolved at bind time, but a missing
1538        // or non-numeric port would otherwise pass validation and only fail at
1539        // server startup, defeating pre-flight `avctl config-validate`/doctor.
1540        match self.listen.rsplit_once(':') {
1541            Some((host, port)) if !host.is_empty() && port.parse::<u16>().is_ok() => {}
1542            _ => {
1543                errors.push(format!(
1544                    "listen {:?} must be host:port with a port in 0-65535",
1545                    self.listen
1546                ));
1547            }
1548        }
1549        // Refuse an all-interfaces bind with no authentication. `0.0.0.0` /
1550        // `[::]` / `*` on a machine reachable by anyone but the operator
1551        // gives every peer a fully-authenticated proxy against the operator's
1552        // provider key, plus the unauthenticated dashboard when it is on.
1553        // The escape hatch is explicit: set `require_identity = true` (and
1554        // configure a JWKS/HMAC source), pin `listen` to a loopback address,
1555        // or set `allow_wildcard_bind = true` — the last is for container /
1556        // service-mesh deployments where an outer network layer already
1557        // controls who reaches the listener.
1558        if !self.require_identity && !self.allow_wildcard_bind {
1559            let host = self
1560                .listen
1561                .rsplit_once(':')
1562                .map(|(h, _)| h.trim_start_matches('[').trim_end_matches(']'))
1563                .unwrap_or("");
1564            // Parse-and-classify instead of literal string matching: the
1565            // unspecified address has many spellings (`::0`,
1566            // `0:0:0:0:0:0:0:0`, `::ffff:0.0.0.0`, …) and a literal list
1567            // silently waves the non-canonical ones through — the exact
1568            // all-interfaces exposure this guard exists to refuse.
1569            // `to_canonical()` first: the v4-mapped form `::ffff:0.0.0.0`
1570            // parses as an Ipv6Addr whose own `is_unspecified()` is false,
1571            // yet binding it produces the same any-interface listener as
1572            // `::` (kernels treat the mapped-zero as INADDR_ANY).
1573            let is_wildcard = matches!(host, "*" | "")
1574                || host
1575                    .parse::<std::net::IpAddr>()
1576                    .is_ok_and(|ip| ip.to_canonical().is_unspecified());
1577            if is_wildcard {
1578                errors.push(format!(
1579                    "listen {:?} binds every interface while require_identity = false: any \
1580                     peer reaching this host would speak to the proxy anonymously with the \
1581                     operator's provider credentials. Either pin listen to a loopback / \
1582                     private-network address, set require_identity = true with an \
1583                     identity_jwks_url / identity_hmac_secret_file, or set \
1584                     allow_wildcard_bind = true if an outer network layer (container port \
1585                     map, service mesh, ingress ACL) controls who reaches the listener.",
1586                    self.listen
1587                ));
1588            }
1589        }
1590        // The dashboard shares the public listener and carries NO
1591        // identity gate of its own — `require_identity = true` protects
1592        // the proxy API but the dashboard routes still hand any peer
1593        // the session list, agent identities, and complete receipts.
1594        // On a wildcard bind that is a tenant-data disclosure, not a
1595        // dev convenience; require the same explicit
1596        // `allow_wildcard_bind` acknowledgment the anonymous-proxy
1597        // guard above uses (an outer network layer controlling access
1598        // covers both concerns), or turn the dashboard off.
1599        if self.dashboard_enabled && self.require_identity && !self.allow_wildcard_bind {
1600            let host = self
1601                .listen
1602                .rsplit_once(':')
1603                .map(|(h, _)| h.trim_start_matches('[').trim_end_matches(']'))
1604                .unwrap_or("");
1605            let is_wildcard = matches!(host, "*" | "")
1606                || host
1607                    .parse::<std::net::IpAddr>()
1608                    .is_ok_and(|ip| ip.to_canonical().is_unspecified());
1609            if is_wildcard {
1610                errors.push(format!(
1611                    "listen {:?} binds every interface with dashboard_enabled = true: the \
1612                     dashboard has no identity gate (require_identity covers only the proxy \
1613                     API), so any peer can enumerate sessions and download receipts. Set \
1614                     dashboard_enabled = false, pin listen, or set allow_wildcard_bind = \
1615                     true if an outer network layer controls who reaches the listener.",
1616                    self.listen
1617                ));
1618            }
1619        }
1620        if self.reconcile_tick_s == 0 {
1621            errors.push("reconcile_tick_s must be greater than zero".into());
1622        }
1623        if self.session_idle_close_s == 0 {
1624            errors.push(
1625                "session_idle_close_s must be greater than zero (0 would close every open session at each reconcile tick)"
1626                    .into(),
1627            );
1628        }
1629        // `atif_retention_days = Some(0)` would deploy a daemon whose
1630        // hourly sweep computes `max_age = Duration::ZERO`; the sweep
1631        // predicate is `age < max_age`, which is false for every sealed
1632        // pair, so every sealed `<stem>.json` + `.atif-auth` +
1633        // `.close-complete` triple is deleted on the first tick — total
1634        // loss of the pre-signing evidence chain. `None` (the ship
1635        // default) disables retention; the CLI `avctl spool-prune
1636        // --retention-days 0` path is a separate, one-off decommission
1637        // command and is intentionally not gated here.
1638        if self.atif_retention_days == Some(0) {
1639            errors.push(
1640                "atif_retention_days = 0 would delete every sealed ATIF pair on the first hourly sweep; \
1641                 omit the field to disable retention, or set >= 1 to enable an N-day window"
1642                    .into(),
1643            );
1644        }
1645        // R69 F3 (landed R70): `BudgetSpec` numeric caps of `Some(0)`
1646        // are silently accepted by the ledger — `try_spend` refuses
1647        // every `amount > 0` against a `limit == 0`, so
1648        // `budget.max_tokens = 0` under the default `require_identity
1649        // = false` shape 429-storms every non-empty chat request; the
1650        // operator symptom is "every request 429s" and the exact
1651        // diagnostic hunt `avctl config-validate` is meant to
1652        // short-circuit. `max_tool_calls["db_write"] = 0` silently
1653        // disables that tool with no config warning. The unset-vs-
1654        // zero divergence for `max_payout_usd_micros` produces two
1655        // subtly different `BudgetDecision::Refused` messages with
1656        // identical policy semantics — surface zero at config time.
1657        //
1658        // The campaign added the same `== 0` fail-loud check for
1659        // every other numeric interval / capacity field
1660        // (`identity_jwks_refresh_s`, `worker_channel_capacity`,
1661        // `reconcile_tick_s`, `session_idle_close_s`,
1662        // `upstream_read_timeout_s`, `shutdown_drain_timeout_s`,
1663        // `atif_retention_days == Some(0)` above, `max_request_bytes`,
1664        // `onnx_dimension`, `breaker.window`). BudgetSpec was the
1665        // odd one out.
1666        Self::validate_budget_spec(&self.budget, "budget", errors);
1667        if let Some(pb) = &self.principal_budget {
1668            Self::validate_budget_spec(pb, "principal_budget", errors);
1669        }
1670        if self.upstream_url.is_empty() {
1671            errors.push("upstream_url is required".into());
1672        }
1673        if !self.upstream_chat_path.starts_with('/') {
1674            errors.push(format!(
1675                "upstream_chat_path must start with '/', got {:?}",
1676                self.upstream_chat_path
1677            ));
1678        }
1679        if self.upstream_api_key_env.as_deref().is_some_and(str::is_empty) {
1680            errors.push("upstream_api_key_env must not be empty when set".into());
1681        }
1682        if self.upstream_api_key_file.as_deref().is_some_and(str::is_empty) {
1683            errors.push("upstream_api_key_file must not be empty when set".into());
1684        }
1685        if self.upstream_api_key_env.is_some() && self.upstream_api_key_file.is_some() {
1686            errors.push(
1687                "set only one of upstream_api_key_env or upstream_api_key_file (ambiguous key source)".into(),
1688            );
1689        }
1690        let has_static_key = self.upstream_api_key_env.is_some() || self.upstream_api_key_file.is_some();
1691        if self.upstream_authorization_passthrough && has_static_key {
1692            errors.push(
1693                "upstream_authorization_passthrough conflicts with upstream_api_key_env/file: choose one auth mode"
1694                    .into(),
1695            );
1696        }
1697        if self.upstream_authorization_passthrough && self.require_identity {
1698            errors.push(
1699                "upstream_authorization_passthrough cannot be combined with require_identity: the \
1700                 Authorization header carries the NHI token, which must never be sent upstream"
1701                    .into(),
1702            );
1703        }
1704        if self.ignore_client_authorization && self.require_identity {
1705            errors.push(
1706                "ignore_client_authorization cannot be combined with require_identity: the \
1707                 identity validator must see the Authorization header, not discard it"
1708                    .into(),
1709            );
1710        }
1711        if self.ignore_client_authorization && self.upstream_authorization_passthrough {
1712            errors.push(
1713                "ignore_client_authorization conflicts with upstream_authorization_passthrough: \
1714                 a client Authorization header cannot be both discarded and forwarded upstream"
1715                    .into(),
1716            );
1717        }
1718        if axum::http::HeaderName::try_from(self.upstream_auth_header.as_str()).is_err() {
1719            errors.push(format!(
1720                "upstream_auth_header {:?} is not a valid HTTP header name",
1721                self.upstream_auth_header
1722            ));
1723        }
1724        for entry in &self.allowed_hosts {
1725            // The Host check strips the request's :port before comparing,
1726            // so an entry carrying a port (`"localhost:8484"`), empty, or
1727            // padded with whitespace can NEVER match — every request 403s
1728            // and the operator learns it in production, not at boot.
1729            // (IPv6 literals are fine: the comparison strips brackets on
1730            // both sides; a port after the bracket is still refused.)
1731            let unbracketed = entry.trim_start_matches('[').trim_end_matches(']');
1732            let has_port = entry.rsplit_once(':').is_some_and(|(head, port)| {
1733                !head.is_empty()
1734                    && !port.is_empty()
1735                    && port.chars().all(|c| c.is_ascii_digit())
1736                    && !entry.ends_with(']')
1737            });
1738            if entry.trim().is_empty() {
1739                errors.push("allowed_hosts contains an empty entry".into());
1740            } else if entry != entry.trim() {
1741                errors.push(format!(
1742                    "allowed_hosts entry {entry:?} has leading/trailing whitespace and would never match a Host header"
1743                ));
1744            } else if has_port && unbracketed.parse::<std::net::Ipv6Addr>().is_err() {
1745                errors.push(format!(
1746                    "allowed_hosts entry {entry:?} includes a port; the Host check compares \
1747                     hostnames with the port stripped, so this entry would never match — drop the port"
1748                ));
1749            }
1750        }
1751        if self
1752            .upstream_auth_scheme
1753            .bytes()
1754            .any(|byte| !(0x21..=0x7e).contains(&byte))
1755        {
1756            errors.push(
1757                "upstream_auth_scheme must contain only visible ASCII with no spaces (use \"\" for a raw key)"
1758                    .into(),
1759            );
1760        }
1761        if self.tool_upstream_bearer_env.is_some() && self.tool_upstream_bearer_file.is_some() {
1762            errors.push(
1763                "set only one of tool_upstream_bearer_env or tool_upstream_bearer_file (ambiguous token source)"
1764                    .into(),
1765            );
1766        }
1767        // Parity with the
1768        // `upstream_api_key_env`/`_file` non-empty checks above.
1769        // The prior code refused ambiguity (both set) and refused
1770        // dangling (bearer without tool URL), but a caller supplying
1771        // an explicit empty string would fail only at first tool
1772        // call — at which point `resolve_upstream_bearer` returns
1773        // the same "environment variable {name} is not set (or
1774        // empty)" error the harness surfaces for a missing key.
1775        // Catch it here so the actionable message reaches the
1776        // operator at config load.
1777        if self
1778            .tool_upstream_bearer_env
1779            .as_deref()
1780            .is_some_and(str::is_empty)
1781        {
1782            errors.push("tool_upstream_bearer_env must not be empty when set".into());
1783        }
1784        if self
1785            .tool_upstream_bearer_file
1786            .as_deref()
1787            .is_some_and(str::is_empty)
1788        {
1789            errors.push("tool_upstream_bearer_file must not be empty when set".into());
1790        }
1791        if (self.tool_upstream_bearer_env.is_some() || self.tool_upstream_bearer_file.is_some())
1792            && self.tool_upstream_url.as_deref().is_none_or(str::is_empty)
1793        {
1794            errors.push("tool_upstream_bearer_env/file requires tool_upstream_url to be set".into());
1795        }
1796        if crate::session::Workflow::parse(&self.default_workflow).is_none() {
1797            errors.push(format!(
1798                "default_workflow must be signed|unsigned, got {:?}",
1799                self.default_workflow
1800            ));
1801        }
1802        // Refuse a principal-scoped budget while identity is optional. Without
1803        // require_identity=true, every unauthenticated request folds into a
1804        // single `"anonymous"` principal and the principal ledger becomes a
1805        // fleet-shared bucket that any caller can drain — the opposite of the
1806        // anti-rotation property the section is meant to provide. Operators
1807        // running a single-tenant appliance can flip
1808        // `allow_anonymous_principal_budget = true` to acknowledge the shape.
1809        if self.principal_budget.is_some() && !self.require_identity && !self.allow_anonymous_principal_budget
1810        {
1811            errors.push(
1812                "principal_budget was set but require_identity = false: every unauthenticated \
1813                 request would share a single `anonymous` principal ledger, making the budget \
1814                 a fleet-wide bucket instead of the per-principal cap it appears to be. Either \
1815                 set require_identity = true, or set allow_anonymous_principal_budget = true to \
1816                 acknowledge single-tenant / trusted-network semantics."
1817                    .into(),
1818            );
1819        }
1820        if self.require_identity
1821            && self.identity_jwks_url.as_deref().is_none_or(str::is_empty)
1822            && self
1823                .identity_hmac_secret_file
1824                .as_deref()
1825                .is_none_or(str::is_empty)
1826        {
1827            errors.push("require_identity=true needs identity_jwks_url or identity_hmac_secret_file".into());
1828        }
1829        // Reject the silent-anonymous-bypass posture.
1830        // `enforce_identity_scopes = true` looks like it's guarding
1831        // routes with `chat_scope` / `session_close_scope` / `tool:*`
1832        // — but the scope check lives INSIDE the `(Some bearer, Some
1833        // validator)` arm of `Pipeline::resolve_identity`. When
1834        // `require_identity = false` (the shipped default), a request
1835        // with no `Authorization` header short-circuits to the
1836        // anonymous fallback and never sees the scope gate. The
1837        // operator reads `enforce_identity_scopes = true` in
1838        // `agentvisor.toml` and reasonably concludes "you need the
1839        // chat scope to reach /v1/chat/completions" — in fact curl
1840        // with no header proceeds as `anonymous`, producing the
1841        // exact repudiation vector the Bearer-case fix
1842        // documented. Refuse the combo so operators either turn
1843        // enforcement off (making the posture explicit) or turn
1844        // identity on.
1845        if self.enforce_identity_scopes && !self.require_identity {
1846            errors.push(
1847                "enforce_identity_scopes=true has no effect while require_identity=false: \
1848                 unauthenticated requests fall through to the anonymous identity and bypass \
1849                 the scope gate entirely. Either set require_identity=true, or set \
1850                 enforce_identity_scopes=false to make the posture explicit."
1851                    .into(),
1852            );
1853        }
1854        if self.identity_jwks_refresh_s == 0 {
1855            errors.push("identity_jwks_refresh_s must be greater than zero".into());
1856        }
1857        // `tokio::time::interval(Duration::from_secs(0))` panics. Guard
1858        // against a value that squeaks past the > 0 check via overflow
1859        // arithmetic elsewhere by requiring a minimum plausible cadence
1860        // — a JWKS refresh below 30 s hammers the IdP and offers no
1861        // real benefit at NHI TTLs measured in minutes.
1862        if self.identity_jwks_refresh_s < 30 {
1863            errors.push(format!(
1864                "identity_jwks_refresh_s {} is too aggressive; a value below 30 s hammers the IdP \
1865                 without benefit given NHI TTLs measured in minutes",
1866                self.identity_jwks_refresh_s
1867            ));
1868        }
1869        if self.identity_hmac_kid.is_empty() {
1870            errors.push("identity_hmac_kid must not be empty".into());
1871        }
1872        // Scope names must be visible-ASCII non-empty
1873        // tokens. An empty `chat_scope = ""` under
1874        // `enforce_identity_scopes = true` + `require_identity =
1875        // true` used to silently reduce the check to
1876        // `identity.scopes.contains("")` — some IdPs emit empty
1877        // scope entries after tokenizing a stray whitespace claim,
1878        // so tokens without any real scope would satisfy the gate.
1879        // Also reject whitespace/control bytes (OAuth scope
1880        // tokens must not be re-tokenizable at any layer).
1881        for (field, value) in [
1882            ("chat_scope", &self.chat_scope),
1883            ("session_close_scope", &self.session_close_scope),
1884            ("session_promote_scope", &self.session_promote_scope),
1885        ] {
1886            if value.is_empty() {
1887                errors.push(format!("{field} must not be empty"));
1888            }
1889            if value.bytes().any(|byte| !(0x21..=0x7e).contains(&byte)) {
1890                errors.push(format!(
1891                    "{field} {value:?} must be visible ASCII with no whitespace or control bytes"
1892                ));
1893            }
1894        }
1895        if self.worker_channel_capacity == 0 {
1896            errors.push("worker_channel_capacity must be > 0".into());
1897        }
1898        // R70 F1 (landed R71): reject `mcp_concurrency == 0` (would
1899        // refuse every MCP call at admission) and values past
1900        // `MAX_MCP_CONCURRENCY` (would silently commit worst-case
1901        // 64 GiB+ of MCP buffering to the runtime).
1902        if self.mcp_concurrency == 0 {
1903            errors.push("mcp_concurrency must be > 0 (0 refuses every MCP call at admission)".into());
1904        }
1905        if self.mcp_concurrency > MAX_MCP_CONCURRENCY {
1906            errors.push(format!(
1907                "mcp_concurrency {} exceeds the safety cap of {} — each admitted MCP call \
1908                 can buffer up to 16 MiB, so oversized values silently commit multi-GB of \
1909                 resident memory to a slow-upstream attack surface",
1910                self.mcp_concurrency, MAX_MCP_CONCURRENCY
1911            ));
1912        }
1913        if crate::provider::adapter_for(&self.provider).is_none() {
1914            errors.push(format!(
1915                "provider must be one of {:?}, got {:?} (Anthropic/Gemini adapters are planned)",
1916                crate::provider::SUPPORTED_PROVIDERS,
1917                self.provider
1918            ));
1919        }
1920        if self.bridge_manifest_path.is_empty() {
1921            errors.push("bridge_manifest_path is required".into());
1922        }
1923        // Refuse empty local-fs path fields. If an
1924        // operator overrides `atif_spool_dir = ""` in TOML (e.g. by
1925        // accidentally interpolating an unset env variable through a
1926        // template), every ATIF spool op used to run on Path::new("")
1927        // whose joins degrade to CWD-relative writes — receipts land
1928        // in the process CWD instead of the expected volume, recovery
1929        // scans miss them on the next boot. Same shape for
1930        // `bridge_data_dir` (embedded broker segments in CWD).
1931        if self.atif_spool_dir.is_empty() {
1932            errors.push("atif_spool_dir must not be empty".into());
1933        }
1934        if self.bridge_data_dir.is_empty() {
1935            errors.push("bridge_data_dir must not be empty".into());
1936        }
1937        // Refuse any payout_field that isn't visible ASCII. This closes:
1938        //   - empty and whitespace-only cases
1939        //   - leading/trailing whitespace
1940        //   - control characters
1941        //   - non-ASCII (accents, smart quotes)
1942        //   - AND the invisible-format class (BOM `\u{FEFF}`, zero-width
1943        //     space `\u{200B}`, U+2060 word-joiner, ...) that Rust's
1944        //     `trim()` misses because Unicode `White_Space` does not
1945        //     include them.
1946        // The invisible-format class is precisely what a browser or
1947        // rich-editor paste can inject into a config value, and
1948        // precisely the class where `serde_json::Value::get(field)`
1949        // silently misses the real JSON key — the same silent-bypass
1950        // class this field exists to prevent (see the field-level
1951        // doc-comment). Real tool schemas' payout field names are
1952        // always plain identifiers, so operator UX cost is zero.
1953        // `is_ascii_graphic()` is the visible-ASCII predicate
1954        // (0x21..=0x7E) shared with `SessionId::parse` and
1955        // `InstanceUid::parse`.
1956        if self.payout_field.is_empty() || !self.payout_field.chars().all(|c| c.is_ascii_graphic()) {
1957            errors.push(
1958                "payout_field must be non-empty visible ASCII (letters, digits, and \
1959                 common punctuation only). An empty, whitespace, or invisible-format \
1960                 (BOM / zero-width) value would match no argument key, silently disabling \
1961                 max_payout_usd_micros — the very silent-bypass class this field exists \
1962                 to prevent. Omit the key to use the default, or set it to your tool \
1963                 schema's payout argument name."
1964                    .into(),
1965            );
1966        }
1967        // Backend selectors: the typed accessors (`bridge()`, `state()`,
1968        // `embedder()`, `vector()`) are the single site owning the
1969        // legal-value vocabulary and the required-companion rules —
1970        // previously the four String
1971        // selectors were enumerated twice. The scheme
1972        // allowlists below run against the *resolved* companion, so
1973        // they can never fire on a missing value. The TOML wire
1974        // format is unchanged: shipped flat-key configs keep parsing.
1975        match self.bridge() {
1976            Err(error) => errors.push(error),
1977            Ok(BridgeBackend::Nats { endpoint }) => {
1978                if !(endpoint.starts_with("nats://") || endpoint.starts_with("tls://")) {
1979                    errors.push(format!(
1980                        "bridge_endpoint (nats backend) must be nats:// or tls://, got {endpoint:?}"
1981                    ));
1982                }
1983            }
1984            // Kafka bridge_endpoint is a `host:port[,host:port]` bootstrap
1985            // list, not a URL — no scheme check applies. rdkafka rejects
1986            // malformed values on connect.
1987            Ok(BridgeBackend::Embedded | BridgeBackend::Kafka { .. }) => {}
1988        }
1989        match self.state() {
1990            Err(error) => errors.push(error),
1991            Ok(StateBackend::Redis { endpoint }) => {
1992                // The state_endpoint
1993                // field is docstring-documented as a comma-separated
1994                // list of URLs for Redis Cluster mode (see the field
1995                // doc above). The scheme allowlist used to be a
1996                // prefix check on the WHOLE string, so
1997                // `redis://a:6379,http://b` passed validate (the
1998                // check saw the `redis://` prefix) and only failed
1999                // at connect. `redis+unix:` was also rejected here
2000                // even though it's a legitimate Unix-socket form the
2001                // redis crate accepts and the doctor's
2002                // `probe_endpoint_any` already recognizes. Split on
2003                // ',' and validate each member independently.
2004                let mut previous_tls = None;
2005                for (index, member) in endpoint.split(',').map(str::trim).enumerate() {
2006                    let ok = member.starts_with("redis://")
2007                        || member.starts_with("rediss://")
2008                        || member.starts_with("unix:")
2009                        || member.starts_with("redis+unix:");
2010                    if !ok {
2011                        errors.push(format!(
2012                            "state_endpoint (redis backend) member {} must be \
2013                             redis://, rediss://, unix:, or redis+unix:",
2014                            index + 1
2015                        ));
2016                    }
2017                    let tls = member.starts_with("rediss://");
2018                    if tls
2019                        && member
2020                            .split_once('#')
2021                            .is_some_and(|(_, fragment)| fragment == "insecure")
2022                    {
2023                        errors
2024                            .push("state_endpoint cannot disable Redis TLS certificate verification".into());
2025                    }
2026                    if previous_tls.is_some_and(|previous| previous != tls) {
2027                        errors.push("state_endpoint cluster members must all use the same transport".into());
2028                    }
2029                    previous_tls = Some(tls);
2030                }
2031            }
2032            Ok(StateBackend::Memory) => {}
2033        }
2034        if let Err(error) = self.embedder() {
2035            errors.push(error);
2036        }
2037        match self.vector() {
2038            Err(error) => errors.push(error),
2039            Ok(VectorBackend::Qdrant { url }) => {
2040                if !(url.starts_with("http://") || url.starts_with("https://")) {
2041                    errors.push(format!("qdrant_url must be http:// or https://, got {url:?}"));
2042                }
2043            }
2044            Ok(VectorBackend::Memory) => {}
2045        }
2046        if self.onnx_dimension == 0 {
2047            errors.push("onnx_dimension must be greater than zero".into());
2048        }
2049        if self.qdrant_collection.is_empty() {
2050            errors.push("qdrant_collection must not be empty".into());
2051        }
2052        if self.breaker.window == 0 {
2053            errors.push(
2054                "breaker.window must be greater than zero (0 trips on token count alone, ignoring semantic similarity)"
2055                    .into(),
2056            );
2057        }
2058        if !self.breaker.delta_epsilon.is_finite() || self.breaker.delta_epsilon <= 0.0 {
2059            errors.push(format!(
2060                "breaker.delta_epsilon must be a finite number greater than zero, got {}",
2061                self.breaker.delta_epsilon
2062            ));
2063        }
2064        // Upper bounds so a fat-finger in TOML cannot OOM the process
2065        // before the runtime feels the misconfiguration. Values are
2066        // deliberately loose: they only reject genuinely absurd numbers.
2067        if self.worker_channel_capacity > MAX_WORKER_CHANNEL_CAPACITY {
2068            errors.push(format!(
2069                "worker_channel_capacity {} exceeds the safety cap of {} — oversized channels hide real backpressure and let per-shard buffers grow unboundedly under overload",
2070                self.worker_channel_capacity, MAX_WORKER_CHANNEL_CAPACITY
2071            ));
2072        }
2073        if self.max_request_bytes == 0 {
2074            errors.push(
2075                "max_request_bytes must be > 0 — a 0 cap forwards to DefaultBodyLimit::max(0), \
2076                 rejecting every non-empty POST body silently"
2077                    .into(),
2078            );
2079        }
2080        if self.max_request_bytes > MAX_REQUEST_BYTES_CAP {
2081            errors.push(format!(
2082                "max_request_bytes {} exceeds the safety cap of {} (512 MiB) — a single request should never legitimately need more, and lifting this defeats the sandbox payload guard",
2083                self.max_request_bytes, MAX_REQUEST_BYTES_CAP
2084            ));
2085        }
2086        if self.onnx_dimension > MAX_ONNX_DIMENSION {
2087            errors.push(format!(
2088                "onnx_dimension {} exceeds the safety cap of {} — most sentence-transformer models are <= 4096",
2089                self.onnx_dimension, MAX_ONNX_DIMENSION
2090            ));
2091        }
2092        // 1 day is already unreasonably long for either an idle window
2093        // or a JWKS refresh cadence; anything larger is almost certainly
2094        // a unit-conversion error (someone thought the field was in ms).
2095        let mut interval_fields: Vec<(&'static str, u64)> = vec![
2096            ("reconcile_tick_s", self.reconcile_tick_s),
2097            ("session_idle_close_s", self.session_idle_close_s),
2098            ("identity_jwks_refresh_s", self.identity_jwks_refresh_s),
2099        ];
2100        if let Some(read_timeout) = self.upstream_read_timeout_s {
2101            // 0 passes the cap check below but makes reqwest's
2102            // read_timeout fire immediately — every upstream request
2103            // fails before the first byte arrives.
2104            if read_timeout == 0 {
2105                errors.push(
2106                    "upstream_read_timeout_s = 0 would time out every upstream read immediately — omit the key to use the built-in 60 s default".into(),
2107                );
2108            }
2109            interval_fields.push(("upstream_read_timeout_s", read_timeout));
2110        }
2111        if let Some(timeout) = self.mcp_request_timeout_s {
2112            if timeout == 0 {
2113                errors.push(
2114                    "mcp_request_timeout_s = 0 would time out every outbound MCP request immediately — omit the key to use the built-in 60 s default".into(),
2115                );
2116            }
2117            interval_fields.push(("mcp_request_timeout_s", timeout));
2118        }
2119        if let Some(drain) = self.shutdown_drain_timeout_s {
2120            if drain == 0 {
2121                errors.push(
2122                    "shutdown_drain_timeout_s = 0 would abandon every in-flight request at shutdown — omit the key to derive it from the upstream read-idle and MCP request timeouts".into(),
2123                );
2124            }
2125            interval_fields.push(("shutdown_drain_timeout_s", drain));
2126        }
2127        if self.shutdown_ready_drain_s > 0 {
2128            interval_fields.push(("shutdown_ready_drain_s", self.shutdown_ready_drain_s));
2129        }
2130        for (field, value) in interval_fields {
2131            if value > MAX_SECONDS_INTERVAL {
2132                errors.push(format!(
2133                    "{field} = {value} exceeds the safety cap of {MAX_SECONDS_INTERVAL} seconds (1 day) — did you mean milliseconds?"
2134                ));
2135            }
2136        }
2137        // Shape-only URL check: reject `upstream_url` that lacks a
2138        // scheme, so an operator setting e.g. `upstream_url =
2139        // "openai.internal"` (missing `https://`) does not silently
2140        // concatenate into a broken url. Full url::Url::parse is
2141        // deferred to reqwest at request time.
2142        //
2143        // Tighten to a strict http/https allowlist so
2144        // this matches the posture applied to
2145        // `identity_jwks_url`, `qdrant_url`, etc. The old
2146        // `contains("://")` check accepted `file:///etc/passwd`,
2147        // `gopher://…`, and other schemes even though the error text
2148        // claimed "must be http:// or https://" — a config-injection
2149        // primitive or a templating typo (`${UPSTREAM:-file:///…}`)
2150        // used to pass `avctl config-validate` and only fail at
2151        // request time. Now every URL field's shape is preflighted
2152        // by the same rule.
2153        if !(self.upstream_url.starts_with("http://") || self.upstream_url.starts_with("https://")) {
2154            errors.push(format!(
2155                "upstream_url must be http:// or https://, got {:?}",
2156                self.upstream_url
2157            ));
2158        }
2159        // A scheme with no host (`http://`, `https:///path`) passed the
2160        // prefix check above, booted, and validated cleanly, failing
2161        // only at request time with a 502 — exactly the class this
2162        // preflight exists to catch at startup.
2163        {
2164            let rest = self
2165                .upstream_url
2166                .split_once("://")
2167                .map(|(_, rest)| rest)
2168                .unwrap_or_default();
2169            let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
2170            if host.is_empty() {
2171                errors.push(format!("upstream_url has no host, got {:?}", self.upstream_url));
2172            }
2173        }
2174        // The chat/tool paths are CONCATENATED onto this base
2175        // (`format!("{upstream_url}/v1/chat/completions")` in
2176        // pipeline.rs), so a base carrying a query or fragment
2177        // silently swallows them: `http://host/#x` + `/v1/...` puts
2178        // the entire path inside the fragment and the actual request
2179        // targets `/` — validation passed, boot succeeded, and every
2180        // completion quietly hit the wrong endpoint. Refuse at
2181        // startup like every other config-shape problem.
2182        if self.upstream_url.contains(['?', '#']) {
2183            errors.push(format!(
2184                "upstream_url must not carry a query or fragment (request paths are \
2185                 appended to it), got {:?}",
2186                self.upstream_url
2187            ));
2188        }
2189        if let Some(tool_upstream) = &self.tool_upstream_url {
2190            // Empty is rejected rather than treated as unset: routing gates
2191            // tool forwarding on `is_some()` (routes.rs), so an empty string
2192            // would silently enable the tool-upstream branches and only fail
2193            // at the first request with a reqwest URL error. Fail loudly at
2194            // startup like every other config-shape problem.
2195            if tool_upstream.is_empty() {
2196                errors.push(
2197                    "tool_upstream_url must not be empty; omit the field to disable tool forwarding"
2198                        .to_owned(),
2199                );
2200            }
2201            if !(tool_upstream.starts_with("http://") || tool_upstream.starts_with("https://")) {
2202                errors.push(format!(
2203                    "tool_upstream_url must be http:// or https://, got {tool_upstream:?}"
2204                ));
2205            }
2206            // Same hostless-URL preflight as `upstream_url` above: a
2207            // scheme with no host (`http://`, `https:///mcp`) passed
2208            // validation, booted, then failed every tool call at
2209            // request-build time — a fail-late shape that also leaves
2210            // claimed executions churning TOOL_OUTCOME_UNCERTAIN.
2211            let rest = tool_upstream
2212                .split_once("://")
2213                .map(|(_, rest)| rest)
2214                .unwrap_or_default();
2215            let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
2216            if !tool_upstream.is_empty() && host.is_empty() {
2217                errors.push(format!("tool_upstream_url has no host, got {tool_upstream:?}"));
2218            }
2219        }
2220        // Extend the scheme allowlist to every URL
2221        // field. `identity_jwks_url`, `qdrant_url`, `bridge_endpoint`
2222        // (when NATS), and `state_endpoint` (when Redis) all used to
2223        // be handed to their respective clients without preflight.
2224        // A typo like `identity_jwks_url = "auth.internal/jwks"`
2225        // (missing scheme) would pass `avctl config-validate` and
2226        // only fail at first fetch — after the process was already
2227        // serving traffic with an empty validator (every token ->
2228        // UnknownKid). A hostile config-injection
2229        // `identity_jwks_url = "file:///etc/passwd"` used to be
2230        // just as invisible. Reject shape early.
2231        if let Some(url) = self
2232            .identity_jwks_url
2233            .as_deref()
2234            .filter(|value| !value.is_empty())
2235        {
2236            if !(url.starts_with("http://") || url.starts_with("https://")) {
2237                errors.push(format!(
2238                    "identity_jwks_url must be http:// or https://, got {url:?}"
2239                ));
2240            }
2241        }
2242        // The qdrant_url / state_endpoint / bridge_endpoint scheme
2243        // allowlists live in the typed-backend block above, running
2244        // against the resolved companion values.
2245
2246        // ── Pillar 2: intent mapping / AuthZEN ──────────────────────
2247        if self.require_intent_mapping && self.intent_map.is_empty() {
2248            errors.push(
2249                "require_intent_mapping is true but intent_map is empty; every tool call \
2250                 would be denied with UNMAPPED_TOOL"
2251                    .into(),
2252            );
2253        }
2254        if self.intent_token_ttl_s == 0 {
2255            errors.push("intent_token_ttl_s must be > 0".into());
2256        }
2257        if self.intent_token_ttl_s > 900 {
2258            errors.push(
2259                "intent_token_ttl_s exceeds the 900 s ceiling; intent tokens must be short-lived".into(),
2260            );
2261        }
2262        let mut mission_ids = std::collections::HashSet::new();
2263        for mission in self.mission.iter().chain(&self.missions) {
2264            if !mission_ids.insert(mission.id.as_str()) {
2265                errors.push(format!("mission id {:?} is used more than once", mission.id));
2266            }
2267        }
2268        if self.mission.as_ref().is_some_and(MissionConfig::has_selectors) {
2269            errors.push(
2270                "[mission] applies to every caller and takes no agents/charters/subjects; \
2271                 put per-agent missions in [[missions]]"
2272                    .into(),
2273            );
2274        }
2275        for mission in &self.missions {
2276            if !mission.has_selectors() {
2277                errors.push(format!(
2278                    "[[missions]] entry {:?} has no agents, charters, or subjects; use [mission] \
2279                     for a mission that applies to every caller",
2280                    mission.id
2281                ));
2282            }
2283            if mission.allowed_intents.is_empty() {
2284                errors.push(format!(
2285                    "mission {:?} has no allowed_intents; every call would be denied",
2286                    mission.id
2287                ));
2288            }
2289        }
2290        if let Some(workload) = &self.workload_identity {
2291            if workload.ca_file.trim().is_empty() {
2292                errors.push("workload_identity.ca_file must not be empty".into());
2293            }
2294            if !(60..=900).contains(&workload.token_ttl_s) {
2295                errors.push("workload_identity.token_ttl_s must be between 60 and 900".into());
2296            }
2297            if self.token_exchange_seed_file.is_none() {
2298                errors.push(
2299                    "[workload_identity] requires token_exchange_seed_file: the gateway signs agent \
2300                     tokens with that key"
2301                        .into(),
2302                );
2303            }
2304            if self.workload_identities.is_empty() {
2305                errors
2306                    .push("[workload_identity] is set but no [[workload_identities]] are registered".into());
2307            }
2308        } else if !self.workload_identities.is_empty() {
2309            errors.push("[[workload_identities]] requires [workload_identity]".into());
2310        }
2311        // The same rule the identity validator applies at request time.
2312        let human = self
2313            .identity_human_subject_pattern
2314            .as_deref()
2315            .and_then(|pattern| {
2316                let mut validator = av_identity::IdentityValidator::new(&self.audience);
2317                validator
2318                    .set_human_subject_pattern(pattern)
2319                    .ok()
2320                    .map(|()| validator)
2321            });
2322        let mut workload_names = std::collections::HashSet::new();
2323        for workload in &self.workload_identities {
2324            if !workload_names.insert(workload.name.as_str()) {
2325                errors.push(format!(
2326                    "workload name {:?} is used more than once",
2327                    workload.name
2328                ));
2329            }
2330            if workload.name.is_empty()
2331                || !workload
2332                    .name
2333                    .bytes()
2334                    .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
2335            {
2336                errors.push(format!(
2337                    "workload name {:?} must be ASCII letters, digits, '-', '_', or '.'",
2338                    workload.name
2339                ));
2340            }
2341            if workload.cf_app_guid.is_none()
2342                && workload.cf_space_guid.is_none()
2343                && workload.cf_org_guid.is_none()
2344            {
2345                errors.push(format!(
2346                    "workload {:?} needs cf_app_guid, cf_space_guid, or cf_org_guid",
2347                    workload.name
2348                ));
2349            }
2350            if workload.sub.trim().is_empty() {
2351                errors.push(format!(
2352                    "workload {:?} needs `sub`: the human principal its agents act for",
2353                    workload.name
2354                ));
2355            } else if human
2356                .as_ref()
2357                .is_some_and(|validator| !validator.subject_is_human(&workload.sub))
2358            {
2359                errors.push(format!(
2360                    "workload {:?} sub {:?} does not match identity_human_subject_pattern",
2361                    workload.name, workload.sub
2362                ));
2363            }
2364            if workload.charter.trim().is_empty() || workload.scopes.is_empty() {
2365                errors.push(format!(
2366                    "workload {:?} needs a charter and at least one scope",
2367                    workload.name
2368                ));
2369            }
2370        }
2371        if let Some(authzen) = &self.authzen {
2372            for (field, endpoint) in
2373                std::iter::once(("authzen.evaluation_endpoint", Some(&authzen.evaluation_endpoint))).chain(
2374                    std::iter::once((
2375                        "authzen.evaluations_endpoint",
2376                        authzen.evaluations_endpoint.as_ref(),
2377                    )),
2378                )
2379            {
2380                if let Some(endpoint) = endpoint {
2381                    if let Err(error) = crate::backend::validate_http_endpoint(endpoint, field) {
2382                        errors.push(error);
2383                    }
2384                }
2385            }
2386            if !(1..=10_000).contains(&authzen.timeout_ms) {
2387                errors.push("authzen.timeout_ms must be between 1 and 10000".into());
2388            }
2389            if authzen
2390                .auth_file
2391                .as_deref()
2392                .is_some_and(|path| path.trim().is_empty())
2393            {
2394                errors.push("authzen.auth_file must not be empty".into());
2395            }
2396        }
2397        if let Some(ref mission) = self.mission {
2398            let now_s = av_core::time::now_ms() / 1000;
2399            if mission.expires_at <= now_s {
2400                tracing::warn!(
2401                    mission_id = %mission.id,
2402                    expires_at = mission.expires_at,
2403                    now = now_s,
2404                    "mission is already expired; the PDP will deny every call \
2405                     until the mission is removed or updated"
2406                );
2407            }
2408            if mission.allowed_intents.is_empty() {
2409                errors.push(format!(
2410                    "mission {:?} has no allowed_intents; every call would be denied",
2411                    mission.id
2412                ));
2413            }
2414        }
2415
2416        // ── Pillar 3: token exchange ────────────────────────────────
2417        if self
2418            .token_exchange_seed_file
2419            .as_ref()
2420            .is_some_and(|path| path.trim().is_empty())
2421        {
2422            errors.push(
2423                "token_exchange_seed_file must not be empty; omit it when token signing is disabled".into(),
2424            );
2425        }
2426        if self.token_exchange_enabled && self.token_exchange_seed_file.is_none() {
2427            errors.push(
2428                "token_exchange_enabled is true but token_exchange_seed_file is not set; \
2429                 the /v1/token endpoint has no key to sign exchanged tokens with"
2430                    .into(),
2431            );
2432        }
2433        let has_identity_source = self
2434            .identity_jwks_url
2435            .as_deref()
2436            .is_some_and(|url| !url.is_empty())
2437            || self
2438                .identity_hmac_secret_file
2439                .as_deref()
2440                .is_some_and(|path| !path.is_empty());
2441        if self.token_exchange_enabled && !has_identity_source {
2442            errors.push(
2443                "token_exchange_enabled is true but neither identity_jwks_url nor \
2444                 identity_hmac_secret_file is set; the /v1/token endpoint cannot validate \
2445                 subject tokens"
2446                    .into(),
2447            );
2448        }
2449        if self.token_exchange_enabled && self.backends.is_empty() {
2450            errors.push(
2451                "token_exchange_enabled is true but no [[backends]] are configured; the \
2452                 gateway signs exchanged tokens only for configured backend names, so every \
2453                 exchange would be refused with invalid_target"
2454                    .into(),
2455            );
2456        }
2457        if self.token_exchange_ttl_s == 0 {
2458            errors.push("token_exchange_ttl_s must be > 0".into());
2459        }
2460        if self.token_exchange_ttl_s > 900 {
2461            errors.push(format!(
2462                "token_exchange_ttl_s = {} exceeds the 900 s ceiling; \
2463                 exchanged tokens must be short-lived",
2464                self.token_exchange_ttl_s
2465            ));
2466        }
2467        let has_exchange_backend = self
2468            .backends
2469            .iter()
2470            .any(|b| matches!(b.auth, BackendAuth::Exchange));
2471        if has_exchange_backend && self.token_exchange_seed_file.is_none() {
2472            errors.push(
2473                "a backend uses auth = \"exchange\" but token_exchange_seed_file is not set; \
2474                 the gateway cannot sign exchanged tokens without a seed"
2475                    .into(),
2476            );
2477        }
2478        if has_exchange_backend && !self.require_identity {
2479            errors.push(
2480                "a backend uses auth = \"exchange\" but require_identity is false; \
2481                 the gateway needs a validated caller identity to perform token exchange"
2482                    .into(),
2483            );
2484        }
2485        if self
2486            .backends
2487            .iter()
2488            .any(|b| matches!(b.auth, BackendAuth::ForwardToken))
2489            && !self.require_identity
2490        {
2491            errors.push(
2492                "a backend uses auth = \"forward_token\" but require_identity is false; \
2493                 only a validated caller token may be forwarded to a backend"
2494                    .into(),
2495            );
2496        }
2497        if let Some(pattern) = &self.identity_human_subject_pattern {
2498            if let Err(error) =
2499                av_identity::IdentityValidator::new(&self.audience).set_human_subject_pattern(pattern)
2500            {
2501                errors.push(format!("identity_human_subject_pattern: {error}"));
2502            }
2503        }
2504        for origin in &self.mcp_allowed_origins {
2505            if let Err(error) = validate_origin(origin) {
2506                errors.push(format!("mcp_allowed_origins entry {origin:?}: {error}"));
2507            }
2508        }
2509        errors.extend(crate::backend::validate_configs(&self.backends));
2510        if self.require_private_backends {
2511            for (field, url) in self
2512                .backends
2513                .iter()
2514                .map(|backend| (format!("backend {:?} url", backend.name), backend.url.as_str()))
2515                .chain(
2516                    self.tool_upstream_url
2517                        .iter()
2518                        .map(|url| ("tool_upstream_url".to_owned(), url.as_str())),
2519                )
2520            {
2521                if let Err(error) = crate::backend::validate_private_endpoint(url, &field) {
2522                    errors.push(error);
2523                }
2524            }
2525        }
2526        if self.backends.iter().any(|backend| backend.name == self.audience) {
2527            errors.push(format!(
2528                "a backend is named {:?}, the same as `audience`; tokens this gateway signs for that \
2529                 backend would also be valid inbound identities, so choose another name",
2530                self.audience
2531            ));
2532        }
2533        if !self.redaction_patterns.is_empty() || !self.redaction_paths.is_empty() {
2534            if let Err(error) = av_redact::RedactionEngine::new(av_redact::RedactionConfig {
2535                regex_patterns: self.redaction_patterns.clone(),
2536                pointer_paths: self.redaction_paths.clone(),
2537                ..av_redact::RedactionConfig::default()
2538            }) {
2539                errors.push(format!("invalid redaction configuration: {error}"));
2540            }
2541        }
2542        if let Some(endpoint) = &self.otel_tenant_endpoint {
2543            if let Err(error) = crate::backend::validate_http_endpoint(endpoint, "otel_tenant_endpoint") {
2544                errors.push(error);
2545            }
2546        }
2547        if let Some(path) = &self.otel_tenant_auth_file {
2548            if path.trim().is_empty() {
2549                errors.push("otel_tenant_auth_file must not be empty".into());
2550            }
2551            if self.otel_tenant_endpoint.is_none() {
2552                errors.push("otel_tenant_auth_file requires otel_tenant_endpoint".into());
2553            }
2554        }
2555        if self.revocation_audit_per_minute == 0 {
2556            errors.push("revocation_audit_per_minute must be > 0".into());
2557        }
2558        let valid_digest = |digest: &str| digest.len() == 64 && digest.bytes().all(|b| b.is_ascii_hexdigit());
2559        let mut operator_names = std::collections::HashSet::new();
2560        let mut introspection_backends = std::collections::HashSet::new();
2561        let mut credential_digests = std::collections::HashSet::new();
2562        if !self.operator_tokens.is_empty() && !has_identity_source {
2563            errors.push("operator_tokens requires identity_jwks_url or identity_hmac_secret_file".into());
2564        }
2565        if !self.introspection_tokens.is_empty() {
2566            if !has_identity_source {
2567                errors.push(
2568                    "introspection_tokens requires identity_jwks_url or identity_hmac_secret_file".into(),
2569                );
2570            }
2571            if self
2572                .token_exchange_seed_file
2573                .as_deref()
2574                .is_none_or(|path| path.trim().is_empty())
2575            {
2576                errors.push("introspection_tokens requires token_exchange_seed_file".into());
2577            }
2578        }
2579        for token in &self.operator_tokens {
2580            if token.name.trim().is_empty() || !operator_names.insert(&token.name) {
2581                errors.push("operator_tokens names must be non-empty and unique".into());
2582            }
2583            if !valid_digest(&token.sha256) {
2584                errors.push(format!(
2585                    "operator_tokens {:?} sha256 must contain exactly 64 hexadecimal characters",
2586                    token.name
2587                ));
2588            }
2589            if !credential_digests.insert(token.sha256.to_ascii_lowercase()) {
2590                errors.push(
2591                    "operator_tokens and introspection_tokens must use distinct sha256 credential digests"
2592                        .into(),
2593                );
2594            }
2595        }
2596        for token in &self.introspection_tokens {
2597            if !introspection_backends.insert(&token.backend) {
2598                errors.push(format!(
2599                    "duplicate introspection_tokens backend {:?}",
2600                    token.backend
2601                ));
2602            }
2603            let known_backend = self.backends.iter().any(|backend| backend.name == token.backend)
2604                || (token.backend == crate::backend::DEFAULT_BACKEND_NAME
2605                    && self.backends.is_empty()
2606                    && self.tool_upstream_url.is_some());
2607            if !known_backend {
2608                errors.push(format!(
2609                    "introspection_tokens references unknown backend {:?}",
2610                    token.backend
2611                ));
2612            }
2613            if !valid_digest(&token.sha256) {
2614                errors.push(format!(
2615                    "introspection_tokens {:?} sha256 must contain exactly 64 hexadecimal characters",
2616                    token.backend
2617                ));
2618            }
2619            if !credential_digests.insert(token.sha256.to_ascii_lowercase()) {
2620                errors.push(
2621                    "operator_tokens and introspection_tokens must use distinct sha256 credential digests"
2622                        .into(),
2623                );
2624            }
2625        }
2626    }
2627
2628    /// R69 F3 (landed R70): reject a `BudgetSpec` with any `Some(0)`
2629    /// numeric cap or a zero entry in `max_tool_calls`. See the
2630    /// call-site comment in `collect_validation_errors` for
2631    /// rationale; message shape matches the other `must be > 0`
2632    /// errors elsewhere in that function.
2633    fn validate_budget_spec(spec: &av_state::BudgetSpec, scope: &str, errors: &mut Vec<String>) {
2634        if spec.max_tokens == Some(0) {
2635            errors.push(format!(
2636                "{scope}.max_tokens must be > 0 (Some(0) refuses every non-zero spend, 429-storming \
2637                 every non-empty chat request); omit the field to disable the cap"
2638            ));
2639        }
2640        if spec.max_total_tool_calls == Some(0) {
2641            errors.push(format!(
2642                "{scope}.max_total_tool_calls must be > 0 (Some(0) refuses every tool call); \
2643                 omit the field to disable the cap"
2644            ));
2645        }
2646        if spec.max_payout_usd_micros == Some(0) {
2647            errors.push(format!(
2648                "{scope}.max_payout_usd_micros must be > 0 (Some(0) refuses every non-zero payout); \
2649                 omit the field to disable the cap"
2650            ));
2651        }
2652        for (tool, cap) in &spec.max_tool_calls {
2653            if *cap == 0 {
2654                errors.push(format!(
2655                    "{scope}.max_tool_calls[{tool:?}] must be > 0 (0 silently disables the tool with \
2656                     no error surface); omit the entry to leave the tool uncapped, or set >= 1"
2657                ));
2658            }
2659        }
2660    }
2661}
2662
2663#[cfg(test)]
2664mod tests {
2665    #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
2666
2667    #[test]
2668    fn security_config_rejects_backend_routing_and_redaction_errors_structurally() {
2669        let base = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2670        base.validate().unwrap();
2671        for (name, url, tools, expected) in [
2672            ("", "http://backend", vec!["read"], "backend name"),
2673            ("default", "http://backend", vec!["read"], "reserved"),
2674            ("tools", "https:///mcp", vec!["read"], "host"),
2675            ("tools", "file:///tmp/mcp", vec!["read"], "http"),
2676            ("tools", "http://backend:bad", vec!["read"], "valid URL"),
2677            ("tools", "http://backend", vec!["Read"], "lowercase ASCII"),
2678            ("tools", "http://backend", vec!["read", "read"], "repeated"),
2679        ] {
2680            let mut config = base.clone();
2681            config.backends = vec![BackendConfig {
2682                name: name.into(),
2683                url: url.into(),
2684                auth: BackendAuth::None,
2685                tools: tools.into_iter().map(str::to_owned).collect(),
2686                transport: Default::default(),
2687            }];
2688            assert!(config.validate().unwrap_err().contains(expected));
2689        }
2690        for (patterns, paths) in [
2691            (vec!["["], vec![]),
2692            (vec!["REDACTED"], vec![]),
2693            (vec![], vec!["password"]),
2694        ] {
2695            let mut config = base.clone();
2696            config.redaction_patterns = patterns.into_iter().map(str::to_owned).collect();
2697            config.redaction_paths = paths.into_iter().map(str::to_owned).collect();
2698            assert!(config.validate().unwrap_err().contains("redaction"));
2699        }
2700        for (ttl, expected) in [(0, false), (900, true), (901, false), (u64::MAX, false)] {
2701            let mut config = base.clone();
2702            config.intent_token_ttl_s = ttl;
2703            assert_eq!(config.validate().is_ok(), expected);
2704        }
2705        let mut empty_seed = base;
2706        empty_seed.token_exchange_seed_file = Some(String::new());
2707        assert!(empty_seed
2708            .validate()
2709            .unwrap_err()
2710            .contains("token_exchange_seed_file"));
2711    }
2712
2713    #[test]
2714    fn security_config_separates_structural_otel_checks_from_build_features() {
2715        let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2716        config.otel_tenant_endpoint = Some("https://tenant.example/v1/traces".into());
2717        config.validate().unwrap();
2718        assert_eq!(
2719            config
2720                .unsupported_backend_requirements()
2721                .iter()
2722                .any(|error| error.contains("otel")),
2723            !cfg!(feature = "otel")
2724        );
2725        config.otel_tenant_endpoint = Some("tenant.example/traces".into());
2726        assert!(config.validate().unwrap_err().contains("otel_tenant_endpoint"));
2727        config.otel_tenant_endpoint = None;
2728        config.otel_tenant_auth_file = Some("/run/secrets/tenant".into());
2729        assert!(config
2730            .validate()
2731            .unwrap_err()
2732            .contains("requires otel_tenant_endpoint"));
2733    }
2734
2735    #[test]
2736    fn security_config_validates_operator_and_introspection_credentials() {
2737        let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2738        config.identity_hmac_secret_file = Some("/run/secrets/identity".into());
2739        config.token_exchange_seed_file = Some("/run/secrets/exchange".into());
2740        config.backends = vec![BackendConfig {
2741            name: "db".into(),
2742            url: "http://db".into(),
2743            auth: BackendAuth::None,
2744            tools: vec![],
2745            transport: Default::default(),
2746        }];
2747        config.operator_tokens = vec![OperatorTokenConfig {
2748            name: "operator".into(),
2749            sha256: "ab".repeat(32),
2750        }];
2751        config.introspection_tokens = vec![IntrospectionTokenConfig {
2752            backend: "db".into(),
2753            sha256: "cd".repeat(32),
2754        }];
2755        config.validate().unwrap();
2756        let good = config.clone();
2757        config
2758            .operator_tokens
2759            .push(config.operator_tokens.first().unwrap().clone());
2760        assert!(config.validate().unwrap_err().contains("unique"));
2761        config = good.clone();
2762        config
2763            .introspection_tokens
2764            .push(config.introspection_tokens.first().unwrap().clone());
2765        assert!(config
2766            .validate()
2767            .unwrap_err()
2768            .contains("duplicate introspection_tokens"));
2769        config = good.clone();
2770        config.introspection_tokens.first_mut().unwrap().backend = "unknown".into();
2771        assert!(config.validate().unwrap_err().contains("unknown backend"));
2772        for digest in ["f".repeat(63), "g".repeat(64), "f".repeat(65)] {
2773            config = good.clone();
2774            config.operator_tokens.first_mut().unwrap().sha256 = digest.clone();
2775            config.introspection_tokens.first_mut().unwrap().sha256 = digest;
2776            let errors = config.validate().unwrap_err();
2777            assert!(errors.contains("operator_tokens"));
2778            assert!(errors.contains("introspection_tokens"));
2779        }
2780        config = good;
2781        config.revocation_audit_per_minute = 0;
2782        assert!(config
2783            .validate()
2784            .unwrap_err()
2785            .contains("revocation_audit_per_minute"));
2786    }
2787
2788    #[test]
2789    fn security_config_rejects_reused_control_credentials_and_missing_identity_sources() {
2790        let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2791        config.identity_hmac_secret_file = Some("/run/secrets/identity".into());
2792        config.token_exchange_seed_file = Some("/run/secrets/exchange".into());
2793        config.backends = ["db", "search"]
2794            .into_iter()
2795            .map(|name| BackendConfig {
2796                name: name.into(),
2797                url: format!("http://{name}"),
2798                auth: BackendAuth::None,
2799                tools: vec![name.into()],
2800                transport: Default::default(),
2801            })
2802            .collect();
2803        config.operator_tokens = vec![OperatorTokenConfig {
2804            name: "operator".into(),
2805            sha256: "ab".repeat(32),
2806        }];
2807        config.introspection_tokens = vec![IntrospectionTokenConfig {
2808            backend: "db".into(),
2809            sha256: "cd".repeat(32),
2810        }];
2811        config.validate().unwrap();
2812        let valid = config.clone();
2813
2814        config.operator_tokens.push(OperatorTokenConfig {
2815            name: "second-operator".into(),
2816            sha256: "AB".repeat(32),
2817        });
2818        assert!(config.validate().unwrap_err().contains("distinct sha256"));
2819        config = valid.clone();
2820        config.introspection_tokens.push(IntrospectionTokenConfig {
2821            backend: "search".into(),
2822            sha256: "CD".repeat(32),
2823        });
2824        assert!(config.validate().unwrap_err().contains("distinct sha256"));
2825        config = valid.clone();
2826        config.introspection_tokens.first_mut().unwrap().sha256 = config
2827            .operator_tokens
2828            .first()
2829            .unwrap()
2830            .sha256
2831            .to_ascii_uppercase();
2832        assert!(config.validate().unwrap_err().contains("distinct sha256"));
2833
2834        config = valid.clone();
2835        config.identity_hmac_secret_file = None;
2836        let errors = config.validate().unwrap_err();
2837        assert!(errors.contains("operator_tokens requires identity"));
2838        assert!(errors.contains("introspection_tokens requires identity"));
2839        config = valid;
2840        config.token_exchange_seed_file = None;
2841        assert!(config
2842            .validate()
2843            .unwrap_err()
2844            .contains("introspection_tokens requires token_exchange_seed_file"));
2845        config.introspection_tokens.clear();
2846        config.validate().unwrap();
2847    }
2848
2849    #[cfg(unix)]
2850    #[test]
2851    fn security_config_loads_tenant_auth_with_owner_only_permissions() {
2852        use std::os::unix::fs::PermissionsExt;
2853        let directory = tempfile::tempdir().unwrap();
2854        let path = directory.path().join("tenant-token");
2855        std::fs::write(&path, "tenant-secret\n").unwrap();
2856        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
2857        let mut config = HarnessConfig::for_tests("http://upstream", "/tmp/spool", "/tmp/bridge");
2858        config.otel_tenant_auth_file = Some(path.to_string_lossy().into_owned());
2859        let header = config.tenant_otel_authorization().unwrap().unwrap();
2860        assert_eq!(header, "Bearer tenant-secret");
2861        assert!(header.is_sensitive());
2862        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
2863        assert!(config.tenant_otel_authorization().is_err());
2864    }
2865
2866    /// Register item 25: docs/reference/CONFIGURATION.md claims to be
2867    /// the configuration reference, yet 29 of 63 fields (including
2868    /// `default_workflow` and `ignore_client_authorization` — the two
2869    /// knobs at the center of the onboarding story) shipped
2870    /// undocumented. Every `HarnessConfig` field must appear in the
2871    /// reference; adding a config key without documenting it now
2872    /// fails CI by design.
2873    #[test]
2874    fn configuration_reference_documents_every_config_field() {
2875        let source = include_str!("config.rs");
2876        let reference = include_str!("../../../docs/reference/CONFIGURATION.md");
2877        let body = source
2878            .split_once("pub struct HarnessConfig {")
2879            .map(|(_, rest)| rest)
2880            .and_then(|rest| rest.split_once("\n}"))
2881            .map(|(body, _)| body)
2882            .unwrap();
2883        let fields: Vec<&str> = body
2884            .lines()
2885            .filter_map(|line| line.trim().strip_prefix("pub "))
2886            .filter_map(|rest| rest.split_once(':'))
2887            .map(|(name, _)| name.trim())
2888            .collect();
2889        assert!(
2890            fields.len() >= 60,
2891            "field extraction broke — found only {} fields",
2892            fields.len()
2893        );
2894        for field in fields {
2895            assert!(
2896                reference.contains(&format!("`{field}`")) || reference.contains(&format!("`[{field}]`")),
2897                "config field `{field}` is not documented in docs/reference/CONFIGURATION.md"
2898            );
2899        }
2900    }
2901
2902    use super::*;
2903
2904    #[test]
2905    fn minimal_toml_parses_with_defaults() {
2906        let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2907        assert_eq!(cfg.config_version, CONFIG_VERSION);
2908        assert_eq!(cfg.default_workflow, "unsigned");
2909        assert_eq!(cfg.session_idle_close_s, 900);
2910        assert!(cfg.compression_enabled);
2911        assert_eq!(cfg.upstream_chat_path, "/v1/chat/completions");
2912        assert_eq!(cfg.upstream_auth_header, "authorization");
2913        assert_eq!(cfg.upstream_auth_scheme, "Bearer");
2914        assert!(cfg.upstream_api_key_env.is_none());
2915        assert!(!cfg.upstream_authorization_passthrough);
2916    }
2917
2918    /// Validation must surface EVERY violation in one
2919    /// run, not one per `avctl config-validate` round-trip.
2920    /// `allowed_hosts` entries that can never match the port-stripped
2921    /// request Host (ports, whitespace, empties) must be refused at
2922    /// boot — the alternative is every request 403ing in production
2923    /// with no hint that the config entry shape is wrong.
2924    #[test]
2925    fn validate_refuses_unmatchable_allowed_hosts_entries() {
2926        for (entry, needle) in [
2927            ("localhost:8484", "includes a port"),
2928            ("[::1]:8484", "includes a port"),
2929            (" localhost", "whitespace"),
2930            ("", "empty"),
2931        ] {
2932            let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2933            cfg.allowed_hosts = vec![entry.to_owned()];
2934            let error = cfg.validate().unwrap_err();
2935            assert!(
2936                error.contains(needle),
2937                "entry {entry:?}: expected {needle:?} in: {error}"
2938            );
2939        }
2940        // Legitimate shapes still pass: hostname, IPv4, bare and
2941        // bracketed IPv6 (bare IPv6 has colons but is not host:port).
2942        for entry in [
2943            "localhost",
2944            "127.0.0.1",
2945            "::1",
2946            "2001:db8::1",
2947            "[::1]",
2948            "agentvisor.internal",
2949        ] {
2950            let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2951            cfg.allowed_hosts = vec![entry.to_owned()];
2952            cfg.validate()
2953                .unwrap_or_else(|e| panic!("entry {entry:?} must pass: {e}"));
2954        }
2955    }
2956
2957    #[test]
2958    fn validate_reports_all_errors_at_once() {
2959        let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2960        cfg.listen = String::new();
2961        cfg.bridge_backend = "carrier-pigeon".to_owned();
2962        cfg.state_backend = "abacus".to_owned();
2963        let error = cfg.validate().unwrap_err();
2964        assert!(
2965            error.contains("config errors"),
2966            "aggregate header missing: {error}"
2967        );
2968        for needle in ["listen", "bridge_backend", "state_backend"] {
2969            assert!(error.contains(needle), "missing {needle} in: {error}");
2970        }
2971    }
2972
2973    /// A single violation keeps the historical bare-message shape
2974    /// (no aggregate header) so existing tooling that string-matches
2975    /// one error keeps working.
2976    #[test]
2977    fn validate_single_error_keeps_bare_message() {
2978        let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2979        cfg.state_backend = "abacus".to_owned();
2980        let error = cfg.validate().unwrap_err();
2981        assert!(
2982            !error.contains("config errors"),
2983            "single error must not aggregate: {error}"
2984        );
2985        assert!(error.contains("state_backend"), "must name the field: {error}");
2986    }
2987
2988    /// An unsupported provider dialect fails
2989    /// pre-flight, naming the supported set — never mid-stream.
2990    #[test]
2991    fn unsupported_provider_is_refused_at_validation() {
2992        let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
2993        assert_eq!(cfg.provider, "openai");
2994        cfg.provider = "google".to_owned();
2995        let error = cfg.validate().unwrap_err();
2996        assert!(error.contains("provider"), "must name the field: {error}");
2997        assert!(error.contains("openai"), "must name the supported set: {error}");
2998        cfg.provider = "anthropic".to_owned();
2999        assert!(
3000            cfg.validate().is_ok(),
3001            "anthropic is a supported dialect (S3 step 2)"
3002        );
3003    }
3004
3005    /// The default backends (embedded/memory/hash/
3006    /// memory) require no cargo feature and must always be runnable.
3007    #[test]
3008    fn default_backends_never_report_unsupported_requirements() {
3009        let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3010        assert!(cfg.unsupported_backend_requirements().is_empty());
3011    }
3012
3013    /// Each feature-gated backend is reported exactly
3014    /// when its cargo feature is compiled out — written against
3015    /// `cfg!` so the same assertion holds under default features AND
3016    /// `--all-features` CI runs. Companions are set because feature
3017    /// detection now runs over the typed backends: a selection that
3018    /// cannot resolve is `validate()`'s problem, not this method's.
3019    #[test]
3020    fn unsupported_backend_requirements_track_build_features() {
3021        let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).unwrap();
3022        cfg.bridge_backend = "kafka".to_owned();
3023        cfg.bridge_endpoint = Some("broker:9092".to_owned());
3024        cfg.state_backend = "redis".to_owned();
3025        cfg.state_endpoint = Some("redis://cache:6379".to_owned());
3026        cfg.embedder_backend = "onnx".to_owned();
3027        cfg.onnx_model_path = Some("model.onnx".to_owned());
3028        cfg.onnx_tokenizer_path = Some("tokenizer.json".to_owned());
3029        cfg.vector_backend = "qdrant".to_owned();
3030        cfg.qdrant_url = Some("http://vectors:6333".to_owned());
3031        let missing = cfg.unsupported_backend_requirements();
3032        for (enabled, feature) in [
3033            (cfg!(feature = "kafka"), "kafka"),
3034            (cfg!(feature = "redis"), "redis"),
3035            (cfg!(feature = "onnx"), "onnx"),
3036            (cfg!(feature = "qdrant"), "qdrant"),
3037        ] {
3038            let reported = missing
3039                .iter()
3040                .any(|message| message.contains(&format!("`{feature}`")));
3041            assert_eq!(
3042                reported, !enabled,
3043                "feature {feature}: enabled={enabled} but reported={reported} in {missing:?}"
3044            );
3045        }
3046    }
3047
3048    /// Each backend selector with a missing required companion fails
3049    /// with an error naming the companion field — the typed accessors
3050    /// make the invalid combination unrepresentable past parsing.
3051    #[test]
3052    fn backend_missing_companion_fails_with_named_error() {
3053        for (extra, companion) in [
3054            ("bridge_backend = \"kafka\"", "bridge_endpoint"),
3055            ("bridge_backend = \"nats\"", "bridge_endpoint"),
3056            (
3057                "bridge_backend = \"kafka\"\nbridge_endpoint = \"\"",
3058                "bridge_endpoint",
3059            ),
3060            ("state_backend = \"redis\"", "state_endpoint"),
3061            ("vector_backend = \"qdrant\"", "qdrant_url"),
3062            ("embedder_backend = \"onnx\"", "onnx_model_path"),
3063            (
3064                // One companion present, the other absent: still refused.
3065                "embedder_backend = \"onnx\"\nonnx_model_path = \"model.onnx\"",
3066                "onnx_tokenizer_path",
3067            ),
3068        ] {
3069            let err =
3070                HarnessConfig::from_toml(&format!("upstream_url = \"https://api\"\n{extra}")).unwrap_err();
3071            assert!(
3072                err.contains(companion) && err.contains("required"),
3073                "{extra}: error must name {companion}: {err}"
3074            );
3075        }
3076    }
3077
3078    /// An unknown backend value fails naming the exhaustive legal set,
3079    /// so a typo in `avctl config-validate` output tells the operator
3080    /// exactly what the field accepts.
3081    #[test]
3082    fn unknown_backend_value_names_legal_set() {
3083        for (field, bogus, legal_set) in [
3084            ("bridge_backend", "carrier-pigeon", "embedded|kafka|nats"),
3085            ("state_backend", "abacus", "memory|redis"),
3086            ("embedder_backend", "vibes", "hash|onnx"),
3087            ("vector_backend", "faiss", "memory|qdrant"),
3088        ] {
3089            let err =
3090                HarnessConfig::from_toml(&format!("upstream_url = \"https://api\"\n{field} = \"{bogus}\""))
3091                    .unwrap_err();
3092            assert!(err.contains(field), "must name the field: {err}");
3093            assert!(err.contains(legal_set), "must name the legal set: {err}");
3094            assert!(err.contains(bogus), "must echo the typo: {err}");
3095        }
3096    }
3097
3098    /// A valid full-feature config resolves to the expected typed
3099    /// backend values, each variant carrying its companion.
3100    #[test]
3101    fn full_feature_config_resolves_to_typed_backends() {
3102        let cfg = HarnessConfig::from_toml(
3103            r#"upstream_url = "https://api"
3104               bridge_backend = "kafka"
3105               bridge_endpoint = "broker-1:9092,broker-2:9092"
3106               state_backend = "redis"
3107               state_endpoint = "redis://cache:6379"
3108               embedder_backend = "onnx"
3109               onnx_model_path = "models/model.onnx"
3110               onnx_tokenizer_path = "models/tokenizer.json"
3111               vector_backend = "qdrant"
3112               qdrant_url = "http://vectors:6333""#,
3113        )
3114        .unwrap();
3115        let backends = cfg.backends().unwrap();
3116        assert_eq!(
3117            backends.bridge,
3118            BridgeBackend::Kafka {
3119                endpoint: "broker-1:9092,broker-2:9092".to_owned()
3120            }
3121        );
3122        assert_eq!(
3123            backends.state,
3124            StateBackend::Redis {
3125                endpoint: "redis://cache:6379".to_owned()
3126            }
3127        );
3128        assert_eq!(
3129            backends.embedder,
3130            EmbedderBackend::Onnx {
3131                model_path: "models/model.onnx".to_owned(),
3132                tokenizer_path: "models/tokenizer.json".to_owned()
3133            }
3134        );
3135        assert_eq!(
3136            backends.vector,
3137            VectorBackend::Qdrant {
3138                url: "http://vectors:6333".to_owned()
3139            }
3140        );
3141        // The defaults resolve to the no-companion variants.
3142        let cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api""#).unwrap();
3143        let backends = cfg.backends().unwrap();
3144        assert_eq!(backends.bridge, BridgeBackend::Embedded);
3145        assert_eq!(backends.state, StateBackend::Memory);
3146        assert_eq!(backends.embedder, EmbedderBackend::Hash);
3147        assert_eq!(backends.vector, VectorBackend::Memory);
3148    }
3149
3150    /// `backends()` accumulates one error per unresolvable selector
3151    /// instead of stopping at the first, mirroring `validate()`.
3152    #[test]
3153    fn backends_accumulates_all_resolution_errors() {
3154        let mut cfg = HarnessConfig::from_toml(r#"upstream_url = "https://api""#).unwrap();
3155        cfg.bridge_backend = "carrier-pigeon".to_owned();
3156        cfg.state_backend = "redis".to_owned(); // missing state_endpoint
3157        cfg.vector_backend = "faiss".to_owned();
3158        let errors = cfg.backends().unwrap_err();
3159        assert_eq!(errors.len(), 3, "{errors:?}");
3160        let joined = errors.join("\n");
3161        for needle in ["bridge_backend", "state_endpoint", "vector_backend"] {
3162            assert!(joined.contains(needle), "missing {needle} in {errors:?}");
3163        }
3164    }
3165
3166    /// A typo'd key must fail loudly (naming the offender) instead of being
3167    /// silently ignored; `config_version` handles forward compatibility.
3168    #[test]
3169    fn unknown_config_key_is_rejected_with_its_name() {
3170        let err = HarnessConfig::from_toml(
3171            r#"upstream_url = "https://api.openai.com"
3172               idel_timeout_s = 10"#,
3173        )
3174        .unwrap_err();
3175        assert!(err.contains("idel_timeout_s"), "error must name the key: {err}");
3176    }
3177
3178    /// The auth surface must reject every ambiguous or unsafe combination
3179    /// loudly at startup instead of picking one silently.
3180    #[test]
3181    fn upstream_auth_invariants_enforced() {
3182        // Both key sources set: ambiguous.
3183        assert!(HarnessConfig::from_toml(
3184            r#"upstream_url = "https://api.openai.com"
3185               upstream_api_key_env = "OPENAI_API_KEY"
3186               upstream_api_key_file = "/etc/key""#
3187        )
3188        .is_err());
3189        // Passthrough plus static key: ambiguous.
3190        assert!(HarnessConfig::from_toml(
3191            r#"upstream_url = "https://api.openai.com"
3192               upstream_api_key_env = "OPENAI_API_KEY"
3193               upstream_authorization_passthrough = true"#
3194        )
3195        .is_err());
3196        // Passthrough would forward the NHI token upstream.
3197        assert!(HarnessConfig::from_toml(
3198            r#"upstream_url = "https://api.openai.com"
3199               upstream_authorization_passthrough = true
3200               require_identity = true
3201               identity_hmac_secret_file = "/run/secrets/hmac""#
3202        )
3203        .is_err());
3204        // Invalid header name.
3205        assert!(HarnessConfig::from_toml(
3206            r#"upstream_url = "https://api.openai.com"
3207               upstream_auth_header = "not a header""#
3208        )
3209        .is_err());
3210        // Scheme with embedded space.
3211        assert!(HarnessConfig::from_toml(
3212            r#"upstream_url = "https://api.openai.com"
3213               upstream_auth_scheme = "Bearer extra""#
3214        )
3215        .is_err());
3216        // Chat path must be absolute.
3217        assert!(HarnessConfig::from_toml(
3218            r#"upstream_url = "https://api.openai.com"
3219               upstream_chat_path = "v1/chat/completions""#
3220        )
3221        .is_err());
3222        // Tool bearer without a tool upstream is a misconfiguration.
3223        assert!(HarnessConfig::from_toml(
3224            r#"upstream_url = "https://api.openai.com"
3225               tool_upstream_bearer_env = "MCP_TOKEN""#
3226        )
3227        .is_err());
3228        // Azure-style raw key header is valid.
3229        let azure = HarnessConfig::from_toml(
3230            r#"upstream_url = "https://res.openai.azure.com"
3231               upstream_chat_path = "/openai/deployments/gpt-4o/chat/completions?api-version=2024-10-21"
3232               upstream_api_key_env = "AZURE_OPENAI_API_KEY"
3233               upstream_auth_header = "api-key"
3234               upstream_auth_scheme = """#,
3235        )
3236        .unwrap();
3237        assert_eq!(azure.upstream_auth_scheme, "");
3238    }
3239
3240    #[test]
3241    fn env_overrides_apply_with_documented_precedence() {
3242        let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
3243        let env = |name: &str| -> Option<String> {
3244            match name {
3245                "AV_LISTEN" => Some("0.0.0.0:9999".into()),
3246                "AV_UPSTREAM_URL" => Some("http://env-upstream".into()),
3247                "AV_UPSTREAM_CHAT_PATH" => Some("/openai/v1/chat/completions".into()),
3248                "AV_UPSTREAM_API_KEY" => Some("sk-secret".into()),
3249                "AV_STATE_ENDPOINT" => Some("redis://redis:6379".into()),
3250                _ => None,
3251            }
3252        };
3253        cfg.apply_env_overrides_from(env);
3254        assert_eq!(cfg.listen, "0.0.0.0:9999");
3255        assert_eq!(cfg.upstream_url, "http://env-upstream");
3256        assert_eq!(cfg.upstream_chat_path, "/openai/v1/chat/completions");
3257        assert_eq!(cfg.state_endpoint.as_deref(), Some("redis://redis:6379"));
3258        // AV_UPSTREAM_API_KEY presence selects itself as the key source...
3259        assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("AV_UPSTREAM_API_KEY"));
3260
3261        // ...but never displaces an explicitly configured source.
3262        let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
3263        cfg.upstream_api_key_env = Some("OPENAI_API_KEY".into());
3264        cfg.apply_env_overrides_from(env);
3265        assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("OPENAI_API_KEY"));
3266
3267        // Empty environment values are ignored rather than blanking fields.
3268        let mut cfg = HarnessConfig::for_tests("http://file-upstream", "spool", "bridge");
3269        cfg.apply_env_overrides_from(|name| (name == "AV_UPSTREAM_URL").then(String::new));
3270        assert_eq!(cfg.upstream_url, "http://file-upstream");
3271    }
3272
3273    #[test]
3274    fn env_key_file_override_precedence() {
3275        // AV_UPSTREAM_KEY_FILE selects the mounted-secret file...
3276        let env = |name: &str| -> Option<String> {
3277            match name {
3278                "AV_UPSTREAM_KEY_FILE" => Some("/run/secrets/api_key".into()),
3279                "AV_UPSTREAM_API_KEY" => Some("sk-secret".into()),
3280                _ => None,
3281            }
3282        };
3283        let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
3284        cfg.apply_env_overrides_from(env);
3285        assert_eq!(cfg.upstream_api_key_file.as_deref(), Some("/run/secrets/api_key"));
3286        // ...and beats the AV_UPSTREAM_API_KEY self-selection (no ambiguity).
3287        assert_eq!(cfg.upstream_api_key_env, None);
3288
3289        // But never displaces a config-file key source.
3290        let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
3291        cfg.upstream_api_key_env = Some("OPENAI_API_KEY".into());
3292        cfg.apply_env_overrides_from(env);
3293        assert_eq!(cfg.upstream_api_key_env.as_deref(), Some("OPENAI_API_KEY"));
3294        assert_eq!(cfg.upstream_api_key_file, None);
3295
3296        // And is ignored entirely in passthrough mode.
3297        let mut cfg = HarnessConfig::for_tests("http://u", "spool", "bridge");
3298        cfg.upstream_authorization_passthrough = true;
3299        cfg.apply_env_overrides_from(env);
3300        assert_eq!(cfg.upstream_api_key_file, None);
3301        assert_eq!(cfg.upstream_api_key_env, None);
3302    }
3303
3304    #[test]
3305    fn builtin_config_validates_once_upstream_is_set() {
3306        let mut cfg = HarnessConfig::builtin().unwrap();
3307        assert!(cfg.validate().is_err(), "must not pass without an upstream");
3308        cfg.apply_env_overrides_from(|name| {
3309            (name == "AV_UPSTREAM_URL").then(|| "http://127.0.0.1:11434".to_owned())
3310        });
3311        cfg.validate().unwrap();
3312        assert_eq!(cfg.bridge_backend, "embedded");
3313        assert_eq!(cfg.state_backend, "memory");
3314        assert_eq!(cfg.embedder_backend, "hash");
3315        assert_eq!(cfg.vector_backend, "memory");
3316    }
3317
3318    #[test]
3319    fn validate_rejects_unbindable_listen_and_zero_intervals() {
3320        let base = || HarnessConfig::for_tests("http://u", "spool", "bridge");
3321
3322        let mut cfg = base();
3323        cfg.listen = String::new();
3324        assert!(cfg.validate().unwrap_err().contains("listen is required"));
3325
3326        cfg = base();
3327        cfg.listen = "no-port-here".into();
3328        assert!(cfg.validate().unwrap_err().contains("host:port"));
3329
3330        cfg = base();
3331        cfg.listen = "127.0.0.1:70000".into();
3332        assert!(cfg.validate().unwrap_err().contains("host:port"));
3333
3334        // Hostnames and OS-assigned port 0 stay legal (bind resolves them).
3335        cfg = base();
3336        cfg.listen = "localhost:8484".into();
3337        cfg.validate().unwrap();
3338        cfg.listen = "[::1]:0".into();
3339        cfg.validate().unwrap();
3340
3341        cfg = base();
3342        cfg.reconcile_tick_s = 0;
3343        assert!(cfg.validate().unwrap_err().contains("reconcile_tick_s"));
3344
3345        cfg = base();
3346        cfg.session_idle_close_s = 0;
3347        assert!(cfg.validate().unwrap_err().contains("session_idle_close_s"));
3348
3349        // `atif_retention_days = Some(0)` would nuke every sealed pair
3350        // on the first hourly sweep (max_age = 0 → `age < 0` false for
3351        // all mtimes → all pruned). Validate must reject it; `None`
3352        // (disable) and `Some(1)`+ (real windows) stay legal.
3353        cfg = base();
3354        cfg.atif_retention_days = Some(0);
3355        assert!(
3356            cfg.validate().unwrap_err().contains("atif_retention_days = 0"),
3357            "atif_retention_days = 0 should be rejected"
3358        );
3359        cfg.atif_retention_days = None;
3360        cfg.validate().unwrap();
3361        cfg.atif_retention_days = Some(1);
3362        cfg.validate().unwrap();
3363
3364        // R69 F3 / R70: BudgetSpec `Some(0)` caps silently 429-storm
3365        // every non-empty request against the ledger's refuse-any-
3366        // spend semantics. Reject at config-validate; None (uncapped)
3367        // and Some(>=1) (real cap) stay legal.
3368        cfg = base();
3369        cfg.budget.max_tokens = Some(0);
3370        assert!(
3371            cfg.validate().unwrap_err().contains("budget.max_tokens"),
3372            "budget.max_tokens = 0 should be rejected"
3373        );
3374        cfg = base();
3375        cfg.budget.max_total_tool_calls = Some(0);
3376        assert!(
3377            cfg.validate()
3378                .unwrap_err()
3379                .contains("budget.max_total_tool_calls"),
3380            "budget.max_total_tool_calls = 0 should be rejected"
3381        );
3382        cfg = base();
3383        cfg.budget.max_payout_usd_micros = Some(0);
3384        assert!(
3385            cfg.validate()
3386                .unwrap_err()
3387                .contains("budget.max_payout_usd_micros"),
3388            "budget.max_payout_usd_micros = 0 should be rejected"
3389        );
3390        cfg = base();
3391        cfg.budget.max_tool_calls = std::collections::BTreeMap::from([("db_write".to_owned(), 0u64)]);
3392        assert!(
3393            cfg.validate()
3394                .unwrap_err()
3395                .contains("max_tool_calls[\"db_write\"]"),
3396            "budget.max_tool_calls[db_write] = 0 should be rejected"
3397        );
3398        // Same discipline applies to the per-principal budget spec.
3399        cfg = base();
3400        cfg.require_identity = true;
3401        cfg.identity_jwks_url = Some("https://example/.well-known/jwks.json".to_owned());
3402        cfg.principal_budget = Some(av_state::BudgetSpec {
3403            max_tokens: Some(0),
3404            ..av_state::BudgetSpec::default()
3405        });
3406        assert!(
3407            cfg.validate()
3408                .unwrap_err()
3409                .contains("principal_budget.max_tokens"),
3410            "principal_budget.max_tokens = 0 should be rejected"
3411        );
3412        // Uncapped (None) stays legal on both.
3413        cfg = base();
3414        cfg.budget = av_state::BudgetSpec::default();
3415        cfg.validate().unwrap();
3416
3417        cfg = base();
3418        cfg.breaker.window = 0;
3419        assert!(cfg.validate().unwrap_err().contains("breaker.window"));
3420
3421        cfg = base();
3422        cfg.breaker.delta_epsilon = f32::NAN;
3423        assert!(cfg.validate().unwrap_err().contains("delta_epsilon"));
3424        cfg.breaker.delta_epsilon = -0.5;
3425        assert!(cfg.validate().unwrap_err().contains("delta_epsilon"));
3426    }
3427
3428    /// Register item 25 sub-clause: "`AV_SIGNING_SEED_FILE` is in zero
3429    /// .md files" — the exact drift the reference table was supposed to
3430    /// prevent. Pass 18 pinned config-file field completeness, but env
3431    /// vars had no completeness guard, so `AV_SIGNING_SEED_FILE` could
3432    /// silently vanish again on a future edit. Every operator-facing
3433    /// `AV_*` env var must appear in at least one reference document.
3434    /// The test-only `AV_HARBOR_INTEROP_OUT` (a Harbor-interop escape
3435    /// hatch scoped to `full_chat_close_and_promotion_flow`) is not
3436    /// operator-facing and is deliberately excluded.
3437    #[test]
3438    fn operator_env_vars_are_documented() {
3439        let refs = [
3440            include_str!("../../../docs/reference/CONFIGURATION.md"),
3441            include_str!("../../../docs/reference/OPERATIONS.md"),
3442            include_str!("../../../docs/reference/OPENAI-COMPATIBILITY.md"),
3443            include_str!("../../../docs/reference/LIMITS.md"),
3444        ]
3445        .concat();
3446        for var in [
3447            // Config resolution (zero-config startup and file override).
3448            "AV_CONFIG",
3449            "AV_UPSTREAM_URL",
3450            // Trust anchor path — the register item-25 explicit callout.
3451            "AV_SIGNING_SEED_FILE",
3452            // Bearer token loader used by `avctl session-promote`.
3453            "AV_BEARER_TOKEN_FILE",
3454        ] {
3455            assert!(
3456                refs.contains(var),
3457                "operator-facing env var `{var}` is not documented in any \
3458                 docs/reference/*.md — register item 25 explicitly names the \
3459                 undocumented AV_ env var class as the drift signature."
3460            );
3461        }
3462    }
3463
3464    #[test]
3465    fn user_config_path_is_stable() {
3466        let path = user_config_path_from(std::path::Path::new("/home/pat"));
3467        assert_eq!(
3468            path,
3469            std::path::Path::new("/home/pat/.agentvisor/agentvisor.toml")
3470        );
3471    }
3472
3473    /// Pin the register-item-6 fix behaviorally. The original register
3474    /// complained: "Wizard-then-`avctl start` inside a checkout silently
3475    /// loads the example config: wrong bind, dead upstream, no API key,
3476    /// no error." The fix was to REMOVE `config/harness.example.toml`
3477    /// from the auto-discovery list entirely (better than the register's
3478    /// requested rank swap): the example is a template, not a config.
3479    /// If someone adds it back — well-meaning "make the example work
3480    /// out of the box" — every developer with a wizard-written
3481    /// `~/.agentvisor/agentvisor.toml` silently regresses to the
3482    /// example's settings the moment they `cd` into a checkout.
3483    #[test]
3484    fn config_search_paths_never_include_the_example_config() {
3485        for path in CONFIG_SEARCH_PATHS {
3486            assert!(
3487                !path.contains("example"),
3488                "CONFIG_SEARCH_PATHS must not auto-discover a `*example*` file: {path:?}. \
3489                 See the doc-comment on CONFIG_SEARCH_PATHS — the example is a template, \
3490                 not a config, and auto-loading it defeats the wizard-written per-user file."
3491            );
3492        }
3493        // Also pin: the per-user wizard file must sit at the documented
3494        // location, so `avctl init --output ~/.agentvisor/agentvisor.toml`
3495        // (the wizard's actual write path) is what `resolve_config_source`
3496        // will find in the fall-through arm.
3497        assert_eq!(
3498            user_config_path_from(std::path::Path::new("/h")),
3499            std::path::PathBuf::from("/h/.agentvisor/agentvisor.toml"),
3500            "the wizard-written path is contract; changing it breaks every operator \
3501             whose home already has this file"
3502        );
3503    }
3504
3505    /// The `/v1` suffix footgun must be detected exactly: flagged when the
3506    /// base URL already ends with the first chat-path segment, silent for
3507    /// bare hosts, ports, and provider paths that do not overlap.
3508    #[test]
3509    fn duplicated_chat_path_segment_detection() {
3510        let cfg = |url: &str| HarnessConfig::for_tests(url, "spool", "bridge");
3511        assert_eq!(
3512            cfg("https://api.openai.com/v1").duplicated_chat_path_segment(),
3513            Some("v1")
3514        );
3515        assert_eq!(
3516            cfg("https://api.openai.com/v1/").duplicated_chat_path_segment(),
3517            Some("v1")
3518        );
3519        assert_eq!(
3520            cfg("http://localhost:8080/v1").duplicated_chat_path_segment(),
3521            Some("v1")
3522        );
3523        assert_eq!(cfg("https://api.openai.com").duplicated_chat_path_segment(), None);
3524        assert_eq!(cfg("http://127.0.0.1:11434").duplicated_chat_path_segment(), None);
3525        // Gemini-style base path that does not repeat the chat path.
3526        let mut gemini = cfg("https://generativelanguage.googleapis.com/v1beta/openai");
3527        gemini.upstream_chat_path = "/chat/completions".into();
3528        assert_eq!(gemini.duplicated_chat_path_segment(), None);
3529        // Azure-style custom path with a matching base suffix still flags.
3530        let mut azure = cfg("https://r.openai.azure.com/openai");
3531        azure.upstream_chat_path = "/openai/deployments/d/chat/completions".into();
3532        assert_eq!(azure.duplicated_chat_path_segment(), Some("openai"));
3533        // A pasted full endpoint URL embeds the entire chat path.
3534        assert_eq!(
3535            cfg("http://10.0.0.5:8000/v1/chat/completions").duplicated_chat_path_segment(),
3536            Some("v1/chat/completions")
3537        );
3538        assert_eq!(
3539            cfg("http://10.0.0.5:8000/v1/chat/completions/").duplicated_chat_path_segment(),
3540            Some("v1/chat/completions")
3541        );
3542    }
3543
3544    #[test]
3545    fn bad_configs_rejected() {
3546        assert!(HarnessConfig::from_toml("").is_err()); // missing upstream
3547        assert!(HarnessConfig::from_toml(
3548            r#"upstream_url = "https://api"
3549               config_version = 99"#
3550        )
3551        .is_err());
3552        // Each case below must reach the check it names, not fall
3553        // through to `upstream_url` scheme validation — otherwise
3554        // deleting the specific validator here still leaves the outer
3555        // is_err() true. Use a valid upstream_url and pin the actual
3556        // rejection reason.
3557        let bad_workflow = HarnessConfig::from_toml(
3558            r#"upstream_url = "https://api"
3559               default_workflow = "sometimes""#,
3560        )
3561        .unwrap_err();
3562        assert!(
3563            bad_workflow.contains("default_workflow"),
3564            "the default_workflow allowlist must be what refuses this config, \
3565             not the upstream_url scheme check via fallthrough; got {bad_workflow}"
3566        );
3567        let bad_capacity = HarnessConfig::from_toml(
3568            r#"upstream_url = "https://api"
3569               worker_channel_capacity = 0"#,
3570        )
3571        .unwrap_err();
3572        assert!(
3573            bad_capacity.contains("worker_channel_capacity"),
3574            "the worker_channel_capacity bounds check must be what refuses this \
3575             config, not the upstream_url scheme check via fallthrough; got {bad_capacity}"
3576        );
3577    }
3578
3579    /// A config from a newer format version must be refused by its declared
3580    /// `config_version` — not by whichever unknown key the strict parser
3581    /// happens to trip on first.
3582    #[test]
3583    fn future_version_config_reports_version_not_unknown_field() {
3584        let err = HarnessConfig::from_toml(
3585            r#"config_version = 2
3586               upstream_url = "https://api"
3587               future_option_from_v2 = true"#,
3588        )
3589        .unwrap_err();
3590        assert!(err.contains("unsupported config_version 2"), "{err}");
3591        assert!(!err.contains("future_option_from_v2"), "{err}");
3592    }
3593
3594    /// Cap on `worker_channel_capacity` rejects the fat-finger config
3595    /// before it reaches the runtime (defence-in-depth; tokio's mpsc
3596    /// does not preallocate — see the `MAX_WORKER_CHANNEL_CAPACITY` doc).
3597    #[test]
3598    fn worker_channel_capacity_cap_rejects_oversized_values() {
3599        let err = HarnessConfig::from_toml(&format!(
3600            "upstream_url = \"https://api\"\nworker_channel_capacity = {}",
3601            MAX_WORKER_CHANNEL_CAPACITY + 1
3602        ))
3603        .unwrap_err();
3604        assert!(
3605            err.contains("worker_channel_capacity"),
3606            "err should name the offending field: {err}"
3607        );
3608    }
3609
3610    /// `upstream_url` without a scheme is rejected at load — otherwise
3611    /// the request-time concat would silently misroute to a bogus host.
3612    /// The shape check was tightened from `contains("://")` to
3613    /// the strict `http://` / `https://` allowlist, matching the
3614    /// posture on every other URL field.
3615    #[test]
3616    fn upstream_url_without_scheme_is_rejected() {
3617        let err = HarnessConfig::from_toml(r#"upstream_url = "openai.internal""#).unwrap_err();
3618        assert!(err.contains("upstream_url"), "{err}");
3619        assert!(err.contains("http"), "{err}");
3620    }
3621
3622    /// Schemes other than http/https are rejected. The
3623    /// prior `contains("://")` shape check accepted `file:///…` and
3624    /// other schemes even though the error text claimed http/https;
3625    /// a config-injection primitive could have pointed the harness
3626    /// at `file:///etc/passwd`. `avctl config-validate` now refuses.
3627    #[test]
3628    fn upstream_url_non_http_scheme_is_rejected() {
3629        let err = HarnessConfig::from_toml(r#"upstream_url = "file:///etc/passwd""#).unwrap_err();
3630        assert!(err.contains("upstream_url"), "{err}");
3631        let err = HarnessConfig::from_toml(r#"upstream_url = "gopher://x""#).unwrap_err();
3632        assert!(err.contains("upstream_url"), "{err}");
3633        // http and https are the only accepted schemes.
3634        assert!(HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).is_ok());
3635        assert!(HarnessConfig::from_toml(r#"upstream_url = "http://gw.local""#).is_ok());
3636    }
3637
3638    /// A scheme with no host (`http://`, `https:///v1`) passed the
3639    /// prefix preflight, booted, and validated cleanly, failing only at
3640    /// request time with a 502. Refuse it at startup like every other
3641    /// URL-shape defect.
3642    #[test]
3643    fn upstream_url_without_host_is_rejected() {
3644        for url in [r#"upstream_url = "http://""#, r#"upstream_url = "https:///v1""#] {
3645            let err = HarnessConfig::from_toml(url).unwrap_err();
3646            assert!(err.contains("no host"), "{err}");
3647        }
3648    }
3649
3650    /// The shipped example config must PARSE AND VALIDATE, and must
3651    /// pin the register-#4 posture: loopback bind, dashboard off, and
3652    /// every uncommented value acceptable to the strict loader. This
3653    /// is the file the README tells checkout users to copy — nothing
3654    /// exercised it before, which is exactly how the README's
3655    /// checkout section drifted stale. All three shipped configs must
3656    /// also opt into the signed workflow: the serde default is
3657    /// "unsigned" (zero-config ergonomics), so any shipped config
3658    /// that omits the key silently produces ZERO signed receipts on
3659    /// clean traffic — the register's item-19 observation, found
3660    /// live in the container/docker configs and the K8s ConfigMap.
3661    #[test]
3662    fn shipped_example_config_parses_and_pins_safe_posture() {
3663        let raw = include_str!("../../../config/harness.example.toml");
3664        let config = HarnessConfig::from_toml(raw)
3665            .unwrap_or_else(|error| panic!("shipped example config must validate: {error}"));
3666        assert_eq!(config.listen, "127.0.0.1:8484", "example must bind loopback");
3667        assert!(!config.dashboard_enabled, "example must ship dashboard-off");
3668        assert_eq!(
3669            config.budget.max_tokens,
3670            Some(200_000),
3671            "example must bind a token budget"
3672        );
3673        assert_eq!(config.default_workflow, "signed");
3674        for (name, raw) in [
3675            (
3676                "container",
3677                include_str!("../../../config/harness.container.toml"),
3678            ),
3679            ("docker", include_str!("../../../config/harness.docker.toml")),
3680        ] {
3681            let config = HarnessConfig::from_toml(raw)
3682                .unwrap_or_else(|error| panic!("shipped {name} config must validate: {error}"));
3683            assert_eq!(
3684                config.default_workflow, "signed",
3685                "{name} config must opt into the signed workflow"
3686            );
3687        }
3688    }
3689
3690    /// Register pass 35 finding — the four shipped tool schemas under
3691    /// config/tool-schemas/ are the daemon's boot-time contract: at
3692    /// startup `load_sandbox` reads each `.json` file and parses it
3693    /// with `serde_json::from_slice`, using the file stem as the tool
3694    /// name. Any parse failure with default `tool_schema_dir =
3695    /// "config/tool-schemas"` errors out the daemon with
3696    /// "parse tool schema {path}: ..." — a CrashLoopBackOff on
3697    /// Kubernetes, an immediate exit on bare-metal.
3698    ///
3699    /// Only db_write.json was parse-tested before this pass (via
3700    /// `tests/sla.rs:33`, which include_str!s it). The other three —
3701    /// payout.json, deploy.json, merge.json — shipped and were
3702    /// referenced by config/harness.example.toml + the K8s ConfigMap,
3703    /// but nothing exercised them at CI time.
3704    #[test]
3705    fn shipped_tool_schemas_parse_as_json() {
3706        for (name, raw) in [
3707            (
3708                "payout.json",
3709                include_str!("../../../config/tool-schemas/payout.json"),
3710            ),
3711            (
3712                "db_write.json",
3713                include_str!("../../../config/tool-schemas/db_write.json"),
3714            ),
3715            (
3716                "deploy.json",
3717                include_str!("../../../config/tool-schemas/deploy.json"),
3718            ),
3719            (
3720                "merge.json",
3721                include_str!("../../../config/tool-schemas/merge.json"),
3722            ),
3723        ] {
3724            let value: serde_json::Value = serde_json::from_str(raw).unwrap_or_else(|error| {
3725                panic!(
3726                    "shipped tool schema {name} must parse as JSON — the daemon calls \
3727                     serde_json::from_slice on each .json file under tool_schema_dir at \
3728                     boot; a parse failure here CrashLoopBackOffs the pod. Error: {error}"
3729                )
3730            });
3731            // The schema must be an object at the top level — that is
3732            // what jsonschema treats as a "schema for a value"; a bare
3733            // array or scalar would compile as "any input matches",
3734            // silently gating nothing. Fail-closed at CI, not at
3735            // request time.
3736            assert!(
3737                value.is_object(),
3738                "shipped tool schema {name} must be a JSON object at the top level \
3739                 (JSON Schema convention); a bare array/scalar would silently accept \
3740                 every tool call"
3741            );
3742        }
3743    }
3744
3745    /// Register pass 34 finding — the K8s ConfigMap embeds a full
3746    /// TOML block for the daemon at deploy/kubernetes/agentvisor-ai.
3747    /// yaml. It ships with the shipped `.toml` files pinned above
3748    /// but the ConfigMap's OWN copy of the config was never
3749    /// parse-tested. If it drifts (typo in a field name, unknown
3750    /// key from a rebase, a rename in HarnessConfig with no matching
3751    /// yaml edit), `kubectl apply` succeeds — YAML syntax is fine —
3752    /// but every pod CrashLoopBackOffs on boot with the classic
3753    /// "unknown field `X`, expected one of ..." serde error. The
3754    /// operator debugging this can't tell the daemon from the config
3755    /// apart without shell access to the pod.
3756    ///
3757    /// This test extracts the embedded TOML at compile time,
3758    /// un-indents it (each line has a 4-space YAML block indent),
3759    /// runs it through the same strict loader `agentvisord` uses at
3760    /// boot, and pins the same safe-posture invariants as the
3761    /// shipped configs above.
3762    #[test]
3763    fn shipped_kubernetes_configmap_toml_parses_and_pins_safe_posture() {
3764        let yaml = include_str!("../../../deploy/kubernetes/agentvisor-ai.yaml");
3765        // Find the block scalar. The ConfigMap's `agentvisor.toml: |`
3766        // header opens a literal block; the block continues while
3767        // subsequent lines are indented at the block's level or
3768        // deeper (or blank). The next YAML-level key or `---`
3769        // separator ends it.
3770        let block_header = "agentvisor.toml: |";
3771        let block_start = yaml
3772            .find(block_header)
3773            .unwrap_or_else(|| panic!("K8s manifest must embed a `{block_header}` block"))
3774            + block_header.len();
3775        let after = &yaml[block_start..];
3776        let after = after.trim_start_matches('\n');
3777        // Every line of the block is indented at least 4 spaces
3778        // (see the ConfigMap in the manifest). Take lines until we
3779        // hit a non-blank line whose indent is smaller.
3780        let mut toml = String::new();
3781        for line in after.lines() {
3782            if line.is_empty() || line.trim().is_empty() {
3783                toml.push('\n');
3784                continue;
3785            }
3786            if let Some(stripped) = line.strip_prefix("    ") {
3787                toml.push_str(stripped);
3788                toml.push('\n');
3789            } else {
3790                break;
3791            }
3792        }
3793        assert!(
3794            toml.contains("config_version"),
3795            "extracted ConfigMap TOML looks empty; did the block indent change?"
3796        );
3797
3798        let config = HarnessConfig::from_toml(&toml).unwrap_or_else(|error| {
3799            panic!(
3800                "K8s ConfigMap agentvisor.toml block must parse and validate under \
3801                 the same strict loader agentvisord uses; a rename/typo here \
3802                 CrashLoopBackOffs every pod at boot. Error: {error}\n\n\
3803                 Extracted TOML:\n{toml}"
3804            )
3805        });
3806
3807        // Same safe-posture pins as the shipped configs. Adding the
3808        // K8s ConfigMap to the roster the register's item 4 concern
3809        // covers.
3810        assert_eq!(
3811            config.default_workflow, "signed",
3812            "K8s ConfigMap must opt into the signed workflow (register item 19); \
3813             the serde default `unsigned` silently mints zero receipts on clean \
3814             traffic"
3815        );
3816        assert!(
3817            !config.dashboard_enabled,
3818            "K8s ConfigMap must ship dashboard-off — the pod binds 0.0.0.0 for \
3819             cluster network reachability, so any workload with Service access \
3820             could otherwise enumerate sessions/costs/receipts (register item 4)"
3821        );
3822        assert!(config.require_identity, "K8s workloads must authenticate callers");
3823        assert!(
3824            !config.allow_wildcard_bind,
3825            "anonymous wildcard binding must not be enabled"
3826        );
3827        assert_eq!(config.audience, "agentvisor-ai");
3828        assert_eq!(
3829            config.identity_hmac_secret_file.as_deref(),
3830            Some("/etc/agentvisor-ai/identity.hmac")
3831        );
3832        assert_eq!(
3833            config.state_backend, "redis",
3834            "production revocations must survive restart"
3835        );
3836        assert_eq!(
3837            config.state_endpoint.as_deref(),
3838            Some("rediss://redis.example.invalid:6379")
3839        );
3840        assert!(yaml.contains("name: AV_STATE_ENDPOINT"));
3841        assert!(yaml.contains("name: agentvisor-ai-state"));
3842        assert!(yaml.contains("key: redis-url"));
3843    }
3844
3845    #[test]
3846    fn shipped_cloudfoundry_config_requires_identity_and_durable_revocations() {
3847        let config = HarnessConfig::from_toml(include_str!("../../../deploy/cloudfoundry/harness.toml"))
3848            .expect("Cloud Foundry configuration must validate");
3849        assert!(config.require_identity);
3850        assert!(!config.dashboard_enabled);
3851        assert!(!config.allow_wildcard_bind);
3852        assert_eq!(config.audience, "agentvisor-ai");
3853        assert_eq!(config.state_backend, "redis");
3854        assert_eq!(
3855            config.state_endpoint.as_deref(),
3856            Some("rediss://redis.example.invalid:6379")
3857        );
3858        let manifest = include_str!("../../../deploy/cloudfoundry/manifest.yml");
3859        assert!(manifest.contains("AV_STATE_ENDPOINT: ((redis_url))"));
3860    }
3861
3862    /// The K8s manifest ships a specific `terminationGracePeriodSeconds`
3863    /// that must cover the daemon's worst-case shutdown budget for the
3864    /// ConfigMap TOML it also ships. If the ConfigMap's
3865    /// `shutdown_drain_timeout_s` (or its derivation from
3866    /// `upstream_read_timeout_s`) pushes the total above the grace, a
3867    /// rolling deploy under a long-tail request SIGKILLs mid-finalize
3868    /// and drops receipts — the exact outcome the graceful-shutdown
3869    /// machinery exists to prevent. Compute both from the SAME
3870    /// manifest so a future edit that touches only one is caught here.
3871    #[test]
3872    fn shipped_kubernetes_configmap_total_shutdown_fits_termination_grace() {
3873        let yaml = include_str!("../../../deploy/kubernetes/agentvisor-ai.yaml");
3874        // Parse the ConfigMap TOML the same way the sibling test does.
3875        let block_header = "agentvisor.toml: |";
3876        let block_start = yaml.find(block_header).unwrap() + block_header.len();
3877        let after = yaml[block_start..].trim_start_matches('\n');
3878        let mut toml = String::new();
3879        for line in after.lines() {
3880            if line.trim().is_empty() {
3881                toml.push('\n');
3882                continue;
3883            }
3884            if let Some(stripped) = line.strip_prefix("    ") {
3885                toml.push_str(stripped);
3886                toml.push('\n');
3887            } else {
3888                break;
3889            }
3890        }
3891        let config = HarnessConfig::from_toml(&toml).unwrap();
3892        assert_total_shutdown_fits(
3893            "K8s manifest ConfigMap",
3894            &config,
3895            extract_yaml_int(yaml, "terminationGracePeriodSeconds:").unwrap(),
3896            // K8s runs preStop synchronously BEFORE SIGTERM and both
3897            // count against the grace period. If the manifest has a
3898            // preStop hook, its sleep MUST be parseable — a silent
3899            // `unwrap_or(0)` here defeats the pin whenever the hook is
3900            // reformatted (YAML block form, single-quoted strings, a
3901            // mechanism other than `sleep`), which is exactly the drift
3902            // class the test exists to catch.
3903            require_prestop_sleep_seconds(yaml),
3904        );
3905    }
3906
3907    /// docker-compose.yml + config/harness.docker.toml pair (the
3908    /// hardened stack). Same drift class as the K8s test above: a
3909    /// tightening of `stop_grace_period` (or a bump in
3910    /// `shutdown_drain_timeout_s`) that individually looks safe can
3911    /// push the sum over the grace and silently start dropping
3912    /// receipts on `docker compose down`.
3913    #[test]
3914    fn shipped_docker_compose_stop_grace_fits_harness_docker_shutdown() {
3915        let compose = include_str!("../../../docker/docker-compose.yml");
3916        let toml = include_str!("../../../config/harness.docker.toml");
3917        let config = HarnessConfig::from_toml(toml).unwrap();
3918        assert_total_shutdown_fits(
3919            "docker-compose.yml / harness.docker.toml",
3920            &config,
3921            extract_yaml_grace_seconds(compose, "stop_grace_period:").unwrap(),
3922            // Docker compose has no preStop equivalent.
3923            0,
3924        );
3925    }
3926
3927    #[test]
3928    fn shipped_full_docker_config_disables_unauthenticated_dashboard() {
3929        let config = HarnessConfig::from_toml(include_str!("../../../config/harness.docker.toml")).unwrap();
3930        assert!(config.require_identity, "full-stack callers must authenticate");
3931        assert!(
3932            config.enforce_identity_scopes,
3933            "full-stack callers need operation scopes"
3934        );
3935        assert!(
3936            !config.dashboard_enabled,
3937            "Compose peers can reach the listener without the host loopback port mapping"
3938        );
3939        assert!(
3940            !config.allow_wildcard_bind,
3941            "the authenticated full stack must not bypass the wildcard safety guards"
3942        );
3943    }
3944
3945    /// docker-compose.minimal.yml + config/harness.container.toml pair
3946    /// (the evaluator on-ramp). Advertised in the README as the
3947    /// zero-friction try-it flow, so an unclean `docker stop` here is
3948    /// exactly the shape a prospect encounters when they Ctrl-C.
3949    #[test]
3950    fn shipped_docker_compose_minimal_stop_grace_fits_container_shutdown() {
3951        let compose = include_str!("../../../docker/docker-compose.minimal.yml");
3952        let toml = include_str!("../../../config/harness.container.toml");
3953        let config = HarnessConfig::from_toml(toml).unwrap();
3954        assert_total_shutdown_fits(
3955            "docker-compose.minimal.yml / harness.container.toml",
3956            &config,
3957            extract_yaml_grace_seconds(compose, "stop_grace_period:").unwrap(),
3958            0,
3959        );
3960    }
3961
3962    /// Shared budget check. The four shutdown phases in finish_shutdown
3963    /// run sequentially, so the worst-case wall time is the sum of
3964    /// each phase's cap plus any pre-drain wall time the deployment
3965    /// requires BEFORE the phases start.
3966    ///   1. HTTP drain: `effective_drain_timeout()` (config-derived).
3967    ///   2. Worker wait_idle: [`WORKER_FINALIZE_PHASE_SECS`] — imported
3968    ///      from the same source of truth `main.rs`'s `finish_shutdown`
3969    ///      call site uses, so a bump there without matching bumps in
3970    ///      the deployment manifests trips this test at CI time.
3971    ///   3. finalize_sessions: same [`WORKER_FINALIZE_PHASE_SECS`].
3972    ///   4. OTel telemetry flush: [`OTEL_FLUSH_SECS`] when the `otel`
3973    ///      feature is on (which it is in the default `--features full`
3974    ///      container image the shipped configs assume).
3975    ///   5. Pre-drain wall time: `shutdown_ready_drain_s` counts
3976    ///      against the grace period BEFORE the drain starts (see the
3977    ///      field's own doc comment), and a K8s `preStop` hook counts
3978    ///      against the grace period BEFORE SIGTERM is even delivered.
3979    fn assert_total_shutdown_fits(
3980        label: &str,
3981        config: &HarnessConfig,
3982        grace_seconds: u64,
3983        pre_signal_seconds: u64,
3984    ) {
3985        let drain = config.effective_drain_timeout().as_secs();
3986        let total = drain
3987            .saturating_add(WORKER_FINALIZE_PHASE_SECS)
3988            .saturating_add(WORKER_FINALIZE_PHASE_SECS)
3989            .saturating_add(OTEL_FLUSH_SECS)
3990            .saturating_add(config.shutdown_ready_drain_s)
3991            .saturating_add(pre_signal_seconds);
3992        assert!(
3993            total <= grace_seconds,
3994            "{label}: worst-case shutdown budget {total}s (drain {drain}s + \
3995             worker {WORKER_FINALIZE_PHASE_SECS}s + finalize {WORKER_FINALIZE_PHASE_SECS}s + \
3996             OTel {OTEL_FLUSH_SECS}s + shutdown_ready_drain_s {}s + pre-signal {}s) \
3997             exceeds the grace period {grace_seconds}s — \
3998             kubelet/dockerd will SIGKILL mid-finalize and drop receipts on \
3999             rolling deploy. Either lower shutdown_drain_timeout_s / \
4000             shutdown_ready_drain_s / the preStop sleep in the config or raise \
4001             the grace period in the deployment manifest.",
4002            config.shutdown_ready_drain_s,
4003            pre_signal_seconds,
4004        );
4005    }
4006
4007    fn extract_yaml_int(yaml: &str, key: &str) -> Option<u64> {
4008        for line in yaml.lines() {
4009            let trimmed = line.trim_start();
4010            if let Some(rest) = trimmed.strip_prefix(key) {
4011                return rest.trim().parse().ok();
4012            }
4013        }
4014        None
4015    }
4016
4017    /// Parses the shipped K8s manifest's `preStop.exec.command`
4018    /// `sleep <n>` argument, accepting the three shapes YAML idiomatically
4019    /// admits so a cosmetic reformat can't defeat the pin:
4020    ///   1. inline flow list, double-quoted: `command: ["sh", "-c", "sleep 5"]`
4021    ///   2. inline flow list, single-quoted: `command: ['sh', '-c', 'sleep 5']`
4022    ///   3. block-scalar list — used elsewhere in the same manifest:
4023    ///      ```yaml
4024    ///      command:
4025    ///        - sh
4026    ///        - -c
4027    ///        - sleep 5
4028    ///      ```
4029    /// Returns `None` when there is no preStop hook at all (no `preStop:`
4030    /// key). Returns `None` when a preStop DOES exist but the shape is
4031    /// unrecognized — the caller must panic on that so the drift is
4032    /// caught at build time, not papered over with `unwrap_or(0)`.
4033    fn extract_prestop_sleep_seconds(yaml: &str) -> Option<u64> {
4034        // Locate the FIRST non-comment `preStop:` occurrence. A future
4035        // manifest edit that adds `# don't use preStop:` above the real
4036        // hook would otherwise anchor our scope to the comment and
4037        // trawl the rest of the file for any `sleep`.
4038        let (prestop_line_idx, prestop_indent) = yaml.lines().enumerate().find_map(|(i, line)| {
4039            let trimmed = line.trim_start();
4040            if trimmed.starts_with('#') {
4041                return None;
4042            }
4043            if trimmed.starts_with("preStop:") {
4044                Some((i, line.len() - trimmed.len()))
4045            } else {
4046                None
4047            }
4048        })?;
4049        // Scope: from the preStop line to the FIRST line whose indent
4050        // is ≤ the preStop line's indent (that line starts a sibling
4051        // key, ending the preStop block). Blank lines and comments
4052        // don't end the block. The previous heuristic (`\nresources:`)
4053        // never fired in real manifests where `resources:` is
4054        // indented, so the parser trawled the whole rest of the file
4055        // and returned `sleep` values from unrelated containers /
4056        // probes / sidecars. Verified by the R8 review's mutation
4057        // cases: block-form preStop `sleep 60` + downstream container
4058        // `command: ["sh","-c","sleep 3"]` used to return 3, not 60.
4059        let mut scope = String::new();
4060        for (i, line) in yaml.lines().enumerate().skip(prestop_line_idx) {
4061            if i == prestop_line_idx {
4062                scope.push_str(line);
4063                scope.push('\n');
4064                continue;
4065            }
4066            let trimmed = line.trim_start();
4067            if trimmed.is_empty() || trimmed.starts_with('#') {
4068                scope.push_str(line);
4069                scope.push('\n');
4070                continue;
4071            }
4072            let indent = line.len() - trimmed.len();
4073            if indent <= prestop_indent {
4074                break;
4075            }
4076            scope.push_str(line);
4077            scope.push('\n');
4078        }
4079        // Shape 1 & 2: same-line inline list. Look for either
4080        // "sleep <n>" or 'sleep <n>' anywhere in the scoped block.
4081        for quote in ['"', '\''] {
4082            let marker = format!("{quote}sleep ");
4083            if let Some(hit) = scope.find(&marker) {
4084                let rest = &scope[hit + marker.len()..];
4085                let n: String = rest.chars().take_while(char::is_ascii_digit).collect();
4086                if let Ok(seconds) = n.parse() {
4087                    return Some(seconds);
4088                }
4089            }
4090        }
4091        // Shape 3: block-scalar list. A line whose non-indent content
4092        // is `- sleep <n>`. YAML also allows `- sh` / `- -c` / `- sleep 5`
4093        // on separate lines; we only need the sleep line.
4094        for line in scope.lines() {
4095            let stripped = line.trim_start().trim_start_matches('-').trim();
4096            if let Some(rest) = stripped.strip_prefix("sleep ") {
4097                let n: String = rest.chars().take_while(char::is_ascii_digit).collect();
4098                if let Ok(seconds) = n.parse() {
4099                    return Some(seconds);
4100                }
4101            }
4102        }
4103        None
4104    }
4105
4106    /// Caller-side companion for [`extract_prestop_sleep_seconds`]: a
4107    /// shipped manifest with a `preStop:` hook MUST have a parseable
4108    /// sleep, or the pin cannot compute the grace budget accurately
4109    /// and silently green-lights a hook whose wall time it ignores.
4110    /// The R7 review agent caught this exact class: a YAML block-form
4111    /// reformat + `sleep 60` bump pushed total shutdown to 190 s
4112    /// against the 150 s grace, and the previous `.unwrap_or(0)` let
4113    /// the test pass.
4114    fn require_prestop_sleep_seconds(yaml: &str) -> u64 {
4115        // Only count a REAL preStop line (`preStop:` at the start of a
4116        // non-comment line), not the substring inside a comment like
4117        // `# don't use preStop:`. Same class of bug the scope-cut
4118        // rewrite fixes below.
4119        let has_prestop = yaml.lines().any(|line| {
4120            let trimmed = line.trim_start();
4121            !trimmed.starts_with('#') && trimmed.starts_with("preStop:")
4122        });
4123        if !has_prestop {
4124            return 0;
4125        }
4126        extract_prestop_sleep_seconds(yaml).expect(
4127            "shipped K8s manifest has a preStop hook but extract_prestop_sleep_seconds \
4128             could not parse its sleep duration — either update the parser to handle \
4129             the new shape (inline vs block YAML, quoting style), or if preStop no \
4130             longer uses `sleep`, delete this test and its helper. Silently returning \
4131             0 would defeat the pin's own purpose: catching a preStop-time bump that \
4132             pushes total shutdown past the grace period.",
4133        )
4134    }
4135
4136    /// Direct regression tests for the parser — pinning the exact
4137    /// mutation cases the R8 review agent proved defeated the previous
4138    /// scope-cut heuristic. If these ever regress, the shipped
4139    /// K8s pin above no longer measures what its comment claims.
4140    #[test]
4141    fn extract_prestop_scope_ignores_downstream_sleep_after_the_block() {
4142        // Block-form preStop `sleep 60` in one container followed by
4143        // an unrelated inline container command with `"sleep 3"` in a
4144        // real sibling later in the YAML. Previous version returned
4145        // 3 (first inline match anywhere after preStop:), silently
4146        // making the pin under-count preStop time by 57 s.
4147        let yaml = "\
4148apiVersion: apps/v1
4149kind: Deployment
4150spec:
4151  template:
4152    spec:
4153      containers:
4154      - name: main
4155        lifecycle:
4156          preStop:
4157            exec:
4158              command:
4159                - sh
4160                - -c
4161                - sleep 60
4162        resources:
4163          limits:
4164            cpu: \"2\"
4165      - name: sidecar
4166        command: [\"sh\", \"-c\", \"sleep 3\"]
4167";
4168        assert_eq!(
4169            extract_prestop_sleep_seconds(yaml),
4170            Some(60),
4171            "the parser must scope to the preStop block and ignore \
4172             downstream containers' inline sleep commands"
4173        );
4174    }
4175
4176    #[test]
4177    fn extract_prestop_scope_ignores_downstream_probe_sleep() {
4178        // Inline preStop `sleep 3` next to a downstream livenessProbe
4179        // exec `command: ["sh", "-c", "sleep 300"]`. The key
4180        // differentiator is quote-style ordering: Shape-1 scanning
4181        // walks `['"', '\'']` and returns on the first hit, so a
4182        // single-quoted preStop + double-quoted probe forces the
4183        // old (unscoped) parser to hit the probe's `"sleep 300"`
4184        // FIRST — returning 300 → pin FALSELY reported "grace
4185        // exceeded" for a config that was actually fine. With the
4186        // indent-scoped scan, only the preStop's `'sleep 3'` is in
4187        // range and the answer is 3. Reviewer note: an earlier
4188        // version of this test used matching double quotes for both
4189        // hooks, which the OLD parser also happened to return 3 for
4190        // (double-quote scan hits the preStop's `"sleep 3"` first),
4191        // making the test hollow — it asserted the same value both
4192        // implementations produced.
4193        let yaml = "\
4194spec:
4195  containers:
4196  - name: main
4197    lifecycle:
4198      preStop:
4199        exec:
4200          command: ['sh', '-c', 'sleep 3']
4201    livenessProbe:
4202      exec:
4203        command: [\"sh\", \"-c\", \"sleep 300\"]
4204";
4205        assert_eq!(extract_prestop_sleep_seconds(yaml), Some(3));
4206    }
4207
4208    #[test]
4209    fn require_prestop_skips_commented_out_prestop_line() {
4210        // A YAML comment mentioning preStop must not fool the parser
4211        // into thinking a hook exists — nor into returning an
4212        // unrelated sleep from a downstream probe.
4213        let yaml = "\
4214spec:
4215  containers:
4216  - name: main
4217    # NOTE: do not add preStop: to this container, see runbook.
4218    livenessProbe:
4219      exec:
4220        command: [\"sh\", \"-c\", \"sleep 90\"]
4221";
4222        assert_eq!(
4223            require_prestop_sleep_seconds(yaml),
4224            0,
4225            "a comment mentioning preStop: is NOT a real hook; require_prestop \
4226             must return 0, not the downstream probe's sleep"
4227        );
4228    }
4229
4230    /// Parses `stop_grace_period: 180s` (compose accepts a `s`/`m`/`h`
4231    /// suffix). We only ship an `s` suffix, so a bare int and `<n>s`
4232    /// are the two shapes to accept.
4233    fn extract_yaml_grace_seconds(yaml: &str, key: &str) -> Option<u64> {
4234        for line in yaml.lines() {
4235            let trimmed = line.trim_start();
4236            if let Some(rest) = trimmed.strip_prefix(key) {
4237                let raw = rest.trim().trim_end_matches('s');
4238                return raw.parse().ok();
4239            }
4240        }
4241        None
4242    }
4243
4244    /// deploy/systemd/agentvisor-ai.service + the `avctl init` default
4245    /// config shape (which writes neither `upstream_read_timeout_s` nor
4246    /// `shutdown_drain_timeout_s`, so the config-derived drain phase is
4247    /// max(30, 60+5) = 65 s). The unit shipped 125 s against a 130 s
4248    /// worst case for years because its comment assumed a 30 s drain —
4249    /// `systemctl restart` SIGKILLed mid-finalize on any deploy whose
4250    /// drain actually ran long. Same class as the compose/k8s pins
4251    /// above; systemd was the one unmanaged sibling.
4252    #[test]
4253    fn shipped_systemd_unit_stop_timeout_fits_default_shutdown() {
4254        let unit = include_str!("../../../deploy/systemd/agentvisor-ai.service");
4255        let stop_timeout = unit
4256            .lines()
4257            .find_map(|line| line.trim().strip_prefix("TimeoutStopSec="))
4258            .and_then(|raw| raw.trim().trim_end_matches('s').parse::<u64>().ok())
4259            .expect("unit file must set TimeoutStopSec explicitly");
4260        // Default-shaped config: only the keys `avctl init` always writes.
4261        let config =
4262            HarnessConfig::from_toml("config_version = 1\nupstream_url = \"https://api.openai.com\"")
4263                .unwrap();
4264        assert_total_shutdown_fits(
4265            "deploy/systemd/agentvisor-ai.service / avctl-init defaults",
4266            &config,
4267            stop_timeout,
4268            // systemd has no preStop equivalent; SIGTERM is delivered
4269            // immediately at `systemctl stop`.
4270            0,
4271        );
4272    }
4273
4274    /// `tool_upstream_url = ""` used to pass validation while runtime
4275    /// routing gates tool forwarding on `is_some()` — the empty string
4276    /// silently enabled the tool-upstream branches and only failed at the
4277    /// first request with a reqwest URL error. Reject it at startup.
4278    #[test]
4279    fn empty_tool_upstream_url_is_rejected() {
4280        let err =
4281            HarnessConfig::from_toml("upstream_url = \"https://api\"\ntool_upstream_url = \"\"").unwrap_err();
4282        assert!(err.contains("tool_upstream_url"), "{err}");
4283        assert!(err.contains("omit"), "should point at omitting the field: {err}");
4284        assert!(HarnessConfig::from_toml(
4285            "upstream_url = \"https://api\"\ntool_upstream_url = \"http://tools/mcp\"",
4286        )
4287        .is_ok());
4288    }
4289
4290    /// `payout_field = ""` silently disabled the payout cap: the empty
4291    /// string matches no argument key, `extract_payout_micros` returned
4292    /// 0, and `ActionBudget::try_tool_call` skipped the payout dimension
4293    /// entirely — including the fail-closed "unbounded payout must
4294    /// never spend" refusal. Every peer field in the same class
4295    /// (atif_spool_dir, bridge_data_dir, upstream_auth_header, …) is
4296    /// checked non-empty; this was the outlier. Refuse at startup.
4297    /// Whitespace, control, non-ASCII, and invisible-format (BOM,
4298    /// zero-width space) variants are the same class — extremely
4299    /// unlikely to match a real tool's JSON key and almost certainly
4300    /// a paste typo from a rich-editor or Windows/UTF-8 signature.
4301    /// `is_ascii_graphic()` covers all of these in one check.
4302    #[test]
4303    fn empty_or_whitespace_payout_field_is_rejected() {
4304        for hostile in [
4305            "",
4306            " ",
4307            "  ",
4308            "\t",
4309            " amount_usd",
4310            "amount_usd ",
4311            // Invisible-format class — the exact one browser paste
4312            // injects and that `trim()` misses because Unicode's
4313            // White_Space property does not cover these.
4314            "\u{FEFF}amount_usd", // BOM at start (UTF-8 signature)
4315            "amount_usd\u{FEFF}", // BOM at end
4316            "\u{200B}amount_usd", // zero-width space
4317            "amount\u{200B}_usd", // zero-width space in the middle
4318            "\u{2060}amount_usd", // word-joiner
4319            "amount_usd\u{00A0}", // NBSP (a Unicode whitespace)
4320            // Non-ASCII: smart quotes / accents / emoji / RTL text.
4321            "amount_usd\u{201D}", // right double-quote
4322            "amóunt_usd",         // accented Latin
4323        ] {
4324            // Interpolate the RAW hostile bytes into the TOML — not
4325            // `{:?}` Debug format, which emits Rust's `\u{feff}`
4326            // escape that TOML doesn't accept (only `\uXXXX` /
4327            // `\UXXXXXXXX`, no braces). The prior version parse-
4328            // failed at the toml crate for every invisible-format
4329            // case, so the payout_field check never actually ran on
4330            // the class the fix targets: the R12 review agent proved
4331            // that reverting the check to R11's `trim()`-based
4332            // predicate still passed 6 of the 8 test cases.
4333            //
4334            // The `[` / `]` / `{` / `}` / `#` in `hostile` cannot
4335            // appear here (all our fixtures are payout-key shapes),
4336            // and TOML basic strings interpret embedded `\` and `"`
4337            // — but our fixtures contain neither. Raw interpolation
4338            // is therefore safe AND correct for THIS test.
4339            let toml = format!("upstream_url = \"https://api.openai.com\"\npayout_field = \"{hostile}\"\n");
4340            let err = match HarnessConfig::from_toml(&toml) {
4341                Err(e) => e,
4342                Ok(_) => panic!("must refuse payout_field = {hostile:?}"),
4343            };
4344            assert!(err.contains("payout_field"), "{hostile:?} => {err}");
4345        }
4346        // The default (omitted key) is fine.
4347        assert!(HarnessConfig::from_toml(r#"upstream_url = "https://api.openai.com""#).is_ok());
4348        // An explicit clean name is fine.
4349        assert!(HarnessConfig::from_toml(
4350            r#"upstream_url = "https://api.openai.com"
4351               payout_field = "amount_usd""#,
4352        )
4353        .is_ok());
4354    }
4355
4356    /// The hostless preflight `upstream_url` gets must cover
4357    /// `tool_upstream_url` too: `tool_upstream_url = "http://"` passed
4358    /// validation, booted, then failed EVERY tool call at request-build
4359    /// time — with claimed executions churning TOOL_OUTCOME_UNCERTAIN.
4360    #[test]
4361    fn hostless_tool_upstream_url_is_rejected() {
4362        for url in ["http://", "https:///mcp", "http://?x=1"] {
4363            let err = HarnessConfig::from_toml(&format!(
4364                "upstream_url = \"https://api\"\ntool_upstream_url = \"{url}\""
4365            ))
4366            .unwrap_err();
4367            assert!(
4368                err.contains("tool_upstream_url") && err.contains("no host"),
4369                "{url}: {err}"
4370            );
4371        }
4372    }
4373
4374    /// Request paths are CONCATENATED onto `upstream_url`
4375    /// (`format!("{base}/v1/chat/completions")`), so a base carrying a
4376    /// query or fragment silently swallows the path: `http://h/#x` +
4377    /// `/v1/...` targets `/` with everything after `#` treated as a
4378    /// fragment. That misconfiguration used to validate + boot and
4379    /// only misbehave per-request. `tool_upstream_url` stays exempt:
4380    /// it is posted WHOLE, so a query there is legitimate.
4381    #[test]
4382    fn upstream_url_rejects_query_and_fragment() {
4383        for url in ["http://127.0.0.1:9000/#oops", "https://api.example.com/?tenant=1"] {
4384            let err = HarnessConfig::from_toml(&format!("upstream_url = \"{url}\"")).unwrap_err();
4385            assert!(
4386                err.contains("upstream_url") && err.contains("query or fragment"),
4387                "{url}: {err}"
4388            );
4389        }
4390        // A query on tool_upstream_url (posted whole) stays legal.
4391        HarnessConfig::from_toml(
4392            "upstream_url = \"https://api\"\ntool_upstream_url = \"http://tools/mcp?fmt=json\"",
4393        )
4394        .unwrap();
4395    }
4396
4397    /// `require_identity = true` gates the proxy API but NOT the
4398    /// dashboard routes — a wildcard bind with the dashboard on hands
4399    /// any peer the session list and complete receipts. Same explicit
4400    /// `allow_wildcard_bind` acknowledgment as the anonymous-proxy
4401    /// guard; loopback binds and dashboard-off stay untouched.
4402    #[test]
4403    fn wildcard_bind_with_dashboard_requires_explicit_acknowledgment() {
4404        let err = HarnessConfig::from_toml(
4405            r#"upstream_url = "https://api"
4406               listen = "0.0.0.0:8484"
4407               require_identity = true
4408               identity_hmac_secret_file = "/etc/av/hmac"
4409               dashboard_enabled = true"#,
4410        )
4411        .unwrap_err();
4412        assert!(
4413            err.contains("dashboard") && err.contains("every interface"),
4414            "wildcard + dashboard must refuse: {err}"
4415        );
4416        // Explicit acknowledgment or dashboard-off both boot.
4417        HarnessConfig::from_toml(
4418            r#"upstream_url = "https://api"
4419               listen = "0.0.0.0:8484"
4420               require_identity = true
4421               identity_hmac_secret_file = "/etc/av/hmac"
4422               dashboard_enabled = true
4423               allow_wildcard_bind = true"#,
4424        )
4425        .unwrap();
4426        HarnessConfig::from_toml(
4427            r#"upstream_url = "https://api"
4428               listen = "0.0.0.0:8484"
4429               require_identity = true
4430               identity_hmac_secret_file = "/etc/av/hmac"
4431               dashboard_enabled = false"#,
4432        )
4433        .unwrap();
4434    }
4435
4436    /// A seconds interval > 1 day is almost certainly a unit-conversion
4437    /// error (someone thought the field was in milliseconds).
4438    #[test]
4439    fn seconds_intervals_reject_absurdly_large_values() {
4440        let err = HarnessConfig::from_toml(&format!(
4441            "upstream_url = \"https://api\"\nsession_idle_close_s = {}",
4442            MAX_SECONDS_INTERVAL + 1
4443        ))
4444        .unwrap_err();
4445        assert!(
4446            err.contains("session_idle_close_s"),
4447            "err should name the offending field: {err}"
4448        );
4449        assert!(err.contains("milliseconds"), "hint should mention ms: {err}");
4450    }
4451
4452    /// `upstream_read_timeout_s = 0` would make every upstream request
4453    /// time out before its first byte; reject it with a pointer at the
4454    /// omit-for-default posture.
4455    #[test]
4456    fn upstream_read_timeout_rejects_zero() {
4457        let err = HarnessConfig::from_toml(
4458            r#"upstream_url = "https://api"
4459               upstream_read_timeout_s = 0"#,
4460        )
4461        .unwrap_err();
4462        assert!(
4463            err.contains("upstream_read_timeout_s"),
4464            "err should name the offending field: {err}"
4465        );
4466        assert!(err.contains("omit"), "err should point at the default: {err}");
4467        // Any positive value under the 1-day cap remains valid.
4468        assert!(HarnessConfig::from_toml(
4469            r#"upstream_url = "https://api"
4470               upstream_read_timeout_s = 1"#,
4471        )
4472        .is_ok());
4473    }
4474
4475    #[test]
4476    fn mcp_request_timeout_is_positive_and_bounded() {
4477        let default = HarnessConfig::from_toml(r#"upstream_url = "https://api""#).unwrap();
4478        assert_eq!(
4479            default
4480                .mcp_request_timeout_s
4481                .unwrap_or(DEFAULT_MCP_REQUEST_TIMEOUT_S),
4482            60
4483        );
4484        for invalid in [0, MAX_SECONDS_INTERVAL + 1] {
4485            let error = HarnessConfig::from_toml(&format!(
4486                "upstream_url = \"https://api\"\nmcp_request_timeout_s = {invalid}"
4487            ))
4488            .unwrap_err();
4489            assert!(error.contains("mcp_request_timeout_s"), "{error}");
4490        }
4491        for valid in [1, DEFAULT_MCP_REQUEST_TIMEOUT_S, MAX_SECONDS_INTERVAL] {
4492            let config = HarnessConfig::from_toml(&format!(
4493                "upstream_url = \"https://api\"\nmcp_request_timeout_s = {valid}"
4494            ))
4495            .unwrap();
4496            assert_eq!(config.mcp_request_timeout_s, Some(valid));
4497        }
4498    }
4499
4500    /// Refuse `enforce_identity_scopes = true` while
4501    /// `require_identity = false`. The combo silently falls through
4502    /// to the anonymous identity on unauthenticated requests,
4503    /// making the scope config a fig leaf.
4504    #[test]
4505    fn round_30_f1_scope_enforcement_requires_identity_requirement() {
4506        let err = HarnessConfig::from_toml(
4507            r#"upstream_url = "https://api.openai.com"
4508               require_identity = false
4509               enforce_identity_scopes = true"#,
4510        )
4511        .unwrap_err();
4512        assert!(
4513            err.contains("enforce_identity_scopes"),
4514            "err should name the flag: {err}"
4515        );
4516        assert!(
4517            err.contains("require_identity"),
4518            "err should name the correlate: {err}"
4519        );
4520        // Both `false` = clean dev posture, still passes.
4521        assert!(HarnessConfig::from_toml(
4522            r#"upstream_url = "https://api.openai.com"
4523               require_identity = false
4524               enforce_identity_scopes = false"#,
4525        )
4526        .is_ok());
4527    }
4528
4529    /// Shutdown derives from both network timeout settings. Read-idle
4530    /// padding does not promise that a whole chat stream can finish.
4531    #[test]
4532    fn drain_timeout_derives_from_upstream_read_timeout() {
4533        let base = HarnessConfig::for_tests("http://127.0.0.1:9", "/tmp", "/tmp");
4534        assert_eq!(base.effective_drain_timeout().as_secs(), 65);
4535        let mut derived = base.clone();
4536        derived.upstream_read_timeout_s = Some(300);
4537        assert_eq!(derived.effective_drain_timeout().as_secs(), 305);
4538        derived.mcp_request_timeout_s = Some(600);
4539        assert_eq!(derived.effective_drain_timeout().as_secs(), 605);
4540        // A short read-idle setting must not shrink the MCP deadline.
4541        let mut short = base.clone();
4542        short.upstream_read_timeout_s = Some(10);
4543        assert_eq!(short.effective_drain_timeout().as_secs(), 65);
4544        short.mcp_request_timeout_s = Some(10);
4545        assert_eq!(short.effective_drain_timeout().as_secs(), 30);
4546        // An operator-supplied shutdown limit still takes precedence.
4547        let mut explicit = derived;
4548        explicit.shutdown_drain_timeout_s = Some(110);
4549        assert_eq!(explicit.effective_drain_timeout().as_secs(), 110);
4550        let mut zero = base;
4551        zero.shutdown_drain_timeout_s = Some(0);
4552        assert!(zero.validate().unwrap_err().contains("shutdown_drain_timeout_s"));
4553    }
4554
4555    /// Refuse a wildcard bind (`0.0.0.0` / `[::]`) when identity is off
4556    /// unless an operator has explicitly acknowledged that an outer network
4557    /// layer controls exposure via `allow_wildcard_bind = true`. This
4558    /// prevents an unauthenticated proxy from silently reaching a
4559    /// developer's corporate LAN because a checked-in example config
4560    /// happened to bind wildcards.
4561    #[test]
4562    fn wildcard_bind_without_identity_is_refused_unless_explicitly_allowed() {
4563        // 0.0.0.0 with require_identity=false and no opt-in → refuse.
4564        let err = HarnessConfig::from_toml(
4565            r#"upstream_url = "https://api.openai.com"
4566               listen = "0.0.0.0:8484"
4567               require_identity = false"#,
4568        )
4569        .unwrap_err();
4570        assert!(err.contains("every interface"), "err should name the risk: {err}");
4571        assert!(
4572            err.contains("allow_wildcard_bind"),
4573            "err should name the escape hatch: {err}"
4574        );
4575
4576        // IPv6 wildcard likewise refused.
4577        let err = HarnessConfig::from_toml(
4578            r#"upstream_url = "https://api.openai.com"
4579               listen = "[::]:8484"
4580               require_identity = false"#,
4581        )
4582        .unwrap_err();
4583        assert!(err.contains("every interface"), "IPv6 wildcard: {err}");
4584
4585        // Non-canonical spellings of the unspecified address are the
4586        // same all-interfaces bind and must be refused too — the prior
4587        // literal string match (`"0.0.0.0" | "::"`) waved these through.
4588        for listen in [
4589            "[::0]:8484",
4590            "[0:0:0:0:0:0:0:0]:8484",
4591            "[0000::]:8484",
4592            // v4-mapped zero: parses as a non-unspecified Ipv6Addr but
4593            // binds INADDR_ANY — needs to_canonical() to classify.
4594            "[::ffff:0.0.0.0]:8484",
4595            "[::ffff:0:0]:8484",
4596        ] {
4597            let err = HarnessConfig::from_toml(&format!(
4598                r#"upstream_url = "https://api.openai.com"
4599                   listen = "{listen}"
4600                   require_identity = false"#,
4601            ))
4602            .unwrap_err();
4603            assert!(
4604                err.contains("every interface"),
4605                "non-canonical wildcard {listen}: {err}"
4606            );
4607        }
4608
4609        // Loopback remains legal.
4610        HarnessConfig::from_toml(
4611            r#"upstream_url = "https://api.openai.com"
4612               listen = "127.0.0.1:8484"
4613               require_identity = false"#,
4614        )
4615        .unwrap()
4616        .validate()
4617        .unwrap();
4618
4619        // Explicit opt-in (container / service-mesh deployments) passes.
4620        HarnessConfig::from_toml(
4621            r#"upstream_url = "https://api.openai.com"
4622               listen = "0.0.0.0:8484"
4623               require_identity = false
4624               allow_wildcard_bind = true"#,
4625        )
4626        .unwrap()
4627        .validate()
4628        .unwrap();
4629
4630        // require_identity=true with a JWKS / HMAC source likewise
4631        // passes — headless (the default-on dashboard has no identity
4632        // gate, so a wildcard bind additionally requires
4633        // dashboard_enabled = false or the allow_wildcard_bind
4634        // acknowledgment; see
4635        // wildcard_bind_with_dashboard_requires_explicit_acknowledgment).
4636        HarnessConfig::from_toml(
4637            r#"upstream_url = "https://api.openai.com"
4638               listen = "0.0.0.0:8484"
4639               require_identity = true
4640               dashboard_enabled = false
4641               identity_jwks_url = "https://idp/keys.json""#,
4642        )
4643        .unwrap()
4644        .validate()
4645        .unwrap();
4646    }
4647
4648    /// Refuse URL fields that omit the scheme or use a
4649    /// scheme the client library will not accept. Preflight beats a
4650    /// runtime failure after the process is already serving traffic.
4651    #[test]
4652    fn round_30_f2_url_scheme_allowlist_enforced() {
4653        // JWKS URL: missing scheme.
4654        let err = HarnessConfig::from_toml(
4655            r#"upstream_url = "https://api"
4656               identity_jwks_url = "auth.internal/jwks""#,
4657        )
4658        .unwrap_err();
4659        assert!(err.contains("identity_jwks_url"), "{err}");
4660        assert!(err.contains("http://") || err.contains("https://"), "{err}");
4661        // JWKS URL: file scheme is a config-injection surface.
4662        let err = HarnessConfig::from_toml(
4663            r#"upstream_url = "https://api"
4664               identity_jwks_url = "file:///etc/passwd""#,
4665        )
4666        .unwrap_err();
4667        assert!(err.contains("identity_jwks_url"), "{err}");
4668        // Redis state_endpoint: bad scheme.
4669        let err = HarnessConfig::from_toml(
4670            r#"upstream_url = "https://api"
4671               state_backend = "redis"
4672               state_endpoint = "http://cache""#,
4673        )
4674        .unwrap_err();
4675        assert!(err.contains("state_endpoint"), "{err}");
4676        // Redis state_endpoint cluster list — one bad
4677        // member must fail validation even if the FIRST member has
4678        // an allowed scheme (the prior prefix-on-whole-string check
4679        // passed on any list beginning with `redis://`).
4680        let err = HarnessConfig::from_toml(
4681            r#"upstream_url = "https://api"
4682               state_backend = "redis"
4683               state_endpoint = "redis://a:6379,http://b:6379""#,
4684        )
4685        .unwrap_err();
4686        assert!(err.contains("state_endpoint"), "{err}");
4687        // Valid cluster list of two Redis URLs passes.
4688        HarnessConfig::from_toml(
4689            r#"upstream_url = "https://api"
4690               state_backend = "redis"
4691               state_endpoint = "rediss://a:6379,rediss://b:6380""#,
4692        )
4693        .unwrap();
4694        for endpoint in [
4695            "redis://a:6379,rediss://b:6380",
4696            "rediss://a:6379/#insecure",
4697            "redis://a:6379,",
4698            "",
4699            "http://user:fixture-secret@cache:6379",
4700        ] {
4701            let err = HarnessConfig::from_toml(&format!(
4702                "upstream_url = \"https://api\"\nstate_backend = \"redis\"\nstate_endpoint = {endpoint:?}"
4703            ))
4704            .unwrap_err();
4705            assert!(err.contains("state_endpoint"), "{err}");
4706            assert!(
4707                !err.contains("fixture-secret"),
4708                "configuration errors must not expose credentials"
4709            );
4710        }
4711        // redis+unix: is a legitimate Unix-socket form
4712        // the redis crate accepts; validate must not reject it.
4713        HarnessConfig::from_toml(
4714            r#"upstream_url = "https://api"
4715               state_backend = "redis"
4716               state_endpoint = "redis+unix:/tmp/redis.sock""#,
4717        )
4718        .unwrap();
4719        // max_request_bytes = 0 is a silent-breakage
4720        // config (DefaultBodyLimit::max(0) rejects every non-empty
4721        // POST body); validate must refuse it.
4722        let err = HarnessConfig::from_toml(
4723            r#"upstream_url = "https://api"
4724               max_request_bytes = 0"#,
4725        )
4726        .unwrap_err();
4727        assert!(err.contains("max_request_bytes"), "{err}");
4728        // NATS bridge_endpoint: bad scheme.
4729        let err = HarnessConfig::from_toml(
4730            r#"upstream_url = "https://api"
4731               bridge_backend = "nats"
4732               bridge_endpoint = "http://bus""#,
4733        )
4734        .unwrap_err();
4735        assert!(err.contains("bridge_endpoint"), "{err}");
4736        // Qdrant: missing scheme.
4737        let err = HarnessConfig::from_toml(
4738            r#"upstream_url = "https://api"
4739               vector_backend = "qdrant"
4740               qdrant_url = "vectors.internal:6333""#,
4741        )
4742        .unwrap_err();
4743        assert!(err.contains("qdrant_url"), "{err}");
4744        // Legit values pass.
4745        assert!(HarnessConfig::from_toml(
4746            r#"upstream_url = "https://api"
4747               identity_jwks_url = "https://auth.internal/jwks"
4748               state_backend = "redis"
4749               state_endpoint = "redis://cache:6379"
4750               bridge_backend = "nats"
4751               bridge_endpoint = "nats://bus:4222"
4752               vector_backend = "qdrant"
4753               qdrant_url = "https://vectors.internal:6333""#,
4754        )
4755        .is_ok());
4756    }
4757
4758    /// Empty `atif_spool_dir` / `bridge_data_dir` are
4759    /// rejected. Without the check they default to `Path::new("")`,
4760    /// making every spool op write to the process CWD instead of the
4761    /// expected volume.
4762    #[test]
4763    fn round_31_f1_local_fs_paths_empty_rejected() {
4764        let err = HarnessConfig::from_toml(
4765            r#"upstream_url = "https://api"
4766               atif_spool_dir = """#,
4767        )
4768        .unwrap_err();
4769        assert!(err.contains("atif_spool_dir"), "{err}");
4770        let err = HarnessConfig::from_toml(
4771            r#"upstream_url = "https://api"
4772               bridge_data_dir = """#,
4773        )
4774        .unwrap_err();
4775        assert!(err.contains("bridge_data_dir"), "{err}");
4776    }
4777
4778    /// Identity scope names must be visible ASCII, no
4779    /// whitespace, no empty strings. Some IdPs tokenize an empty
4780    /// "scope" claim into an empty string; without this check the
4781    /// gate becomes `scopes.contains("")` and any token satisfies it.
4782    #[test]
4783    fn round_31_f2_scope_names_rejected_empty_or_whitespaced() {
4784        // Empty chat_scope.
4785        let err = HarnessConfig::from_toml(
4786            r#"upstream_url = "https://api"
4787               require_identity = true
4788               identity_hmac_secret_file = "/tmp/secret"
4789               enforce_identity_scopes = true
4790               chat_scope = """#,
4791        )
4792        .unwrap_err();
4793        assert!(err.contains("chat_scope"), "{err}");
4794        // Whitespace in scope.
4795        let err = HarnessConfig::from_toml(
4796            r#"upstream_url = "https://api"
4797               require_identity = true
4798               identity_hmac_secret_file = "/tmp/secret"
4799               enforce_identity_scopes = true
4800               chat_scope = "chat write""#,
4801        )
4802        .unwrap_err();
4803        assert!(err.contains("chat_scope"), "{err}");
4804        // Control byte in scope.
4805        let err = HarnessConfig::from_toml(
4806            r#"upstream_url = "https://api"
4807               require_identity = true
4808               identity_hmac_secret_file = "/tmp/secret"
4809               enforce_identity_scopes = true
4810               session_close_scope = "close\tsession""#,
4811        )
4812        .unwrap_err();
4813        assert!(err.contains("session_close_scope"), "{err}");
4814        // A well-formed scope passes.
4815        assert!(HarnessConfig::from_toml(
4816            r#"upstream_url = "https://api"
4817               require_identity = true
4818               identity_hmac_secret_file = "/tmp/secret"
4819               enforce_identity_scopes = true
4820               chat_scope = "chat:write"
4821               session_close_scope = "session:close"
4822               session_promote_scope = "session:promote""#,
4823        )
4824        .is_ok());
4825    }
4826}
4827
4828// The permissive test constructor lives in its own
4829// FILE so a grep through config.rs finds only production defaults —
4830// `for_tests`'s test values fooled three independent reviewers. A
4831// child module (not a sibling) so it can reach the private
4832// `default_*` helpers without widening their visibility.
4833// Gated OUT of production builds entirely (Action Register item 7):
4834// `test` covers this crate's unit tests; the `test-support` feature is
4835// enabled only by the self dev-dependency, so integration tests and
4836// benches see it while `cargo build`/`--release`/`--features full`
4837// artifacts cannot even name it.
4838#[cfg(any(test, feature = "test-support"))]
4839#[path = "config_testkit.rs"]
4840mod testkit;