1use 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 #[serde(rename = "project", default)]
53 pub projects: Vec<ProjectConfig>,
54 #[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 #[serde(default)]
84 pub static_concurrency: Option<usize>,
85 #[serde(default)]
90 pub per_repo_timeout_secs: Option<u64>,
91 #[serde(default)]
98 pub scheduler_tick_secs: Option<u64>,
99 #[serde(default)]
105 pub chain_lane_concurrency: Option<usize>,
106 #[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 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 pub fn static_concurrency_override(&self) -> Option<usize> {
140 self.static_concurrency.map(|n| n.max(1))
141 }
142
143 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 pub const DEFAULT_CHAIN_LANE_CONCURRENCY: usize = 2;
156
157 pub const DEFAULT_FAST_LANE_CONCURRENCY: usize = 8;
160
161 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 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 #[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 #[default]
202 Auto,
203 Process,
205 Birdcage,
207 Libkrun,
209 Firecracker,
211 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 #[serde(default)]
225 pub runtime: AiRuntime,
226 #[serde(default = "default_max_concurrent_one_shot")]
231 pub max_concurrent_one_shot: u32,
232 #[serde(default)]
237 pub default_run_budget_usd_micros: Option<i64>,
238 #[serde(default)]
245 pub pricing: HashMap<String, AiPricingOverride>,
246 #[serde(default)]
254 pub payload_synthesis_per_call_cap_usd_micros: Option<i64>,
255 #[serde(default)]
260 pub spec_derivation_per_call_cap_usd_micros: Option<i64>,
261 #[serde(default)]
266 pub chain_reasoning_per_call_cap_usd_micros: Option<i64>,
267 #[serde(default)]
272 pub novel_discovery_per_call_cap_usd_micros: Option<i64>,
273 #[serde(default)]
280 pub exploration_soft_cap_usd_micros: Option<i64>,
281 #[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#[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 pub const DEFAULT_RUN_BUDGET_USD_MICROS: i64 = i64::MAX;
333
334 pub fn max_concurrent_one_shot_resolved(&self) -> usize {
337 self.max_concurrent_one_shot.max(1) as usize
338 }
339
340 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 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 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 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 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 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 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 #[default]
418 None,
419 Anthropic,
422 LocalLlm,
426 ClaudeCode,
429 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 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 pub binary_path: Option<PathBuf>,
457 pub min_version: Option<String>,
460}
461
462#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
464#[serde(default, deny_unknown_fields)]
465pub struct EnvConfig {
466 pub pull_policy: Option<EnvPullPolicy>,
473}
474
475impl EnvConfig {
476 pub fn pull_policy_resolved(&self) -> EnvPullPolicy {
479 self.pull_policy.unwrap_or_default()
480 }
481}
482
483#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
488#[serde(rename_all = "kebab-case")]
489pub enum EnvPullPolicy {
490 #[default]
492 Missing,
493 Always,
495 Never,
497}
498
499fn default_optional_scanner_enabled() -> bool {
500 true
501}
502
503#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
505#[serde(default, deny_unknown_fields)]
506pub struct RunConfig {
507 pub replay_stable_check: bool,
512 #[serde(default)]
517 pub allow_state_changing_live_probes: bool,
518 #[serde(default)]
522 pub browser_checks_enabled: bool,
523 #[serde(default)]
527 pub exploit_mode_enabled: bool,
528 #[serde(default)]
532 pub exploit_dry_run: bool,
533 #[serde(default = "default_true")]
537 pub business_logic_templates_enabled: bool,
538 #[serde(default)]
543 pub research_mode_enabled: bool,
544 #[serde(default)]
549 pub unsafe_attack_agent_enabled: bool,
550 #[serde(default)]
553 pub business_logic_template_ids: Vec<String>,
554 #[serde(default)]
558 pub exploit_request_cap: Option<u32>,
559 #[serde(default)]
564 pub exploit_requests_per_second: Option<u32>,
565 #[serde(default = "default_true")]
570 pub exploit_reset_after_state_changing: bool,
571 #[serde(default = "default_optional_scanner_enabled")]
575 pub enable_zap_baseline: bool,
576 #[serde(default = "default_optional_scanner_enabled")]
579 pub enable_nuclei: bool,
580 #[serde(default = "default_optional_scanner_enabled")]
584 pub enable_trivy: bool,
585 #[serde(default = "default_optional_scanner_enabled")]
589 pub enable_osv_scanner: bool,
590 #[serde(default = "default_optional_scanner_enabled")]
593 pub enable_secret_scanning: bool,
594 #[serde(default = "default_optional_scanner_enabled")]
598 pub enable_katana: bool,
599 #[serde(default = "default_optional_scanner_enabled")]
603 pub enable_httpx: bool,
604 #[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 pub const DEFAULT_EXPLOIT_REQUEST_CAP: u32 = 10;
642 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 #[serde(default)]
670 pub webhook_secret_ref: Option<String>,
671 #[serde(default)]
675 pub webhook_branch: Option<String>,
676 #[serde(default)]
683 pub webhook_provider: Option<String>,
684 #[serde(default)]
690 pub webhook_max_concurrent: Option<usize>,
691 #[serde(default)]
698 pub webhook_rate_limit_per_minute: Option<u32>,
699}
700
701impl TriggersConfig {
702 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
728#[serde(deny_unknown_fields)]
729pub struct ScheduleConfig {
730 pub cron: String,
733 #[serde(default)]
736 pub repo: Option<String>,
737 #[serde(default = "default_schedule_label")]
740 pub label: String,
741}
742
743fn default_schedule_label() -> String {
744 "scheduled".to_string()
745}
746
747#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
750#[serde(deny_unknown_fields)]
751pub struct ProjectConfig {
752 pub name: String,
755 #[serde(default)]
757 pub description: Option<String>,
758 #[serde(default)]
761 pub target_base_url: Option<String>,
762 #[serde(default)]
766 pub env_config: Option<toml::Value>,
767 #[serde(default)]
771 pub launch: Option<ProjectLaunchConfig>,
772 #[serde(default)]
776 pub runtime_profile: Option<ProjectRuntimeProfile>,
777 #[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 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 #[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 Git {
947 url: String,
948 #[serde(default)]
949 branch: Option<String>,
950 #[serde(default)]
952 auth: Option<String>,
953 },
954 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 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 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}