Skip to main content

nyx_agent_core/
config.rs

1//! Typed configuration loaded from `nyx-agent.toml`.
2//!
3//! Missing sections fall back to defaults so that `nyx-agent doctor` and
4//! other read-only operations work in a fresh checkout with no config
5//! file on disk.
6
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13use nyx_agent_types::product::{
14    LaunchEnvRef, LaunchHealthCheck, LaunchStep, ProjectLaunchProfileInput,
15};
16use nyx_agent_types::project::ProjectRuntimeProfile;
17
18#[derive(Debug, Error)]
19pub enum ConfigError {
20    #[error("failed to read config at {path}: {source}")]
21    Read {
22        path: PathBuf,
23        #[source]
24        source: std::io::Error,
25    },
26    #[error("failed to parse config at {path}: {source}")]
27    Parse {
28        path: PathBuf,
29        #[source]
30        source: toml::de::Error,
31    },
32    #[error("failed to serialise config: {0}")]
33    Serialise(#[from] toml::ser::Error),
34}
35
36#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
37#[serde(default, deny_unknown_fields)]
38pub struct Config {
39    pub general: GeneralConfig,
40    pub performance: PerformanceConfig,
41    pub sandbox: SandboxConfig,
42    pub ai: AiConfig,
43    pub ui: UiConfig,
44    pub triggers: TriggersConfig,
45    pub nyx: NyxConfig,
46    pub run: RunConfig,
47    pub env: EnvConfig,
48    /// Projects own repos. Each `[[project]]` block declares one
49    /// product (e.g. backend + frontend) and groups its repos under
50    /// `[[project.repo]]`. The top-level `[[repo]]` shape is rejected;
51    /// every repo must live under a project.
52    #[serde(rename = "project", default)]
53    pub projects: Vec<ProjectConfig>,
54    /// Cron-driven scan schedule entries. Each entry pairs a 5-field
55    /// cron expression with an optional repo filter (`None` scans
56    /// every enabled repo). The daemon's scheduler task evaluates
57    /// every entry once per minute.
58    #[serde(rename = "schedule", default)]
59    pub schedules: Vec<ScheduleConfig>,
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63#[serde(default, deny_unknown_fields)]
64pub struct GeneralConfig {
65    pub log_level: String,
66    pub state_dir: Option<PathBuf>,
67}
68
69impl Default for GeneralConfig {
70    fn default() -> Self {
71        Self { log_level: "info".to_string(), state_dir: None }
72    }
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76#[serde(default, deny_unknown_fields)]
77pub struct PerformanceConfig {
78    pub max_parallel_scans: u32,
79    pub scan_timeout_secs: u64,
80    /// Explicit override for the per-run static-pass fan-out.
81    /// `None` -> dispatcher computes `min(num_cpus / 2, len(repos))`.
82    /// `Some(n)` -> use exactly `n.max(1)` parallel jobs.
83    #[serde(default)]
84    pub static_concurrency: Option<usize>,
85    /// Per-repo budget for the static-pass scan. A scan that exceeds
86    /// the budget is killed and its repo bundle records
87    /// `Inconclusive(StaticPassTimeout)` while the rest of the run
88    /// continues. `None` -> 30 minutes.
89    #[serde(default)]
90    pub per_repo_timeout_secs: Option<u64>,
91    /// Cadence at which the cron scheduler wakes to evaluate
92    /// `[[schedule]]` entries. `None` -> 60 seconds (matches the
93    /// granularity of the standard 5-field cron expression).
94    /// Floored at 1 second by [`PerformanceConfig::scheduler_tick`].
95    /// Operators should only lower this when a sub-minute cron
96    /// granularity is required; tighter polling spends more CPU.
97    #[serde(default)]
98    pub scheduler_tick_secs: Option<u64>,
99    /// Per-lane simultaneous-spinup cap for the chain lane. `None`
100    /// -> built-in default (2). Mirrors
101    /// `nyx_agent_sandbox::LaneConcurrency::DEFAULT_CHAIN`. A configured
102    /// `0` is floored to `1` by
103    /// [`PerformanceConfig::chain_lane_concurrency_resolved`].
104    #[serde(default)]
105    pub chain_lane_concurrency: Option<usize>,
106    /// Per-lane simultaneous-spinup cap for the fast lane. `None`
107    /// -> built-in default (8). Mirrors
108    /// `nyx_agent_sandbox::LaneConcurrency::DEFAULT_FAST`. A configured
109    /// `0` is floored to `1` by
110    /// [`PerformanceConfig::fast_lane_concurrency_resolved`].
111    #[serde(default)]
112    pub fast_lane_concurrency: Option<usize>,
113}
114
115impl Default for PerformanceConfig {
116    fn default() -> Self {
117        Self {
118            max_parallel_scans: 4,
119            scan_timeout_secs: 600,
120            static_concurrency: None,
121            per_repo_timeout_secs: None,
122            scheduler_tick_secs: None,
123            chain_lane_concurrency: None,
124            fast_lane_concurrency: None,
125        }
126    }
127}
128
129impl PerformanceConfig {
130    /// Resolved per-repo timeout. Falls back to 30 minutes when the
131    /// operator has not set `[performance] per_repo_timeout_secs`.
132    pub fn per_repo_timeout(&self) -> std::time::Duration {
133        std::time::Duration::from_secs(self.per_repo_timeout_secs.unwrap_or(30 * 60))
134    }
135
136    /// Resolved static-pass fan-out. Returns `None` when the operator
137    /// has not set `[performance] static_concurrency`; the dispatcher
138    /// then derives the default from CPU count and repo count.
139    pub fn static_concurrency_override(&self) -> Option<usize> {
140        self.static_concurrency.map(|n| n.max(1))
141    }
142
143    /// Resolved scheduler wake cadence. Falls back to 60 seconds when
144    /// the operator has not set `[performance] scheduler_tick_secs`;
145    /// a configured `0` is floored to `1` so the loop cannot busy-wait.
146    pub fn scheduler_tick(&self) -> std::time::Duration {
147        let secs = self.scheduler_tick_secs.unwrap_or(60).max(1);
148        std::time::Duration::from_secs(secs)
149    }
150
151    /// Built-in fallback for the chain-lane simultaneous-spinup cap.
152    /// Mirrors `nyx_agent_sandbox::LaneConcurrency::DEFAULT_CHAIN`; kept
153    /// duplicated here so this crate has no reverse dep on the sandbox
154    /// crate. The two values must stay in sync.
155    pub const DEFAULT_CHAIN_LANE_CONCURRENCY: usize = 2;
156
157    /// Built-in fallback for the fast-lane simultaneous-spinup cap.
158    /// Mirrors `nyx_agent_sandbox::LaneConcurrency::DEFAULT_FAST`.
159    pub const DEFAULT_FAST_LANE_CONCURRENCY: usize = 8;
160
161    /// Resolved chain-lane spinup cap. Falls back to
162    /// [`Self::DEFAULT_CHAIN_LANE_CONCURRENCY`] when the operator has
163    /// not set `[performance] chain_lane_concurrency`; a configured
164    /// `0` is floored to `1` so the worker pool cannot deadlock.
165    pub fn chain_lane_concurrency_resolved(&self) -> usize {
166        self.chain_lane_concurrency
167            .map(|n| n.max(1))
168            .unwrap_or(Self::DEFAULT_CHAIN_LANE_CONCURRENCY)
169    }
170
171    /// Resolved fast-lane spinup cap. Falls back to
172    /// [`Self::DEFAULT_FAST_LANE_CONCURRENCY`] when the operator has
173    /// not set `[performance] fast_lane_concurrency`; a configured
174    /// `0` is floored to `1`.
175    pub fn fast_lane_concurrency_resolved(&self) -> usize {
176        self.fast_lane_concurrency.map(|n| n.max(1)).unwrap_or(Self::DEFAULT_FAST_LANE_CONCURRENCY)
177    }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
181#[serde(default, deny_unknown_fields)]
182pub struct SandboxConfig {
183    pub enabled: bool,
184    pub allow_network: bool,
185    /// The first-launch wizard records the operator's preferred
186    /// sandbox backend here; the launcher reads it to pick a backend.
187    #[serde(default)]
188    pub backend: SandboxBackend,
189}
190
191impl Default for SandboxConfig {
192    fn default() -> Self {
193        Self { enabled: true, allow_network: false, backend: SandboxBackend::default() }
194    }
195}
196
197#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "kebab-case")]
199pub enum SandboxBackend {
200    /// Pick the strongest available backend at runtime.
201    #[default]
202    Auto,
203    /// No kernel isolation. Static-pass only. Always works.
204    Process,
205    /// macOS Seatbelt profile shipped with the agent.
206    Birdcage,
207    /// Lightweight microVM on Linux via libkrun.
208    Libkrun,
209    /// Lightweight microVM on Linux via Firecracker.
210    Firecracker,
211    /// Docker container fallback. Slowest, requires the docker daemon.
212    Docker,
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
216#[serde(default, deny_unknown_fields)]
217pub struct AiConfig {
218    pub provider: Option<String>,
219    pub model: Option<String>,
220    pub api_base: Option<String>,
221    /// Operator-selected AI runtime. The wizard writes this; the run
222    /// dispatcher reads it to pick which provider client to build.
223    /// The API key itself is stored in the OS keychain, not in TOML.
224    #[serde(default)]
225    pub runtime: AiRuntime,
226    /// Maximum number of in-flight `one_shot` AI calls per run.
227    /// PayloadSynthesis / SpecDerivation / ChainReasoning all share
228    /// this cap. `0` is floored to `1` by
229    /// [`AiConfig::max_concurrent_one_shot_resolved`].
230    #[serde(default = "default_max_concurrent_one_shot")]
231    pub max_concurrent_one_shot: u32,
232    /// Per-run AI budget cap (in USD micros) stamped on brand-new
233    /// `(run_id, kind)` rows the `BudgetStoreTracker` auto-creates.
234    /// `None` leaves the run uncapped. Operators can enable a cap via
235    /// `[ai] default_run_budget_usd_micros` in `nyx-agent.toml`.
236    #[serde(default)]
237    pub default_run_budget_usd_micros: Option<i64>,
238    /// Optional per-model pricing overrides. Each entry maps an exact
239    /// Anthropic model id (e.g. `claude-opus-4-7-20260101`) to a
240    /// per-million-token USD rate sheet. The Anthropic adapter
241    /// consults this map first; unmatched models fall back to the
242    /// built-in pricing table. Configured via
243    /// `[ai.pricing.<model>]` blocks in `nyx-agent.toml`.
244    #[serde(default)]
245    pub pricing: HashMap<String, AiPricingOverride>,
246    /// Per-call cap forwarded into each PayloadSynthesis `Budget`. The
247    /// adapter checks the call against `min(per_call_cap, run_cap -
248    /// spent_so_far)`, so this knob lets an operator clamp a single
249    /// PayloadSynthesis call below the shared per-run bucket. `None`
250    /// leaves the call uncapped.
251    /// Configured via
252    /// `[ai] payload_synthesis_per_call_cap_usd_micros`.
253    #[serde(default)]
254    pub payload_synthesis_per_call_cap_usd_micros: Option<i64>,
255    /// Per-call cap forwarded into each SpecDerivation `Budget`. See
256    /// `payload_synthesis_per_call_cap_usd_micros` for semantics.
257    /// Configured via
258    /// `[ai] spec_derivation_per_call_cap_usd_micros`.
259    #[serde(default)]
260    pub spec_derivation_per_call_cap_usd_micros: Option<i64>,
261    /// Per-call cap forwarded into the single ChainReasoning `Budget`.
262    /// Same shape as the PayloadSynthesis / SpecDerivation knobs.
263    /// Configured via
264    /// `[ai] chain_reasoning_per_call_cap_usd_micros`.
265    #[serde(default)]
266    pub chain_reasoning_per_call_cap_usd_micros: Option<i64>,
267    /// Per-call cap forwarded into each NovelFindingDiscovery batch.
268    /// Same shape; defaults to the run cap so a single batch may use
269    /// the full bucket when no earlier pass has spent yet. Configured
270    /// via `[ai] novel_discovery_per_call_cap_usd_micros`.
271    #[serde(default)]
272    pub novel_discovery_per_call_cap_usd_micros: Option<i64>,
273    /// Per-task soft cap for AI Exploration. Crossing the cap emits
274    /// a single operator warning but does not halt the run; the hard
275    /// cap below is the only ceiling that aborts an in-progress
276    /// exploration. `None` falls back to the caller-supplied default
277    /// (crate-level constant in `nyx-agent-ai`). Configured via
278    /// `[ai] exploration_soft_cap_usd_micros`.
279    #[serde(default)]
280    pub exploration_soft_cap_usd_micros: Option<i64>,
281    /// Per-run hard cap for AI Exploration. Sized for Claude Opus
282    /// pricing on a Phase-23 exploration loop. `None` falls back to
283    /// the caller-supplied default. Configured via
284    /// `[ai] exploration_run_cap_usd_micros`.
285    #[serde(default)]
286    pub exploration_run_cap_usd_micros: Option<i64>,
287}
288
289impl Default for AiConfig {
290    fn default() -> Self {
291        Self {
292            provider: None,
293            model: None,
294            api_base: None,
295            runtime: AiRuntime::default(),
296            max_concurrent_one_shot: default_max_concurrent_one_shot(),
297            default_run_budget_usd_micros: None,
298            pricing: HashMap::new(),
299            payload_synthesis_per_call_cap_usd_micros: None,
300            spec_derivation_per_call_cap_usd_micros: None,
301            chain_reasoning_per_call_cap_usd_micros: None,
302            novel_discovery_per_call_cap_usd_micros: None,
303            exploration_soft_cap_usd_micros: None,
304            exploration_run_cap_usd_micros: None,
305        }
306    }
307}
308
309/// Operator-friendly pricing override for one Anthropic model. All
310/// fields are USD per million tokens; the adapter converts to
311/// micros-per-token at construction time. Cache fields default to
312/// zero so an override that only sets `input`/`output` keeps the
313/// no-cache pricing of the haiku/sonnet defaults.
314#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(default, deny_unknown_fields)]
316pub struct AiPricingOverride {
317    pub input_per_mtok_usd: i64,
318    pub output_per_mtok_usd: i64,
319    pub cache_write_per_mtok_usd: i64,
320    pub cache_read_per_mtok_usd: i64,
321}
322
323fn default_max_concurrent_one_shot() -> u32 {
324    4
325}
326
327impl AiConfig {
328    /// Sentinel used for the built-in uncapped AI budget. The budget
329    /// plumbing still stores an integer cap, so `i64::MAX` gives the
330    /// existing adapters a practical "unlimited" ceiling without a
331    /// schema migration.
332    pub const DEFAULT_RUN_BUDGET_USD_MICROS: i64 = i64::MAX;
333
334    /// Floored fan-out used by run-time dispatchers. A configured `0`
335    /// would deadlock a semaphore acquire so we floor to `1`.
336    pub fn max_concurrent_one_shot_resolved(&self) -> usize {
337        self.max_concurrent_one_shot.max(1) as usize
338    }
339
340    /// Resolved per-run AI budget cap, honouring the operator override
341    /// when set. Missing, negative, or zero values resolve to the
342    /// built-in uncapped sentinel.
343    pub fn default_run_budget_usd_micros_resolved(&self) -> i64 {
344        match self.default_run_budget_usd_micros {
345            Some(v) if v > 0 => v,
346            _ => Self::DEFAULT_RUN_BUDGET_USD_MICROS,
347        }
348    }
349
350    /// Resolved per-call cap for PayloadSynthesis. Falls back to the
351    /// built-in default when the operator did not set
352    /// `[ai] payload_synthesis_per_call_cap_usd_micros` or set a
353    /// non-positive value.
354    pub fn payload_synthesis_per_call_cap_usd_micros_resolved(&self) -> i64 {
355        match self.payload_synthesis_per_call_cap_usd_micros {
356            Some(v) if v > 0 => v,
357            _ => Self::DEFAULT_RUN_BUDGET_USD_MICROS,
358        }
359    }
360
361    /// Resolved per-call cap for SpecDerivation. Same fall-back rules
362    /// as `payload_synthesis_per_call_cap_usd_micros_resolved`.
363    pub fn spec_derivation_per_call_cap_usd_micros_resolved(&self) -> i64 {
364        match self.spec_derivation_per_call_cap_usd_micros {
365            Some(v) if v > 0 => v,
366            _ => Self::DEFAULT_RUN_BUDGET_USD_MICROS,
367        }
368    }
369
370    /// Resolved per-call cap for ChainReasoning. Same fall-back rules.
371    pub fn chain_reasoning_per_call_cap_usd_micros_resolved(&self) -> i64 {
372        match self.chain_reasoning_per_call_cap_usd_micros {
373            Some(v) if v > 0 => v,
374            _ => Self::DEFAULT_RUN_BUDGET_USD_MICROS,
375        }
376    }
377
378    /// Resolved per-call cap for NovelFindingDiscovery batches. Same
379    /// fall-back rules; the per-call cap defaults to the run cap so a
380    /// single batch may consume the entire bucket when no earlier pass
381    /// has spent yet.
382    pub fn novel_discovery_per_call_cap_usd_micros_resolved(&self) -> i64 {
383        match self.novel_discovery_per_call_cap_usd_micros {
384            Some(v) if v > 0 => v,
385            _ => Self::DEFAULT_RUN_BUDGET_USD_MICROS,
386        }
387    }
388
389    /// Resolved per-task soft cap for AI Exploration. Falls back to
390    /// the caller-supplied default when the operator did not set
391    /// `[ai] exploration_soft_cap_usd_micros` or set a non-positive
392    /// value. The default lives in the `nyx-agent-ai` crate
393    /// (`DEFAULT_EXPLORATION_SOFT_CAP_USD_MICROS`); core does not
394    /// depend on `nyx-agent-ai`, so the caller passes the value in.
395    pub fn exploration_soft_cap_usd_micros_resolved(&self, default: i64) -> i64 {
396        match self.exploration_soft_cap_usd_micros {
397            Some(v) if v > 0 => v,
398            _ => default,
399        }
400    }
401
402    /// Resolved per-run hard cap for AI Exploration. Same fall-back
403    /// rules as `exploration_soft_cap_usd_micros_resolved`.
404    pub fn exploration_run_cap_usd_micros_resolved(&self, default: i64) -> i64 {
405        match self.exploration_run_cap_usd_micros {
406            Some(v) if v > 0 => v,
407            _ => default,
408        }
409    }
410}
411
412#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
413#[serde(rename_all = "kebab-case")]
414pub enum AiRuntime {
415    /// AI features off. Static scans, route modelling, live checks,
416    /// evidence storage, and triage still run.
417    #[default]
418    None,
419    /// Hosted Anthropic API. The wizard prompts for an API key and
420    /// stashes it in the OS keychain under `secrets::ACCOUNT_AI_ANTHROPIC`.
421    Anthropic,
422    /// Local OpenAI-compatible runtime (LM Studio, Ollama, vLLM, ...).
423    /// The endpoint URL goes in `api_base`; any embedded bearer goes
424    /// in the keychain under `secrets::ACCOUNT_AI_LOCAL_LLM`.
425    LocalLlm,
426    /// Optional local adapter that drives an already-installed `claude`
427    /// CLI on `$PATH`.
428    ClaudeCode,
429    /// Optional local adapter that drives an already-installed `codex`
430    /// CLI on `$PATH`.
431    Codex,
432}
433
434#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
435#[serde(default, deny_unknown_fields)]
436pub struct UiConfig {
437    pub listen_addr: String,
438    pub open_browser: bool,
439}
440
441impl Default for UiConfig {
442    fn default() -> Self {
443        // Plan: serve opens a browser on startup unless --no-open /
444        // --headless. Default this to true so users who never write
445        // `[ui].open_browser` keep the documented behaviour, and those
446        // who set it to false in nyx-agent.toml suppress the launch.
447        Self { listen_addr: "127.0.0.1:8765".to_string(), open_browser: true }
448    }
449}
450
451#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
452#[serde(default, deny_unknown_fields)]
453pub struct NyxConfig {
454    /// Override the discovered `nyx` binary. When `None`, the runner falls
455    /// back to a `PATH` lookup.
456    pub binary_path: Option<PathBuf>,
457    /// Override the built-in minimum-supported `nyx` version. Useful in
458    /// integration tests; production deployments should leave it unset.
459    pub min_version: Option<String>,
460}
461
462/// `[env]` section: env-builder (docker-compose) knobs.
463#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
464#[serde(default, deny_unknown_fields)]
465pub struct EnvConfig {
466    /// Image-pull policy forwarded to `docker compose up --pull <policy>`.
467    /// `None` falls back to [`EnvPullPolicy::default`] (`Missing`: pull
468    /// only when the local store is missing the image), matching the
469    /// docker daemon's own default. Operators on a CI lane with a warm
470    /// image cache can set `[env] pull_policy = "never"` to skip the
471    /// per-spin-up pull RTT.
472    pub pull_policy: Option<EnvPullPolicy>,
473}
474
475impl EnvConfig {
476    /// Resolved image-pull policy: the operator override when set,
477    /// otherwise [`EnvPullPolicy::default`] (`Missing`).
478    pub fn pull_policy_resolved(&self) -> EnvPullPolicy {
479        self.pull_policy.unwrap_or_default()
480    }
481}
482
483/// Operator-friendly mirror of `nyx_agent_sandbox::env::PullPolicy`.
484/// Defined here so the `[env]` toml block parses without
485/// `nyx-agent-core` taking a reverse dep on the sandbox crate. The
486/// binary glue converts to the runtime type at the EnvBuilder seam.
487#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
488#[serde(rename_all = "kebab-case")]
489pub enum EnvPullPolicy {
490    /// Pull only when the local store does not have the image.
491    #[default]
492    Missing,
493    /// Re-pull on every spin-up.
494    Always,
495    /// Never pull; fail spin-up if the image is missing locally.
496    Never,
497}
498
499fn default_optional_scanner_enabled() -> bool {
500    true
501}
502
503/// `[run]` section: verifier knobs.
504#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
505#[serde(default, deny_unknown_fields)]
506pub struct RunConfig {
507    /// When `true`, the deterministic payload runner re-executes each
508    /// (vuln, benign) pair a second time and stamps `replay_stable` on
509    /// the resulting `VerifyResult`. Adds ~2× cost per verify; default
510    /// is `false` so the verifier stays fast on the happy path.
511    pub replay_stable_check: bool,
512    /// Allow live verification plans to send methods that are likely to
513    /// mutate target state (`POST`, `PUT`, `PATCH`, `DELETE`). Defaults
514    /// to false so Nyx Agent only runs safe probes unless the operator
515    /// explicitly opts in for their local app.
516    #[serde(default)]
517    pub allow_state_changing_live_probes: bool,
518    /// Opt in to browser-driven checks when a local Playwright runtime is
519    /// available. When false, browser plans are recorded as skipped with
520    /// an explicit reason.
521    #[serde(default)]
522    pub browser_checks_enabled: bool,
523    /// Master opt-in for exploit mode. Defaults to false; when false,
524    /// live verification stays non-destructive even if an older config
525    /// sets `allow_state_changing_live_probes = true`.
526    #[serde(default)]
527    pub exploit_mode_enabled: bool,
528    /// Evaluate guarded live probes and write audit records without
529    /// sending HTTP/browser traffic. Static analysis and source-only
530    /// scanners still run normally.
531    #[serde(default)]
532    pub exploit_dry_run: bool,
533    /// Generate first-class business-logic pentest candidates from the
534    /// route/auth model. The generated plans still pass through the
535    /// normal live-verifier safety gates.
536    #[serde(default = "default_true")]
537    pub business_logic_templates_enabled: bool,
538    /// Enable deeper authorized product-logic research. This adds
539    /// invariant-focused candidate hypotheses and gives AI planning /
540    /// exploration a broader product-logic brief. It does not relax
541    /// live execution safety gates.
542    #[serde(default)]
543    pub research_mode_enabled: bool,
544    /// Enable the pre-MVP unsafe local attack-agent phase. Once this
545    /// final phase is invoked it does not route actions through the
546    /// guarded live-verifier policy; it relies on the configured local
547    /// development environment and CLI-backed sandbox boundary.
548    #[serde(default)]
549    pub unsafe_attack_agent_enabled: bool,
550    /// Optional allowlist of business-logic template ids. Empty means
551    /// every registered template is considered.
552    #[serde(default)]
553    pub business_logic_template_ids: Vec<String>,
554    /// Per-candidate cap on guarded live HTTP/browser actions. `None`
555    /// falls back to [`RunConfig::DEFAULT_EXPLOIT_REQUEST_CAP`]; a
556    /// configured `0` is floored to `1`.
557    #[serde(default)]
558    pub exploit_request_cap: Option<u32>,
559    /// Per-candidate rate limit for guarded live requests/actions.
560    /// `None` falls back to
561    /// [`RunConfig::DEFAULT_EXPLOIT_REQUESTS_PER_SECOND`]; `0` floors
562    /// to `1`.
563    #[serde(default)]
564    pub exploit_requests_per_second: Option<u32>,
565    /// After an allowed state-changing probe, ask the environment
566    /// orchestration layer to reset/rollback when it supports that
567    /// operation. Defaults true so opt-in exploit runs clean up after
568    /// themselves when docker-compose orchestration is available.
569    #[serde(default = "default_true")]
570    pub exploit_reset_after_state_changing: bool,
571    /// Optional passive ZAP baseline orchestration. Enabled by default,
572    /// but the binary is only used when present on PATH; findings become
573    /// candidates.
574    #[serde(default = "default_optional_scanner_enabled")]
575    pub enable_zap_baseline: bool,
576    /// Optional Nuclei orchestration. Enabled by default, but the binary
577    /// is only used when present on PATH; findings become candidates.
578    #[serde(default = "default_optional_scanner_enabled")]
579    pub enable_nuclei: bool,
580    /// Optional Trivy repository/filesystem scan. Enabled by default,
581    /// but the binary is only used when present on PATH; findings become
582    /// source-context candidates for AI exploration.
583    #[serde(default = "default_optional_scanner_enabled")]
584    pub enable_trivy: bool,
585    /// Optional OSV-Scanner dependency scan. Enabled by default, but the
586    /// binary is only used when present on PATH; findings become
587    /// source-context candidates for AI exploration.
588    #[serde(default = "default_optional_scanner_enabled")]
589    pub enable_osv_scanner: bool,
590    /// Optional secret scanning. Enabled by default; Nyx Agent prefers
591    /// `gitleaks` when present and falls back to `detect-secrets`.
592    #[serde(default = "default_optional_scanner_enabled")]
593    pub enable_secret_scanning: bool,
594    /// Optional Katana crawler orchestration. Enabled by default, but
595    /// the binary is only used when present on PATH; sensitive routes
596    /// become live-test candidates.
597    #[serde(default = "default_optional_scanner_enabled")]
598    pub enable_katana: bool,
599    /// Optional ProjectDiscovery httpx probe orchestration. Enabled by
600    /// default, but the binary is only used when present on PATH;
601    /// interesting live metadata becomes candidates.
602    #[serde(default = "default_optional_scanner_enabled")]
603    pub enable_httpx: bool,
604    /// Aggressive external tooling is off unless this explicit gate is
605    /// true. Nyx Agent does not run sqlmap by default.
606    #[serde(default)]
607    pub enable_aggressive_sqlmap: bool,
608}
609
610impl Default for RunConfig {
611    fn default() -> Self {
612        Self {
613            replay_stable_check: false,
614            allow_state_changing_live_probes: false,
615            browser_checks_enabled: false,
616            exploit_mode_enabled: false,
617            exploit_dry_run: false,
618            business_logic_templates_enabled: true,
619            research_mode_enabled: false,
620            unsafe_attack_agent_enabled: false,
621            business_logic_template_ids: Vec::new(),
622            exploit_request_cap: None,
623            exploit_requests_per_second: None,
624            exploit_reset_after_state_changing: true,
625            enable_zap_baseline: true,
626            enable_nuclei: true,
627            enable_trivy: true,
628            enable_osv_scanner: true,
629            enable_secret_scanning: true,
630            enable_katana: true,
631            enable_httpx: true,
632            enable_aggressive_sqlmap: false,
633        }
634    }
635}
636
637impl RunConfig {
638    /// Default request/action cap for one candidate live-verification
639    /// attempt. This keeps malformed or model-generated workflows from
640    /// fanning out indefinitely.
641    pub const DEFAULT_EXPLOIT_REQUEST_CAP: u32 = 10;
642    /// Default per-candidate live request/action rate. Optional
643    /// scanners have their own CLI-level throttles; this cap covers
644    /// the built-in verifier.
645    pub const DEFAULT_EXPLOIT_REQUESTS_PER_SECOND: u32 = 5;
646
647    pub fn exploit_request_cap_resolved(&self) -> u32 {
648        self.exploit_request_cap.unwrap_or(Self::DEFAULT_EXPLOIT_REQUEST_CAP).max(1)
649    }
650
651    pub fn exploit_requests_per_second_resolved(&self) -> u32 {
652        self.exploit_requests_per_second.unwrap_or(Self::DEFAULT_EXPLOIT_REQUESTS_PER_SECOND).max(1)
653    }
654
655    pub fn state_changing_live_probes_allowed(&self) -> bool {
656        self.exploit_mode_enabled && self.allow_state_changing_live_probes
657    }
658}
659
660#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
661#[serde(default, deny_unknown_fields)]
662pub struct TriggersConfig {
663    pub on_push: bool,
664    pub on_pr: bool,
665    pub schedule_cron: Option<String>,
666    /// HMAC-SHA256 secret for `POST /webhook/git`. When
667    /// unset, the webhook handler returns 503 so a misconfigured host
668    /// cannot accept unauthenticated triggers.
669    #[serde(default)]
670    pub webhook_secret_ref: Option<String>,
671    /// Optional branch filter for the webhook. When set, the handler
672    /// only triggers a scan if the payload's branch ref matches.
673    /// `None` accepts any branch.
674    #[serde(default)]
675    pub webhook_branch: Option<String>,
676    /// Selects the body decoder for `POST /webhook/git`. Accepted
677    /// values: `refheads` (default, covers GitHub / Gitea / Forgejo /
678    /// Gogs / GitLab top-level `ref`), `bitbucket` (Bitbucket Server /
679    /// Data Center `changes[].refId`), `sourcehut` (nested
680    /// `event.refs[0].name`). Unknown values fall back to `refheads`
681    /// with a warning so a typo never silently disables webhooks.
682    #[serde(default)]
683    pub webhook_provider: Option<String>,
684    /// Cap on simultaneous in-flight `POST /webhook/git` handlers.
685    /// `None` falls back to the in-crate default. Set to a small
686    /// integer to bound the worst-case parallel HMAC + body-buffer
687    /// cost a flood of valid-signed deliveries can impose on the
688    /// daemon. Non-positive values fall back to the default.
689    #[serde(default)]
690    pub webhook_max_concurrent: Option<usize>,
691    /// Per-source-IP token bucket size for `POST /webhook/git`,
692    /// expressed in deliveries per minute. `None` falls back to the
693    /// in-crate default. Non-positive values fall back to the
694    /// default. The token-bucket burst depth matches this value so a
695    /// fresh sender can fire that many requests back-to-back before
696    /// throttling kicks in.
697    #[serde(default)]
698    pub webhook_rate_limit_per_minute: Option<u32>,
699}
700
701impl TriggersConfig {
702    /// Resolved cap on simultaneous in-flight webhook handlers.
703    /// Falls back to the crate-level default when the operator left
704    /// the knob unset or stamped a non-positive value.
705    pub fn webhook_max_concurrent_resolved(&self, default: usize) -> usize {
706        match self.webhook_max_concurrent {
707            Some(n) if n > 0 => n,
708            _ => default,
709        }
710    }
711
712    /// Resolved per-IP rate limit in deliveries per minute. Falls
713    /// back to the crate-level default when the operator left the
714    /// knob unset or stamped a non-positive value.
715    pub fn webhook_rate_limit_per_minute_resolved(&self, default: u32) -> u32 {
716        match self.webhook_rate_limit_per_minute {
717            Some(n) if n > 0 => n,
718            _ => default,
719        }
720    }
721}
722
723/// One `[[schedule]]` entry. A 5-field cron expression plus
724/// an optional repo filter. When `repo` is `None` the scheduler runs
725/// against every enabled repo (i.e. the same shape as the API's
726/// manual-scan endpoint with no `repo=` query).
727#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
728#[serde(deny_unknown_fields)]
729pub struct ScheduleConfig {
730    /// 5-field cron expression (minute hour day-of-month month day-of-week).
731    /// Example: `0 3 * * 1` = 03:00 every Monday.
732    pub cron: String,
733    /// Limit the run to a single configured repo. `None` scans every
734    /// enabled repo.
735    #[serde(default)]
736    pub repo: Option<String>,
737    /// Operator-readable label surfaced in tracing spans and the UI.
738    /// Default `"scheduled"`.
739    #[serde(default = "default_schedule_label")]
740    pub label: String,
741}
742
743fn default_schedule_label() -> String {
744    "scheduled".to_string()
745}
746
747/// A project groups one or more repos that belong to the same
748/// product. Scan/run/env-builder/chain-runner operate per-project.
749#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
750#[serde(deny_unknown_fields)]
751pub struct ProjectConfig {
752    /// Unique project name. Used as the human-facing identifier and
753    /// as the workspace directory prefix.
754    pub name: String,
755    /// Optional free-form description surfaced in the UI.
756    #[serde(default)]
757    pub description: Option<String>,
758    /// Optional base URL the sandbox env-builder dials when running
759    /// dynamic checks against the running stack.
760    #[serde(default)]
761    pub target_base_url: Option<String>,
762    /// Optional structured env overrides merged into the project's
763    /// docker-compose / sandbox runtime. Stored as opaque TOML so each
764    /// stack can carry whatever keys it needs.
765    #[serde(default)]
766    pub env_config: Option<toml::Value>,
767    /// Optional first-class launch recipe for local-app orchestration.
768    /// When set, CLI/API scans start this profile before Nyx/static
769    /// analysis and stop it after the pentest run.
770    #[serde(default)]
771    pub launch: Option<ProjectLaunchConfig>,
772    /// Optional auth/session profile. Auth material is referenced via
773    /// env vars or session files; raw secrets should not be stored in
774    /// TOML.
775    #[serde(default)]
776    pub runtime_profile: Option<ProjectRuntimeProfile>,
777    /// Repos that belong to this project. Use `[[project.repo]]`
778    /// blocks in TOML.
779    #[serde(rename = "repo", default)]
780    pub repos: Vec<RepoConfig>,
781}
782
783#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
784#[serde(default, deny_unknown_fields)]
785pub struct ProjectLaunchConfig {
786    pub name: Option<String>,
787    /// `auto`, `already-running`, `custom-commands`, or `docker-compose`.
788    /// `auto` is resolved before the profile is persisted.
789    pub mode: Option<String>,
790    pub target_urls: Vec<String>,
791    #[serde(default)]
792    pub build: Vec<ProjectLaunchCommandConfig>,
793    #[serde(default)]
794    pub start: Vec<ProjectLaunchCommandConfig>,
795    #[serde(default)]
796    pub seed: Vec<ProjectLaunchCommandConfig>,
797    #[serde(default)]
798    pub reset: Vec<ProjectLaunchCommandConfig>,
799    #[serde(default)]
800    pub login: Vec<ProjectLaunchCommandConfig>,
801    #[serde(default)]
802    pub stop: Vec<ProjectLaunchCommandConfig>,
803    #[serde(default)]
804    pub health: Vec<ProjectLaunchHealthConfig>,
805    #[serde(default)]
806    pub env_files: Vec<String>,
807    #[serde(default)]
808    pub env_vars: Vec<ProjectLaunchEnvVarConfig>,
809}
810
811impl ProjectLaunchConfig {
812    pub fn to_profile_input(&self, fallback_target_url: Option<&str>) -> ProjectLaunchProfileInput {
813        let mut target_urls = self.target_urls.clone();
814        if target_urls.is_empty() {
815            if let Some(target) = fallback_target_url.filter(|target| !target.trim().is_empty()) {
816                target_urls.push(target.to_string());
817            }
818        }
819        let env_refs = self
820            .env_files
821            .iter()
822            .filter(|path| !path.trim().is_empty())
823            .map(|path| LaunchEnvRef {
824                kind: "env-file".to_string(),
825                value: path.trim().to_string(),
826                secret: true,
827            })
828            .chain(self.env_vars.iter().filter_map(ProjectLaunchEnvVarConfig::to_env_ref))
829            .collect();
830        ProjectLaunchProfileInput {
831            name: self.name.clone(),
832            mode: self.mode.clone(),
833            build_steps: self.build.iter().map(ProjectLaunchCommandConfig::to_step).collect(),
834            start_steps: self.start.iter().map(ProjectLaunchCommandConfig::to_step).collect(),
835            seed_steps: self.seed.iter().map(ProjectLaunchCommandConfig::to_step).collect(),
836            reset_steps: self.reset.iter().map(ProjectLaunchCommandConfig::to_step).collect(),
837            login_steps: self.login.iter().map(ProjectLaunchCommandConfig::to_step).collect(),
838            stop_steps: self.stop.iter().map(ProjectLaunchCommandConfig::to_step).collect(),
839            health_checks: self.health.iter().map(ProjectLaunchHealthConfig::to_check).collect(),
840            target_urls,
841            env_refs,
842            working_dirs: Vec::new(),
843        }
844    }
845}
846
847#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
848#[serde(deny_unknown_fields)]
849pub struct ProjectLaunchCommandConfig {
850    pub command: String,
851    #[serde(default, alias = "repo")]
852    pub repo_name: Option<String>,
853    #[serde(default)]
854    pub working_directory: Option<String>,
855    #[serde(default, alias = "timeout_secs")]
856    pub timeout_seconds: Option<u64>,
857    #[serde(default, alias = "input")]
858    pub stdin: Option<String>,
859}
860
861impl ProjectLaunchCommandConfig {
862    pub fn to_step(&self) -> LaunchStep {
863        LaunchStep {
864            command: self.command.clone(),
865            repo_id: None,
866            repo_name: self.repo_name.clone(),
867            working_directory: self.working_directory.clone(),
868            timeout_seconds: self.timeout_seconds,
869            stdin: self.stdin.clone(),
870        }
871    }
872}
873
874#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
875#[serde(default, deny_unknown_fields)]
876pub struct ProjectLaunchHealthConfig {
877    pub kind: Option<String>,
878    pub url: Option<String>,
879    pub host: Option<String>,
880    pub port: Option<u16>,
881    pub command: Option<ProjectLaunchCommandConfig>,
882    #[serde(alias = "timeout_secs")]
883    pub timeout_seconds: Option<u64>,
884}
885
886impl ProjectLaunchHealthConfig {
887    pub fn to_check(&self) -> LaunchHealthCheck {
888        LaunchHealthCheck {
889            kind: self.kind.clone().unwrap_or_else(|| {
890                if self.command.is_some() {
891                    "command".to_string()
892                } else {
893                    "http".to_string()
894                }
895            }),
896            url: self.url.clone(),
897            host: self.host.clone(),
898            port: self.port,
899            command: self.command.as_ref().map(ProjectLaunchCommandConfig::to_step),
900            timeout_seconds: self.timeout_seconds,
901        }
902    }
903}
904
905#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
906#[serde(default, deny_unknown_fields)]
907pub struct ProjectLaunchEnvVarConfig {
908    pub name: String,
909    pub secret: bool,
910}
911
912impl Default for ProjectLaunchEnvVarConfig {
913    fn default() -> Self {
914        Self { name: String::new(), secret: true }
915    }
916}
917
918impl ProjectLaunchEnvVarConfig {
919    fn to_env_ref(&self) -> Option<LaunchEnvRef> {
920        let name = self.name.trim();
921        (!name.is_empty()).then(|| LaunchEnvRef {
922            kind: "env-var".to_string(),
923            value: name.to_string(),
924            secret: self.secret,
925        })
926    }
927}
928
929#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
930#[serde(deny_unknown_fields)]
931pub struct RepoConfig {
932    pub name: String,
933    /// Operator attestation that they own this repo and consent to scanning.
934    /// The daemon refuses to ingest a repo without `i_own_this = true`.
935    #[serde(default)]
936    pub i_own_this: bool,
937    pub source: RepoSourceConfig,
938    #[serde(default = "default_true")]
939    pub enabled: bool,
940}
941
942#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
943#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
944pub enum RepoSourceConfig {
945    /// Read-only clone of a remote git URL into `<state>/repos/<name>/`.
946    Git {
947        url: String,
948        #[serde(default)]
949        branch: Option<String>,
950        /// Auth descriptor: `ssh-key:<path>`, `token-env:<var>`, `gh-app:<id>`.
951        #[serde(default)]
952        auth: Option<String>,
953    },
954    /// Read-only snapshot of a directory already present on disk.
955    LocalPath { path: PathBuf },
956}
957
958fn default_true() -> bool {
959    true
960}
961
962impl Config {
963    #[tracing::instrument(skip_all, fields(path = %path.display()))]
964    pub fn load_from(path: &Path) -> Result<Self, ConfigError> {
965        let raw = std::fs::read_to_string(path)
966            .map_err(|source| ConfigError::Read { path: path.to_path_buf(), source })?;
967        Self::parse(&raw, path)
968    }
969
970    #[tracing::instrument(skip_all, fields(path = %path.display()))]
971    pub fn load_or_default(path: &Path) -> Result<Self, ConfigError> {
972        match std::fs::read_to_string(path) {
973            Ok(raw) => Self::parse(&raw, path),
974            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(Self::default()),
975            Err(source) => Err(ConfigError::Read { path: path.to_path_buf(), source }),
976        }
977    }
978
979    pub fn parse(raw: &str, path: &Path) -> Result<Self, ConfigError> {
980        toml::from_str(raw)
981            .map_err(|source| ConfigError::Parse { path: path.to_path_buf(), source })
982    }
983
984    pub fn to_toml_string(&self) -> Result<String, ConfigError> {
985        Ok(toml::to_string_pretty(self)?)
986    }
987}
988
989#[cfg(test)]
990mod tests {
991    use std::path::PathBuf;
992
993    use super::*;
994
995    #[test]
996    fn defaults_roundtrip_through_toml() {
997        let cfg = Config::default();
998        let rendered = cfg.to_toml_string().expect("serialise defaults");
999        let parsed = Config::parse(&rendered, &PathBuf::from("<test>")).expect("parse defaults");
1000        assert_eq!(parsed, cfg);
1001    }
1002
1003    #[test]
1004    fn run_defaults_enable_recommended_optional_scanners() {
1005        let cfg = Config::parse("[run]\n", &PathBuf::from("<test>")).expect("parse run defaults");
1006        assert!(cfg.run.enable_zap_baseline);
1007        assert!(cfg.run.enable_nuclei);
1008        assert!(cfg.run.enable_trivy);
1009        assert!(cfg.run.enable_osv_scanner);
1010        assert!(cfg.run.enable_secret_scanning);
1011        assert!(cfg.run.enable_katana);
1012        assert!(cfg.run.enable_httpx);
1013        assert!(!cfg.run.enable_aggressive_sqlmap);
1014        assert!(!cfg.run.exploit_mode_enabled);
1015        assert!(!cfg.run.exploit_dry_run);
1016        assert!(cfg.run.business_logic_templates_enabled);
1017        assert!(cfg.run.business_logic_template_ids.is_empty());
1018        assert_eq!(cfg.run.exploit_request_cap_resolved(), RunConfig::DEFAULT_EXPLOIT_REQUEST_CAP);
1019        assert_eq!(
1020            cfg.run.exploit_requests_per_second_resolved(),
1021            RunConfig::DEFAULT_EXPLOIT_REQUESTS_PER_SECOND
1022        );
1023        assert!(cfg.run.exploit_reset_after_state_changing);
1024        assert!(!cfg.run.state_changing_live_probes_allowed());
1025    }
1026
1027    #[test]
1028    fn exploit_policy_config_parses_and_resolves() {
1029        let raw = r#"
1030[run]
1031exploit_mode_enabled = true
1032allow_state_changing_live_probes = true
1033exploit_dry_run = true
1034business_logic_templates_enabled = false
1035business_logic_template_ids = ["tenant_object_isolation", "webhook_callback_trust_boundary"]
1036exploit_request_cap = 0
1037exploit_requests_per_second = 0
1038exploit_reset_after_state_changing = false
1039"#;
1040        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse exploit policy");
1041        assert!(cfg.run.exploit_mode_enabled);
1042        assert!(cfg.run.allow_state_changing_live_probes);
1043        assert!(cfg.run.state_changing_live_probes_allowed());
1044        assert!(cfg.run.exploit_dry_run);
1045        assert!(!cfg.run.business_logic_templates_enabled);
1046        assert_eq!(
1047            cfg.run.business_logic_template_ids,
1048            vec![
1049                "tenant_object_isolation".to_string(),
1050                "webhook_callback_trust_boundary".to_string()
1051            ]
1052        );
1053        assert_eq!(cfg.run.exploit_request_cap_resolved(), 1);
1054        assert_eq!(cfg.run.exploit_requests_per_second_resolved(), 1);
1055        assert!(!cfg.run.exploit_reset_after_state_changing);
1056    }
1057
1058    #[test]
1059    fn populated_config_roundtrips() {
1060        let cfg = Config {
1061            general: GeneralConfig {
1062                log_level: "debug".to_string(),
1063                state_dir: Some(PathBuf::from("/tmp/nyx")),
1064            },
1065            performance: PerformanceConfig {
1066                max_parallel_scans: 8,
1067                scan_timeout_secs: 1200,
1068                static_concurrency: Some(2),
1069                per_repo_timeout_secs: Some(45),
1070                scheduler_tick_secs: Some(10),
1071                chain_lane_concurrency: Some(3),
1072                fast_lane_concurrency: Some(12),
1073            },
1074            sandbox: SandboxConfig {
1075                enabled: false,
1076                allow_network: true,
1077                backend: SandboxBackend::Birdcage,
1078            },
1079            ai: AiConfig {
1080                provider: Some("anthropic".to_string()),
1081                model: Some("claude-opus-4-7".to_string()),
1082                api_base: None,
1083                runtime: AiRuntime::Anthropic,
1084                max_concurrent_one_shot: 2,
1085                default_run_budget_usd_micros: None,
1086                pricing: {
1087                    let mut m = HashMap::new();
1088                    m.insert(
1089                        "claude-opus-4-7-20260101".to_string(),
1090                        AiPricingOverride {
1091                            input_per_mtok_usd: 12,
1092                            output_per_mtok_usd: 60,
1093                            cache_write_per_mtok_usd: 15,
1094                            cache_read_per_mtok_usd: 1,
1095                        },
1096                    );
1097                    m
1098                },
1099                payload_synthesis_per_call_cap_usd_micros: Some(2_500_000),
1100                spec_derivation_per_call_cap_usd_micros: Some(1_500_000),
1101                chain_reasoning_per_call_cap_usd_micros: Some(3_000_000),
1102                novel_discovery_per_call_cap_usd_micros: Some(4_000_000),
1103                exploration_soft_cap_usd_micros: Some(3_500_000),
1104                exploration_run_cap_usd_micros: Some(8_000_000),
1105            },
1106            ui: UiConfig { listen_addr: "0.0.0.0:9999".to_string(), open_browser: true },
1107            triggers: TriggersConfig {
1108                on_push: true,
1109                on_pr: true,
1110                schedule_cron: Some("0 * * * *".to_string()),
1111                webhook_secret_ref: Some("env:NYX_WEBHOOK_SECRET".to_string()),
1112                webhook_branch: Some("main".to_string()),
1113                webhook_provider: Some("github".to_string()),
1114                webhook_max_concurrent: Some(4),
1115                webhook_rate_limit_per_minute: Some(60),
1116            },
1117            nyx: NyxConfig {
1118                binary_path: Some(PathBuf::from("/opt/nyx/bin/nyx")),
1119                min_version: Some("0.2.0".to_string()),
1120            },
1121            run: RunConfig {
1122                replay_stable_check: true,
1123                allow_state_changing_live_probes: true,
1124                browser_checks_enabled: true,
1125                exploit_mode_enabled: true,
1126                exploit_dry_run: true,
1127                exploit_request_cap: Some(3),
1128                exploit_requests_per_second: Some(2),
1129                exploit_reset_after_state_changing: false,
1130                ..RunConfig::default()
1131            },
1132            env: EnvConfig { pull_policy: Some(EnvPullPolicy::Never) },
1133            projects: vec![ProjectConfig {
1134                name: "acme-app".to_string(),
1135                description: Some("Acme web product".to_string()),
1136                target_base_url: Some("http://localhost:3000".to_string()),
1137                env_config: None,
1138                launch: None,
1139                runtime_profile: None,
1140                repos: vec![
1141                    RepoConfig {
1142                        name: "acme-backend".to_string(),
1143                        i_own_this: true,
1144                        source: RepoSourceConfig::Git {
1145                            url: "git@github.com:acme/acme-backend.git".to_string(),
1146                            branch: Some("main".to_string()),
1147                            auth: Some("ssh-key:~/.ssh/work_ed25519".to_string()),
1148                        },
1149                        enabled: true,
1150                    },
1151                    RepoConfig {
1152                        name: "monolith".to_string(),
1153                        i_own_this: true,
1154                        source: RepoSourceConfig::LocalPath {
1155                            path: PathBuf::from("/Users/eli/code/monolith"),
1156                        },
1157                        enabled: true,
1158                    },
1159                ],
1160            }],
1161            schedules: vec![ScheduleConfig {
1162                cron: "0 3 * * 1".to_string(),
1163                repo: Some("acme-backend".to_string()),
1164                label: "weekly-monday-3am".to_string(),
1165            }],
1166        };
1167        let rendered = cfg.to_toml_string().expect("serialise");
1168        let parsed = Config::parse(&rendered, &PathBuf::from("<test>")).expect("parse");
1169        assert_eq!(parsed, cfg);
1170    }
1171
1172    #[test]
1173    fn missing_file_returns_default() {
1174        let path = PathBuf::from("/definitely/does/not/exist/nyx-agent.toml");
1175        let cfg = Config::load_or_default(&path).expect("missing file -> default");
1176        assert_eq!(cfg, Config::default());
1177    }
1178
1179    #[test]
1180    fn empty_string_parses_to_default() {
1181        let cfg = Config::parse("", &PathBuf::from("<test>")).expect("empty parses");
1182        assert_eq!(cfg, Config::default());
1183    }
1184
1185    #[test]
1186    fn repo_enabled_defaults_to_true_when_omitted() {
1187        let raw = "[[project]]\nname = \"p\"\n\n[[project.repo]]\nname = \"acme-backend\"\n\
1188                   i_own_this = true\n\
1189                   source = { kind = \"local-path\", path = \"/srv/repos/acme-backend\" }\n";
1190        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1191        assert_eq!(cfg.projects.len(), 1);
1192        assert_eq!(cfg.projects[0].repos.len(), 1);
1193        assert!(
1194            cfg.projects[0].repos[0].enabled,
1195            "declared repo without explicit enabled must default to true"
1196        );
1197    }
1198
1199    #[test]
1200    fn repo_source_git_parses_with_inline_table() {
1201        let raw = "[[project]]\nname = \"p\"\n\n[[project.repo]]\nname = \"billing\"\n\
1202                   i_own_this = true\n\
1203                   source = { kind = \"git\", url = \"git@github.com:org/billing.git\", \
1204                              branch = \"main\", auth = \"ssh-key:~/.ssh/work_ed25519\" }\n";
1205        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1206        match &cfg.projects[0].repos[0].source {
1207            RepoSourceConfig::Git { url, branch, auth } => {
1208                assert_eq!(url, "git@github.com:org/billing.git");
1209                assert_eq!(branch.as_deref(), Some("main"));
1210                assert_eq!(auth.as_deref(), Some("ssh-key:~/.ssh/work_ed25519"));
1211            }
1212            other => panic!("expected git source, got {other:?}"),
1213        }
1214    }
1215
1216    #[test]
1217    fn repo_source_local_path_parses() {
1218        let raw = "[[project]]\nname = \"p\"\n\n[[project.repo]]\nname = \"monolith\"\n\
1219                   i_own_this = true\n\
1220                   source = { kind = \"local-path\", path = \"/home/eli/code/monolith\" }\n";
1221        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1222        match &cfg.projects[0].repos[0].source {
1223            RepoSourceConfig::LocalPath { path } => {
1224                assert_eq!(path, &PathBuf::from("/home/eli/code/monolith"));
1225            }
1226            other => panic!("expected local-path source, got {other:?}"),
1227        }
1228    }
1229
1230    #[test]
1231    fn repo_source_unknown_kind_rejected() {
1232        let raw = "[[project]]\nname = \"p\"\n\n[[project.repo]]\nname = \"x\"\n\
1233                   i_own_this = true\n\
1234                   source = { kind = \"hg\", path = \"/srv/x\" }\n";
1235        let err = Config::parse(raw, &PathBuf::from("<test>")).expect_err("must reject");
1236        assert!(matches!(err, ConfigError::Parse { .. }));
1237    }
1238
1239    #[test]
1240    fn repo_i_own_this_defaults_to_false_when_omitted() {
1241        let raw = "[[project]]\nname = \"p\"\n\n[[project.repo]]\nname = \"x\"\n\
1242                   source = { kind = \"local-path\", path = \"/srv/x\" }\n";
1243        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1244        assert!(
1245            !cfg.projects[0].repos[0].i_own_this,
1246            "i_own_this must default to false so the daemon refuses unattested repos"
1247        );
1248    }
1249
1250    #[test]
1251    fn top_level_repo_block_rejected() {
1252        // Bare `[[repo]]` is no longer accepted. The TOML must
1253        // declare a `[[project]]` first and nest repos under it.
1254        let raw = "[[repo]]\nname = \"x\"\ni_own_this = true\n\
1255                   source = { kind = \"local-path\", path = \"/srv/x\" }\n";
1256        let err = Config::parse(raw, &PathBuf::from("<test>")).expect_err("must reject");
1257        assert!(matches!(err, ConfigError::Parse { .. }));
1258    }
1259
1260    #[test]
1261    fn project_groups_multiple_repos() {
1262        let raw = "[[project]]\nname = \"acme\"\ndescription = \"Acme product\"\n\
1263                   target_base_url = \"http://localhost:3000\"\n\n\
1264                   [[project.repo]]\nname = \"acme-backend\"\ni_own_this = true\nenabled = true\n\
1265                   source = { kind = \"local-path\", path = \"/p/backend\" }\n\n\
1266                   [[project.repo]]\nname = \"acme-frontend\"\ni_own_this = true\nenabled = true\n\
1267                   source = { kind = \"local-path\", path = \"/p/frontend\" }\n";
1268        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1269        assert_eq!(cfg.projects.len(), 1);
1270        let p = &cfg.projects[0];
1271        assert_eq!(p.name, "acme");
1272        assert_eq!(p.description.as_deref(), Some("Acme product"));
1273        assert_eq!(p.target_base_url.as_deref(), Some("http://localhost:3000"));
1274        assert_eq!(p.repos.len(), 2);
1275        assert_eq!(p.repos[0].name, "acme-backend");
1276        assert_eq!(p.repos[1].name, "acme-frontend");
1277    }
1278
1279    #[test]
1280    fn project_launch_profile_parses_lifecycle_hooks() {
1281        let raw = r#"
1282            [[project]]
1283            name = "acme"
1284            target_base_url = "http://localhost:3000"
1285
1286            [project.launch]
1287            mode = "custom-commands"
1288            env_files = [".env.test"]
1289
1290            [[project.launch.build]]
1291            command = "npm ci"
1292            repo = "web"
1293            timeout_secs = 120
1294
1295            [[project.launch.start]]
1296            command = "npm run dev"
1297            repo_name = "web"
1298
1299            [[project.launch.seed]]
1300            command = "npm run seed:test"
1301
1302            [[project.launch.reset]]
1303            command = "npm run db:reset"
1304
1305            [[project.launch.login]]
1306            command = "npm run session:nyx-agent"
1307
1308            [[project.launch.stop]]
1309            command = "npm run stop"
1310
1311            [[project.launch.health]]
1312            url = "http://localhost:3000/health"
1313            timeout_seconds = 15
1314
1315            [[project.launch.env_vars]]
1316            name = "NYX_AGENT_TEST_TOKEN"
1317            secret = true
1318        "#;
1319        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1320        let launch = cfg.projects[0].launch.as_ref().expect("launch config");
1321        let input = launch.to_profile_input(cfg.projects[0].target_base_url.as_deref());
1322
1323        assert_eq!(input.mode.as_deref(), Some("custom-commands"));
1324        assert_eq!(input.target_urls, vec!["http://localhost:3000"]);
1325        assert_eq!(input.build_steps[0].repo_name.as_deref(), Some("web"));
1326        assert_eq!(input.build_steps[0].timeout_seconds, Some(120));
1327        assert_eq!(input.seed_steps[0].command, "npm run seed:test");
1328        assert_eq!(input.reset_steps[0].command, "npm run db:reset");
1329        assert_eq!(input.login_steps[0].command, "npm run session:nyx-agent");
1330        assert_eq!(input.stop_steps[0].command, "npm run stop");
1331        assert_eq!(input.health_checks[0].url.as_deref(), Some("http://localhost:3000/health"));
1332        assert_eq!(input.env_refs.len(), 2);
1333    }
1334
1335    #[test]
1336    fn unknown_field_rejected() {
1337        let raw = "garbage_field = true\n";
1338        let err = Config::parse(raw, &PathBuf::from("<test>")).expect_err("must reject");
1339        assert!(matches!(err, ConfigError::Parse { .. }));
1340    }
1341
1342    #[test]
1343    fn performance_static_concurrency_and_per_repo_timeout_round_trip() {
1344        let raw = "[performance]\nstatic_concurrency = 3\nper_repo_timeout_secs = 5\n";
1345        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1346        assert_eq!(cfg.performance.static_concurrency, Some(3));
1347        assert_eq!(cfg.performance.per_repo_timeout_secs, Some(5));
1348        assert_eq!(cfg.performance.per_repo_timeout(), std::time::Duration::from_secs(5));
1349        assert_eq!(cfg.performance.static_concurrency_override(), Some(3));
1350    }
1351
1352    #[test]
1353    fn performance_omitted_overrides_fall_back() {
1354        let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
1355        assert!(cfg.performance.static_concurrency.is_none());
1356        assert!(cfg.performance.per_repo_timeout_secs.is_none());
1357        assert_eq!(cfg.performance.per_repo_timeout(), std::time::Duration::from_secs(30 * 60));
1358        assert!(cfg.performance.static_concurrency_override().is_none());
1359    }
1360
1361    #[test]
1362    fn ai_max_concurrent_one_shot_default_is_four() {
1363        let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
1364        assert_eq!(cfg.ai.max_concurrent_one_shot, 4);
1365        assert_eq!(cfg.ai.max_concurrent_one_shot_resolved(), 4);
1366    }
1367
1368    #[test]
1369    fn ai_max_concurrent_one_shot_zero_floors_to_one() {
1370        let raw = "[ai]\nmax_concurrent_one_shot = 0\n";
1371        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1372        assert_eq!(cfg.ai.max_concurrent_one_shot, 0);
1373        assert_eq!(cfg.ai.max_concurrent_one_shot_resolved(), 1);
1374    }
1375
1376    #[test]
1377    fn ai_max_concurrent_one_shot_roundtrips_through_toml() {
1378        let raw = "[ai]\nmax_concurrent_one_shot = 8\n";
1379        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1380        assert_eq!(cfg.ai.max_concurrent_one_shot, 8);
1381        let rendered = cfg.to_toml_string().expect("ser");
1382        let back = Config::parse(&rendered, &PathBuf::from("<test>")).expect("roundtrip");
1383        assert_eq!(back.ai.max_concurrent_one_shot, 8);
1384    }
1385
1386    #[test]
1387    fn performance_static_concurrency_zero_floors_to_one() {
1388        let cfg = Config {
1389            performance: PerformanceConfig {
1390                static_concurrency: Some(0),
1391                ..PerformanceConfig::default()
1392            },
1393            ..Config::default()
1394        };
1395        assert_eq!(cfg.performance.static_concurrency_override(), Some(1));
1396    }
1397
1398    #[test]
1399    fn performance_scheduler_tick_defaults_to_sixty_seconds() {
1400        let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
1401        assert!(cfg.performance.scheduler_tick_secs.is_none());
1402        assert_eq!(cfg.performance.scheduler_tick(), std::time::Duration::from_secs(60));
1403    }
1404
1405    #[test]
1406    fn performance_scheduler_tick_roundtrips_through_toml() {
1407        let raw = "[performance]\nscheduler_tick_secs = 5\n";
1408        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1409        assert_eq!(cfg.performance.scheduler_tick_secs, Some(5));
1410        assert_eq!(cfg.performance.scheduler_tick(), std::time::Duration::from_secs(5));
1411    }
1412
1413    #[test]
1414    fn performance_scheduler_tick_zero_floors_to_one_second() {
1415        let cfg = Config {
1416            performance: PerformanceConfig {
1417                scheduler_tick_secs: Some(0),
1418                ..PerformanceConfig::default()
1419            },
1420            ..Config::default()
1421        };
1422        assert_eq!(cfg.performance.scheduler_tick(), std::time::Duration::from_secs(1));
1423    }
1424
1425    #[test]
1426    fn performance_lane_concurrency_defaults_match_sandbox_constants() {
1427        let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
1428        assert!(cfg.performance.chain_lane_concurrency.is_none());
1429        assert!(cfg.performance.fast_lane_concurrency.is_none());
1430        assert_eq!(cfg.performance.chain_lane_concurrency_resolved(), 2);
1431        assert_eq!(cfg.performance.fast_lane_concurrency_resolved(), 8);
1432    }
1433
1434    #[test]
1435    fn performance_lane_concurrency_roundtrips_through_toml() {
1436        let raw = "[performance]\nchain_lane_concurrency = 4\nfast_lane_concurrency = 16\n";
1437        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1438        assert_eq!(cfg.performance.chain_lane_concurrency, Some(4));
1439        assert_eq!(cfg.performance.fast_lane_concurrency, Some(16));
1440        assert_eq!(cfg.performance.chain_lane_concurrency_resolved(), 4);
1441        assert_eq!(cfg.performance.fast_lane_concurrency_resolved(), 16);
1442    }
1443
1444    #[test]
1445    fn ai_pricing_override_defaults_empty() {
1446        let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
1447        assert!(cfg.ai.pricing.is_empty());
1448    }
1449
1450    #[test]
1451    fn ai_pricing_override_parses_per_model_block() {
1452        let raw = "[ai.pricing.\"claude-opus-4-7\"]\n\
1453                   input_per_mtok_usd = 12\n\
1454                   output_per_mtok_usd = 60\n\
1455                   cache_write_per_mtok_usd = 15\n\
1456                   cache_read_per_mtok_usd = 1\n";
1457        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1458        let entry =
1459            cfg.ai.pricing.get("claude-opus-4-7").expect("override for claude-opus-4-7 must parse");
1460        assert_eq!(entry.input_per_mtok_usd, 12);
1461        assert_eq!(entry.output_per_mtok_usd, 60);
1462        assert_eq!(entry.cache_write_per_mtok_usd, 15);
1463        assert_eq!(entry.cache_read_per_mtok_usd, 1);
1464    }
1465
1466    #[test]
1467    fn ai_pricing_override_omitted_cache_fields_default_to_zero() {
1468        let raw = "[ai.pricing.\"claude-haiku-4-5\"]\n\
1469                   input_per_mtok_usd = 1\n\
1470                   output_per_mtok_usd = 5\n";
1471        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1472        let entry = cfg.ai.pricing.get("claude-haiku-4-5").expect("override must parse");
1473        assert_eq!(entry.input_per_mtok_usd, 1);
1474        assert_eq!(entry.output_per_mtok_usd, 5);
1475        assert_eq!(entry.cache_write_per_mtok_usd, 0);
1476        assert_eq!(entry.cache_read_per_mtok_usd, 0);
1477    }
1478
1479    #[test]
1480    fn ai_pricing_override_unknown_field_rejected() {
1481        let raw = "[ai.pricing.\"claude-haiku-4-5\"]\n\
1482                   input_per_mtok_usd = 1\n\
1483                   garbage = true\n";
1484        let err = Config::parse(raw, &PathBuf::from("<test>")).expect_err("must reject");
1485        assert!(matches!(err, ConfigError::Parse { .. }));
1486    }
1487
1488    #[test]
1489    fn ai_run_budget_defaults_to_uncapped_sentinel() {
1490        let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
1491        assert!(cfg.ai.default_run_budget_usd_micros.is_none());
1492        assert_eq!(cfg.ai.default_run_budget_usd_micros_resolved(), i64::MAX);
1493    }
1494
1495    #[test]
1496    fn ai_per_call_caps_default_to_run_budget_constant() {
1497        let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
1498        assert!(cfg.ai.payload_synthesis_per_call_cap_usd_micros.is_none());
1499        assert!(cfg.ai.spec_derivation_per_call_cap_usd_micros.is_none());
1500        assert!(cfg.ai.chain_reasoning_per_call_cap_usd_micros.is_none());
1501        assert!(cfg.ai.novel_discovery_per_call_cap_usd_micros.is_none());
1502        let fallback = AiConfig::DEFAULT_RUN_BUDGET_USD_MICROS;
1503        assert_eq!(cfg.ai.payload_synthesis_per_call_cap_usd_micros_resolved(), fallback);
1504        assert_eq!(cfg.ai.spec_derivation_per_call_cap_usd_micros_resolved(), fallback);
1505        assert_eq!(cfg.ai.chain_reasoning_per_call_cap_usd_micros_resolved(), fallback);
1506        assert_eq!(cfg.ai.novel_discovery_per_call_cap_usd_micros_resolved(), fallback);
1507    }
1508
1509    #[test]
1510    fn ai_per_call_caps_parse_per_task_overrides() {
1511        let raw = "[ai]\n\
1512                   payload_synthesis_per_call_cap_usd_micros = 2500000\n\
1513                   spec_derivation_per_call_cap_usd_micros = 1500000\n\
1514                   chain_reasoning_per_call_cap_usd_micros = 3000000\n\
1515                   novel_discovery_per_call_cap_usd_micros = 4000000\n";
1516        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1517        assert_eq!(cfg.ai.payload_synthesis_per_call_cap_usd_micros_resolved(), 2_500_000);
1518        assert_eq!(cfg.ai.spec_derivation_per_call_cap_usd_micros_resolved(), 1_500_000);
1519        assert_eq!(cfg.ai.chain_reasoning_per_call_cap_usd_micros_resolved(), 3_000_000);
1520        assert_eq!(cfg.ai.novel_discovery_per_call_cap_usd_micros_resolved(), 4_000_000);
1521    }
1522
1523    #[test]
1524    fn ai_per_call_caps_non_positive_overrides_fall_back_to_default() {
1525        let raw = "[ai]\n\
1526                   payload_synthesis_per_call_cap_usd_micros = 0\n\
1527                   spec_derivation_per_call_cap_usd_micros = -1\n";
1528        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1529        let fallback = AiConfig::DEFAULT_RUN_BUDGET_USD_MICROS;
1530        assert_eq!(cfg.ai.payload_synthesis_per_call_cap_usd_micros_resolved(), fallback);
1531        assert_eq!(cfg.ai.spec_derivation_per_call_cap_usd_micros_resolved(), fallback);
1532    }
1533
1534    #[test]
1535    fn ai_exploration_caps_default_to_caller_default() {
1536        let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
1537        assert!(cfg.ai.exploration_soft_cap_usd_micros.is_none());
1538        assert!(cfg.ai.exploration_run_cap_usd_micros.is_none());
1539        // Caller default round-trips through the resolved getter.
1540        assert_eq!(cfg.ai.exploration_soft_cap_usd_micros_resolved(5_000_000), 5_000_000);
1541        assert_eq!(cfg.ai.exploration_run_cap_usd_micros_resolved(10_000_000), 10_000_000);
1542    }
1543
1544    #[test]
1545    fn ai_exploration_caps_parse_operator_overrides() {
1546        let raw = "[ai]\n\
1547                   exploration_soft_cap_usd_micros = 3500000\n\
1548                   exploration_run_cap_usd_micros = 8000000\n";
1549        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1550        assert_eq!(cfg.ai.exploration_soft_cap_usd_micros_resolved(5_000_000), 3_500_000);
1551        assert_eq!(cfg.ai.exploration_run_cap_usd_micros_resolved(10_000_000), 8_000_000);
1552    }
1553
1554    #[test]
1555    fn ai_exploration_caps_non_positive_overrides_fall_back_to_caller_default() {
1556        let raw = "[ai]\n\
1557                   exploration_soft_cap_usd_micros = 0\n\
1558                   exploration_run_cap_usd_micros = -1\n";
1559        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1560        assert_eq!(cfg.ai.exploration_soft_cap_usd_micros_resolved(5_000_000), 5_000_000);
1561        assert_eq!(cfg.ai.exploration_run_cap_usd_micros_resolved(10_000_000), 10_000_000);
1562    }
1563
1564    #[test]
1565    fn env_pull_policy_defaults_to_missing() {
1566        let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
1567        assert!(cfg.env.pull_policy.is_none());
1568        assert_eq!(cfg.env.pull_policy_resolved(), EnvPullPolicy::Missing);
1569    }
1570
1571    #[test]
1572    fn env_pull_policy_parses_kebab_case_variants() {
1573        for (raw, expected) in [
1574            ("[env]\npull_policy = \"missing\"\n", EnvPullPolicy::Missing),
1575            ("[env]\npull_policy = \"always\"\n", EnvPullPolicy::Always),
1576            ("[env]\npull_policy = \"never\"\n", EnvPullPolicy::Never),
1577        ] {
1578            let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1579            assert_eq!(cfg.env.pull_policy, Some(expected));
1580            assert_eq!(cfg.env.pull_policy_resolved(), expected);
1581        }
1582    }
1583
1584    #[test]
1585    fn env_pull_policy_unknown_value_rejected() {
1586        let raw = "[env]\npull_policy = \"sometimes\"\n";
1587        let err = Config::parse(raw, &PathBuf::from("<test>")).expect_err("must reject");
1588        assert!(matches!(err, ConfigError::Parse { .. }));
1589    }
1590
1591    #[test]
1592    fn env_pull_policy_unknown_field_rejected() {
1593        let raw = "[env]\nmystery = true\n";
1594        let err = Config::parse(raw, &PathBuf::from("<test>")).expect_err("must reject");
1595        assert!(matches!(err, ConfigError::Parse { .. }));
1596    }
1597
1598    #[test]
1599    fn webhook_limit_knobs_default_to_none_and_fall_back_to_caller_default() {
1600        let cfg = Config::parse("", &PathBuf::from("<test>")).expect("parse");
1601        assert!(cfg.triggers.webhook_max_concurrent.is_none());
1602        assert!(cfg.triggers.webhook_rate_limit_per_minute.is_none());
1603        assert_eq!(cfg.triggers.webhook_max_concurrent_resolved(8), 8);
1604        assert_eq!(cfg.triggers.webhook_rate_limit_per_minute_resolved(30), 30);
1605    }
1606
1607    #[test]
1608    fn webhook_limit_knobs_parse_operator_overrides() {
1609        let raw = "[triggers]\n\
1610                   webhook_max_concurrent = 16\n\
1611                   webhook_rate_limit_per_minute = 120\n";
1612        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1613        assert_eq!(cfg.triggers.webhook_max_concurrent, Some(16));
1614        assert_eq!(cfg.triggers.webhook_rate_limit_per_minute, Some(120));
1615        assert_eq!(cfg.triggers.webhook_max_concurrent_resolved(8), 16);
1616        assert_eq!(cfg.triggers.webhook_rate_limit_per_minute_resolved(30), 120);
1617    }
1618
1619    #[test]
1620    fn webhook_limit_knobs_non_positive_overrides_fall_back_to_default() {
1621        let raw = "[triggers]\n\
1622                   webhook_max_concurrent = 0\n\
1623                   webhook_rate_limit_per_minute = 0\n";
1624        let cfg = Config::parse(raw, &PathBuf::from("<test>")).expect("parse");
1625        assert_eq!(cfg.triggers.webhook_max_concurrent_resolved(8), 8);
1626        assert_eq!(cfg.triggers.webhook_rate_limit_per_minute_resolved(30), 30);
1627    }
1628
1629    #[test]
1630    fn performance_lane_concurrency_zero_floors_to_one() {
1631        let cfg = Config {
1632            performance: PerformanceConfig {
1633                chain_lane_concurrency: Some(0),
1634                fast_lane_concurrency: Some(0),
1635                ..PerformanceConfig::default()
1636            },
1637            ..Config::default()
1638        };
1639        assert_eq!(cfg.performance.chain_lane_concurrency_resolved(), 1);
1640        assert_eq!(cfg.performance.fast_lane_concurrency_resolved(), 1);
1641    }
1642}