1use crate::config::traits::ChannelConfig;
2use crate::providers::{is_glm_alias, is_zai_alias};
3use crate::security::{AutonomyLevel, DomainMatcher};
4use anyhow::{Context, Result};
5use directories::UserDirs;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::{OnceLock, RwLock};
11#[cfg(unix)]
12use tokio::fs::File;
13use tokio::fs::{self, OpenOptions};
14use tokio::io::AsyncWriteExt;
15
16const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
17 "provider.anthropic",
18 "provider.compatible",
19 "provider.copilot",
20 "provider.gemini",
21 "provider.glm",
22 "provider.ollama",
23 "provider.openai",
24 "provider.openrouter",
25 "channel.dingtalk",
26 "channel.discord",
27 "channel.feishu",
28 "channel.lark",
29 "channel.matrix",
30 "channel.mattermost",
31 "channel.nextcloud_talk",
32 "channel.qq",
33 "channel.signal",
34 "channel.slack",
35 "channel.telegram",
36 "channel.wati",
37 "channel.whatsapp",
38 "tool.browser",
39 "tool.composio",
40 "tool.http_request",
41 "tool.pushover",
42 "tool.web_search",
43 "memory.embeddings",
44 "tunnel.custom",
45 "transcription.groq",
46];
47
48const SUPPORTED_PROXY_SERVICE_SELECTORS: &[&str] = &[
49 "provider.*",
50 "channel.*",
51 "tool.*",
52 "memory.*",
53 "tunnel.*",
54 "transcription.*",
55];
56
57static RUNTIME_PROXY_CONFIG: OnceLock<RwLock<ProxyConfig>> = OnceLock::new();
58static RUNTIME_PROXY_CLIENT_CACHE: OnceLock<RwLock<HashMap<String, reqwest::Client>>> =
59 OnceLock::new();
60
61#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
67pub struct Config {
68 #[serde(skip)]
70 pub workspace_dir: PathBuf,
71 #[serde(skip)]
73 pub config_path: PathBuf,
74 pub api_key: Option<String>,
76 pub api_url: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub api_path: Option<String>,
82 #[serde(alias = "model_provider")]
84 pub default_provider: Option<String>,
85 #[serde(alias = "model")]
87 pub default_model: Option<String>,
88 #[serde(default)]
90 pub model_providers: HashMap<String, ModelProviderConfig>,
91 #[serde(
93 default = "default_temperature",
94 deserialize_with = "deserialize_temperature"
95 )]
96 pub default_temperature: f64,
97
98 #[serde(default = "default_provider_timeout_secs")]
103 pub provider_timeout_secs: u64,
104
105 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub provider_max_tokens: Option<u32>,
112
113 #[serde(default)]
122 pub extra_headers: HashMap<String, String>,
123
124 #[serde(default)]
126 pub observability: ObservabilityConfig,
127
128 #[serde(default)]
130 pub autonomy: AutonomyConfig,
131
132 #[serde(default)]
134 pub trust: crate::trust::TrustConfig,
135
136 #[serde(default)]
138 pub security: SecurityConfig,
139
140 #[serde(default)]
142 pub backup: BackupConfig,
143
144 #[serde(default)]
146 pub data_retention: DataRetentionConfig,
147
148 #[serde(default)]
150 pub cloud_ops: CloudOpsConfig,
151
152 #[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")]
159 pub conversational_ai: ConversationalAiConfig,
160
161 #[serde(default)]
163 pub security_ops: SecurityOpsConfig,
164
165 #[serde(default)]
167 pub runtime: RuntimeConfig,
168
169 #[serde(default)]
171 pub reliability: ReliabilityConfig,
172
173 #[serde(default)]
175 pub scheduler: SchedulerConfig,
176
177 #[serde(default)]
179 pub agent: AgentConfig,
180
181 #[serde(default)]
183 pub pacing: PacingConfig,
184
185 #[serde(default)]
187 pub skills: SkillsConfig,
188
189 #[serde(default)]
191 pub pipeline: PipelineConfig,
192
193 #[serde(default)]
195 pub model_routes: Vec<ModelRouteConfig>,
196
197 #[serde(default)]
199 pub embedding_routes: Vec<EmbeddingRouteConfig>,
200
201 #[serde(default)]
203 pub query_classification: QueryClassificationConfig,
204
205 #[serde(default)]
207 pub heartbeat: HeartbeatConfig,
208
209 #[serde(default)]
211 pub cron: CronConfig,
212
213 #[serde(default)]
215 pub channels_config: ChannelsConfig,
216
217 #[serde(default)]
219 pub memory: MemoryConfig,
220
221 #[serde(default)]
223 pub storage: StorageConfig,
224
225 #[serde(default)]
227 pub tunnel: TunnelConfig,
228
229 #[serde(default)]
231 pub gateway: GatewayConfig,
232
233 #[serde(default)]
235 pub composio: ComposioConfig,
236
237 #[serde(default)]
239 pub microsoft365: Microsoft365Config,
240
241 #[serde(default)]
243 pub secrets: SecretsConfig,
244
245 #[serde(default)]
247 pub browser: BrowserConfig,
248
249 #[serde(default)]
271 pub browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig,
272
273 #[serde(default)]
275 pub http_request: HttpRequestConfig,
276
277 #[serde(default)]
279 pub multimodal: MultimodalConfig,
280
281 #[serde(default)]
283 pub media_pipeline: MediaPipelineConfig,
284
285 #[serde(default)]
287 pub web_fetch: WebFetchConfig,
288
289 #[serde(default)]
291 pub link_enricher: LinkEnricherConfig,
292
293 #[serde(default)]
295 pub text_browser: TextBrowserConfig,
296
297 #[serde(default)]
299 pub web_search: WebSearchConfig,
300
301 #[serde(default)]
303 pub project_intel: ProjectIntelConfig,
304
305 #[serde(default)]
307 pub google_workspace: GoogleWorkspaceConfig,
308
309 #[serde(default)]
311 pub proxy: ProxyConfig,
312
313 #[serde(default)]
315 pub identity: IdentityConfig,
316
317 #[serde(default)]
319 pub cost: CostConfig,
320
321 #[serde(default)]
323 pub peripherals: PeripheralsConfig,
324
325 #[serde(default)]
327 pub delegate: DelegateToolConfig,
328
329 #[serde(default)]
331 pub agents: HashMap<String, DelegateAgentConfig>,
332
333 #[serde(default)]
335 pub swarms: HashMap<String, SwarmConfig>,
336
337 #[serde(default)]
339 pub hooks: HooksConfig,
340
341 #[serde(default)]
343 pub hardware: HardwareConfig,
344
345 #[serde(default)]
347 pub transcription: TranscriptionConfig,
348
349 #[serde(default)]
351 pub tts: TtsConfig,
352
353 #[serde(default, alias = "mcpServers")]
355 pub mcp: McpConfig,
356
357 #[serde(default)]
363 pub kumiho: KumihoConfig,
364
365 #[serde(default)]
371 pub operator: OperatorConfig,
372
373 #[serde(default)]
375 pub nodes: NodesConfig,
376
377 #[serde(default)]
379 pub clawhub: ClawHubConfig,
380
381 #[serde(default)]
383 pub workspace: WorkspaceConfig,
384
385 #[serde(default)]
387 pub notion: NotionConfig,
388
389 #[serde(default)]
391 pub jira: JiraConfig,
392
393 #[serde(default)]
395 pub node_transport: NodeTransportConfig,
396
397 #[serde(default)]
399 pub linkedin: LinkedInConfig,
400
401 #[serde(default)]
403 pub image_gen: ImageGenConfig,
404
405 #[serde(default)]
407 pub plugins: PluginsConfig,
408
409 #[serde(default)]
418 pub locale: Option<String>,
419
420 #[serde(default)]
422 pub verifiable_intent: VerifiableIntentConfig,
423
424 #[serde(default)]
426 pub claude_code: ClaudeCodeConfig,
427
428 #[serde(default)]
430 pub claude_code_runner: ClaudeCodeRunnerConfig,
431
432 #[serde(default)]
434 pub codex_cli: CodexCliConfig,
435
436 #[serde(default)]
438 pub gemini_cli: GeminiCliConfig,
439
440 #[serde(default)]
442 pub opencode_cli: OpenCodeCliConfig,
443
444 #[serde(default)]
446 pub sop: SopConfig,
447
448 #[serde(default)]
450 pub shell_tool: ShellToolConfig,
451}
452
453#[allow(clippy::struct_excessive_bools)]
458#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
459pub struct WorkspaceConfig {
460 #[serde(default)]
462 pub enabled: bool,
463 #[serde(default)]
465 pub active_workspace: Option<String>,
466 #[serde(default = "default_workspaces_dir")]
468 pub workspaces_dir: String,
469 #[serde(default = "default_true")]
471 pub isolate_memory: bool,
472 #[serde(default = "default_true")]
474 pub isolate_secrets: bool,
475 #[serde(default = "default_true")]
477 pub isolate_audit: bool,
478 #[serde(default)]
480 pub cross_workspace_search: bool,
481}
482
483fn default_workspaces_dir() -> String {
484 "~/.construct/workspaces".to_string()
485}
486
487impl Default for WorkspaceConfig {
488 fn default() -> Self {
489 Self {
490 enabled: false,
491 active_workspace: None,
492 workspaces_dir: default_workspaces_dir(),
493 isolate_memory: true,
494 isolate_secrets: true,
495 isolate_audit: true,
496 cross_workspace_search: false,
497 }
498 }
499}
500
501#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
503pub struct ModelProviderConfig {
504 #[serde(default)]
506 pub name: Option<String>,
507 #[serde(default)]
509 pub base_url: Option<String>,
510 #[serde(default, skip_serializing_if = "Option::is_none")]
513 pub api_path: Option<String>,
514 #[serde(default)]
516 pub wire_api: Option<String>,
517 #[serde(default)]
519 pub requires_openai_auth: bool,
520 #[serde(default, skip_serializing_if = "Option::is_none")]
522 pub azure_openai_resource: Option<String>,
523 #[serde(default, skip_serializing_if = "Option::is_none")]
525 pub azure_openai_deployment: Option<String>,
526 #[serde(default, skip_serializing_if = "Option::is_none")]
528 pub azure_openai_api_version: Option<String>,
529 #[serde(default, skip_serializing_if = "Option::is_none")]
534 pub max_tokens: Option<u32>,
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
541pub struct DelegateToolConfig {
542 #[serde(default = "default_delegate_timeout_secs")]
546 pub timeout_secs: u64,
547 #[serde(default = "default_delegate_agentic_timeout_secs")]
551 pub agentic_timeout_secs: u64,
552}
553
554impl Default for DelegateToolConfig {
555 fn default() -> Self {
556 Self {
557 timeout_secs: DEFAULT_DELEGATE_TIMEOUT_SECS,
558 agentic_timeout_secs: DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS,
559 }
560 }
561}
562
563#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
567pub struct DelegateAgentConfig {
568 pub provider: String,
570 pub model: String,
572 #[serde(default)]
574 pub system_prompt: Option<String>,
575 #[serde(default)]
577 pub api_key: Option<String>,
578 #[serde(default)]
580 pub temperature: Option<f64>,
581 #[serde(default = "default_max_depth")]
583 pub max_depth: u32,
584 #[serde(default)]
586 pub agentic: bool,
587 #[serde(default)]
589 pub allowed_tools: Vec<String>,
590 #[serde(default = "default_max_tool_iterations")]
592 pub max_iterations: usize,
593 #[serde(default)]
596 pub timeout_secs: Option<u64>,
597 #[serde(default)]
600 pub agentic_timeout_secs: Option<u64>,
601 #[serde(default)]
604 pub skills_directory: Option<String>,
605}
606
607fn default_delegate_timeout_secs() -> u64 {
608 DEFAULT_DELEGATE_TIMEOUT_SECS
609}
610
611fn default_delegate_agentic_timeout_secs() -> u64 {
612 DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
619#[serde(rename_all = "snake_case")]
620pub enum SwarmStrategy {
621 Sequential,
623 Parallel,
625 Router,
627}
628
629#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
631pub struct SwarmConfig {
632 pub agents: Vec<String>,
634 pub strategy: SwarmStrategy,
636 #[serde(default, skip_serializing_if = "Option::is_none")]
638 pub router_prompt: Option<String>,
639 #[serde(default, skip_serializing_if = "Option::is_none")]
641 pub description: Option<String>,
642 #[serde(default = "default_swarm_timeout_secs")]
644 pub timeout_secs: u64,
645}
646
647const DEFAULT_SWARM_TIMEOUT_SECS: u64 = 300;
648
649fn default_swarm_timeout_secs() -> u64 {
650 DEFAULT_SWARM_TIMEOUT_SECS
651}
652
653pub const TEMPERATURE_RANGE: std::ops::RangeInclusive<f64> = 0.0..=2.0;
655
656const DEFAULT_TEMPERATURE: f64 = 0.7;
658
659fn default_temperature() -> f64 {
660 DEFAULT_TEMPERATURE
661}
662
663const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 120;
665
666fn default_provider_timeout_secs() -> u64 {
667 DEFAULT_PROVIDER_TIMEOUT_SECS
668}
669
670pub const DEFAULT_DELEGATE_TIMEOUT_SECS: u64 = 120;
672
673pub const DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS: u64 = 300;
675
676pub fn validate_temperature(value: f64) -> std::result::Result<f64, String> {
678 if TEMPERATURE_RANGE.contains(&value) {
679 Ok(value)
680 } else {
681 Err(format!(
682 "temperature {value} is out of range (expected {}..={})",
683 TEMPERATURE_RANGE.start(),
684 TEMPERATURE_RANGE.end()
685 ))
686 }
687}
688
689fn deserialize_temperature<'de, D>(deserializer: D) -> std::result::Result<f64, D::Error>
691where
692 D: serde::Deserializer<'de>,
693{
694 let value: f64 = serde::Deserialize::deserialize(deserializer)?;
695 validate_temperature(value).map_err(serde::de::Error::custom)
696}
697
698fn normalize_reasoning_effort(value: &str) -> std::result::Result<String, String> {
699 let normalized = value.trim().to_ascii_lowercase();
700 match normalized.as_str() {
701 "minimal" | "low" | "medium" | "high" | "xhigh" => Ok(normalized),
702 _ => Err(format!(
703 "reasoning_effort {value:?} is invalid (expected one of: minimal, low, medium, high, xhigh)"
704 )),
705 }
706}
707
708fn deserialize_reasoning_effort_opt<'de, D>(
709 deserializer: D,
710) -> std::result::Result<Option<String>, D::Error>
711where
712 D: serde::Deserializer<'de>,
713{
714 let value: Option<String> = Option::deserialize(deserializer)?;
715 value
716 .map(|raw| normalize_reasoning_effort(&raw).map_err(serde::de::Error::custom))
717 .transpose()
718}
719
720fn default_max_depth() -> u32 {
721 3
722}
723
724fn default_max_tool_iterations() -> usize {
725 10
726}
727
728#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
732pub enum HardwareTransport {
733 #[default]
734 None,
735 Native,
736 Serial,
737 Probe,
738}
739
740impl std::fmt::Display for HardwareTransport {
741 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
742 match self {
743 Self::None => write!(f, "none"),
744 Self::Native => write!(f, "native"),
745 Self::Serial => write!(f, "serial"),
746 Self::Probe => write!(f, "probe"),
747 }
748 }
749}
750
751#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
753pub struct HardwareConfig {
754 #[serde(default)]
756 pub enabled: bool,
757 #[serde(default)]
759 pub transport: HardwareTransport,
760 #[serde(default)]
762 pub serial_port: Option<String>,
763 #[serde(default = "default_baud_rate")]
765 pub baud_rate: u32,
766 #[serde(default)]
768 pub probe_target: Option<String>,
769 #[serde(default)]
771 pub workspace_datasheets: bool,
772}
773
774fn default_baud_rate() -> u32 {
775 115_200
776}
777
778impl HardwareConfig {
779 pub fn transport_mode(&self) -> HardwareTransport {
781 self.transport.clone()
782 }
783}
784
785impl Default for HardwareConfig {
786 fn default() -> Self {
787 Self {
788 enabled: false,
789 transport: HardwareTransport::None,
790 serial_port: None,
791 baud_rate: default_baud_rate(),
792 probe_target: None,
793 workspace_datasheets: false,
794 }
795 }
796}
797
798fn default_transcription_api_url() -> String {
801 "https://api.groq.com/openai/v1/audio/transcriptions".into()
802}
803
804fn default_transcription_model() -> String {
805 "whisper-large-v3-turbo".into()
806}
807
808fn default_transcription_max_duration_secs() -> u64 {
809 120
810}
811
812fn default_transcription_provider() -> String {
813 "groq".into()
814}
815
816fn default_openai_stt_model() -> String {
817 "whisper-1".into()
818}
819
820fn default_deepgram_stt_model() -> String {
821 "nova-2".into()
822}
823
824fn default_google_stt_language_code() -> String {
825 "en-US".into()
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
833pub struct TranscriptionConfig {
834 #[serde(default)]
836 pub enabled: bool,
837 #[serde(default = "default_transcription_provider")]
839 pub default_provider: String,
840 #[serde(default)]
844 pub api_key: Option<String>,
845 #[serde(default = "default_transcription_api_url")]
847 pub api_url: String,
848 #[serde(default = "default_transcription_model")]
850 pub model: String,
851 #[serde(default)]
853 pub language: Option<String>,
854 #[serde(default)]
858 pub initial_prompt: Option<String>,
859 #[serde(default = "default_transcription_max_duration_secs")]
861 pub max_duration_secs: u64,
862 #[serde(default)]
864 pub openai: Option<OpenAiSttConfig>,
865 #[serde(default)]
867 pub deepgram: Option<DeepgramSttConfig>,
868 #[serde(default)]
870 pub assemblyai: Option<AssemblyAiSttConfig>,
871 #[serde(default)]
873 pub google: Option<GoogleSttConfig>,
874 #[serde(default)]
876 pub local_whisper: Option<LocalWhisperConfig>,
877 #[serde(default)]
880 pub transcribe_non_ptt_audio: bool,
881}
882
883impl Default for TranscriptionConfig {
884 fn default() -> Self {
885 Self {
886 enabled: false,
887 default_provider: default_transcription_provider(),
888 api_key: None,
889 api_url: default_transcription_api_url(),
890 model: default_transcription_model(),
891 language: None,
892 initial_prompt: None,
893 max_duration_secs: default_transcription_max_duration_secs(),
894 openai: None,
895 deepgram: None,
896 assemblyai: None,
897 google: None,
898 local_whisper: None,
899 transcribe_non_ptt_audio: false,
900 }
901 }
902}
903
904#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
908#[serde(rename_all = "lowercase")]
909pub enum McpTransport {
910 #[default]
912 Stdio,
913 Http,
915 Sse,
917}
918
919#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
921pub struct McpServerConfig {
922 pub name: String,
924 #[serde(default)]
926 pub transport: McpTransport,
927 #[serde(default)]
929 pub url: Option<String>,
930 #[serde(default)]
932 pub command: String,
933 #[serde(default)]
935 pub args: Vec<String>,
936 #[serde(default)]
938 pub env: HashMap<String, String>,
939 #[serde(default)]
941 pub headers: HashMap<String, String>,
942 #[serde(default)]
944 pub tool_timeout_secs: Option<u64>,
945}
946
947#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
949pub struct McpConfig {
950 #[serde(default)]
952 pub enabled: bool,
953 #[serde(default = "default_deferred_loading")]
958 pub deferred_loading: bool,
959 #[serde(default, alias = "mcpServers")]
961 pub servers: Vec<McpServerConfig>,
962}
963
964fn default_deferred_loading() -> bool {
965 true
966}
967
968impl Default for McpConfig {
969 fn default() -> Self {
970 Self {
971 enabled: false,
972 deferred_loading: default_deferred_loading(),
973 servers: Vec::new(),
974 }
975 }
976}
977
978#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
986pub struct KumihoConfig {
987 #[serde(default = "default_true")]
989 pub enabled: bool,
990
991 #[serde(default = "default_kumiho_mcp_path")]
996 pub mcp_path: String,
997
998 #[serde(default = "default_kumiho_space_prefix")]
1003 pub space_prefix: String,
1004
1005 #[serde(default = "default_kumiho_api_url")]
1010 pub api_url: String,
1011
1012 #[serde(default = "default_kumiho_memory_project")]
1014 pub memory_project: String,
1015
1016 #[serde(default = "default_kumiho_harness_project")]
1018 pub harness_project: String,
1019}
1020
1021fn default_kumiho_mcp_path() -> String {
1022 "~/.construct/kumiho/run_kumiho_mcp.py".to_string()
1023}
1024
1025fn default_kumiho_space_prefix() -> String {
1026 "Construct".to_string()
1027}
1028
1029fn default_kumiho_api_url() -> String {
1030 "https://api.kumiho.cloud".to_string()
1031}
1032
1033fn default_kumiho_memory_project() -> String {
1034 "CognitiveMemory".to_string()
1035}
1036
1037fn default_kumiho_harness_project() -> String {
1038 "Construct".to_string()
1039}
1040
1041impl Default for KumihoConfig {
1042 fn default() -> Self {
1043 Self {
1044 enabled: true,
1045 mcp_path: default_kumiho_mcp_path(),
1046 space_prefix: default_kumiho_space_prefix(),
1047 api_url: default_kumiho_api_url(),
1048 memory_project: default_kumiho_memory_project(),
1049 harness_project: default_kumiho_harness_project(),
1050 }
1051 }
1052}
1053
1054#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1062pub struct OperatorConfig {
1063 #[serde(default = "default_true")]
1065 pub enabled: bool,
1066
1067 #[serde(default)]
1072 pub mcp_path: String,
1073
1074 #[serde(default = "default_operator_max_tool_iterations")]
1082 pub max_tool_iterations: usize,
1083}
1084
1085fn default_operator_max_tool_iterations() -> usize {
1086 80
1087}
1088
1089impl Default for OperatorConfig {
1090 fn default() -> Self {
1091 Self {
1092 enabled: default_true(),
1093 mcp_path: String::new(),
1094 max_tool_iterations: default_operator_max_tool_iterations(),
1095 }
1096 }
1097}
1098
1099#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1107pub struct ClawHubConfig {
1108 #[serde(default = "default_true")]
1110 pub enabled: bool,
1111
1112 #[serde(default)]
1115 pub api_token: Option<String>,
1116
1117 #[serde(default = "default_clawhub_api_url")]
1119 pub api_url: String,
1120}
1121
1122fn default_clawhub_api_url() -> String {
1123 "https://clawhub.ai".to_string()
1124}
1125
1126impl Default for ClawHubConfig {
1127 fn default() -> Self {
1128 Self {
1129 enabled: true,
1130 api_token: None,
1131 api_url: default_clawhub_api_url(),
1132 }
1133 }
1134}
1135
1136#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1138pub struct VerifiableIntentConfig {
1139 #[serde(default)]
1141 pub enabled: bool,
1142
1143 #[serde(default = "default_vi_strictness")]
1147 pub strictness: String,
1148}
1149
1150fn default_vi_strictness() -> String {
1151 "strict".to_owned()
1152}
1153
1154impl Default for VerifiableIntentConfig {
1155 fn default() -> Self {
1156 Self {
1157 enabled: false,
1158 strictness: default_vi_strictness(),
1159 }
1160 }
1161}
1162
1163#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1170pub struct NodesConfig {
1171 #[serde(default)]
1173 pub enabled: bool,
1174 #[serde(default = "default_max_nodes")]
1176 pub max_nodes: usize,
1177 #[serde(default)]
1179 pub auth_token: Option<String>,
1180}
1181
1182fn default_max_nodes() -> usize {
1183 16
1184}
1185
1186impl Default for NodesConfig {
1187 fn default() -> Self {
1188 Self {
1189 enabled: false,
1190 max_nodes: default_max_nodes(),
1191 auth_token: None,
1192 }
1193 }
1194}
1195
1196fn default_tts_provider() -> String {
1199 "openai".into()
1200}
1201
1202fn default_tts_voice() -> String {
1203 "alloy".into()
1204}
1205
1206fn default_tts_format() -> String {
1207 "mp3".into()
1208}
1209
1210fn default_tts_max_text_length() -> usize {
1211 4096
1212}
1213
1214fn default_openai_tts_model() -> String {
1215 "tts-1".into()
1216}
1217
1218fn default_openai_tts_speed() -> f64 {
1219 1.0
1220}
1221
1222fn default_elevenlabs_model_id() -> String {
1223 "eleven_monolingual_v1".into()
1224}
1225
1226fn default_elevenlabs_stability() -> f64 {
1227 0.5
1228}
1229
1230fn default_elevenlabs_similarity_boost() -> f64 {
1231 0.5
1232}
1233
1234fn default_google_tts_language_code() -> String {
1235 "en-US".into()
1236}
1237
1238fn default_edge_tts_binary_path() -> String {
1239 "edge-tts".into()
1240}
1241
1242fn default_piper_tts_api_url() -> String {
1243 "http://127.0.0.1:5000/v1/audio/speech".into()
1244}
1245
1246#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1248pub struct TtsConfig {
1249 #[serde(default)]
1251 pub enabled: bool,
1252 #[serde(default = "default_tts_provider")]
1254 pub default_provider: String,
1255 #[serde(default = "default_tts_voice")]
1257 pub default_voice: String,
1258 #[serde(default = "default_tts_format")]
1260 pub default_format: String,
1261 #[serde(default = "default_tts_max_text_length")]
1263 pub max_text_length: usize,
1264 #[serde(default)]
1266 pub openai: Option<OpenAiTtsConfig>,
1267 #[serde(default)]
1269 pub elevenlabs: Option<ElevenLabsTtsConfig>,
1270 #[serde(default)]
1272 pub google: Option<GoogleTtsConfig>,
1273 #[serde(default)]
1275 pub edge: Option<EdgeTtsConfig>,
1276 #[serde(default)]
1278 pub piper: Option<PiperTtsConfig>,
1279}
1280
1281impl Default for TtsConfig {
1282 fn default() -> Self {
1283 Self {
1284 enabled: false,
1285 default_provider: default_tts_provider(),
1286 default_voice: default_tts_voice(),
1287 default_format: default_tts_format(),
1288 max_text_length: default_tts_max_text_length(),
1289 openai: None,
1290 elevenlabs: None,
1291 google: None,
1292 edge: None,
1293 piper: None,
1294 }
1295 }
1296}
1297
1298#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1300pub struct OpenAiTtsConfig {
1301 #[serde(default)]
1303 pub api_key: Option<String>,
1304 #[serde(default = "default_openai_tts_model")]
1306 pub model: String,
1307 #[serde(default = "default_openai_tts_speed")]
1309 pub speed: f64,
1310}
1311
1312#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1314pub struct ElevenLabsTtsConfig {
1315 #[serde(default)]
1317 pub api_key: Option<String>,
1318 #[serde(default = "default_elevenlabs_model_id")]
1320 pub model_id: String,
1321 #[serde(default = "default_elevenlabs_stability")]
1323 pub stability: f64,
1324 #[serde(default = "default_elevenlabs_similarity_boost")]
1326 pub similarity_boost: f64,
1327}
1328
1329#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1331pub struct GoogleTtsConfig {
1332 #[serde(default)]
1334 pub api_key: Option<String>,
1335 #[serde(default = "default_google_tts_language_code")]
1337 pub language_code: String,
1338}
1339
1340#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1342pub struct EdgeTtsConfig {
1343 #[serde(default = "default_edge_tts_binary_path")]
1345 pub binary_path: String,
1346}
1347
1348#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1350pub struct PiperTtsConfig {
1351 #[serde(default = "default_piper_tts_api_url")]
1353 pub api_url: String,
1354}
1355
1356#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1358#[serde(rename_all = "snake_case")]
1359pub enum ToolFilterGroupMode {
1360 Always,
1362 #[default]
1365 Dynamic,
1366}
1367
1368#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1388pub struct ToolFilterGroup {
1389 #[serde(default)]
1391 pub mode: ToolFilterGroupMode,
1392 #[serde(default)]
1394 pub tools: Vec<String>,
1395 #[serde(default)]
1398 pub keywords: Vec<String>,
1399 #[serde(default)]
1401 pub filter_builtins: bool,
1402}
1403
1404#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1406pub struct OpenAiSttConfig {
1407 #[serde(default)]
1409 pub api_key: Option<String>,
1410 #[serde(default = "default_openai_stt_model")]
1412 pub model: String,
1413}
1414
1415#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1417pub struct DeepgramSttConfig {
1418 #[serde(default)]
1420 pub api_key: Option<String>,
1421 #[serde(default = "default_deepgram_stt_model")]
1423 pub model: String,
1424}
1425
1426#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1428pub struct AssemblyAiSttConfig {
1429 #[serde(default)]
1431 pub api_key: Option<String>,
1432}
1433
1434#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1436pub struct GoogleSttConfig {
1437 #[serde(default)]
1439 pub api_key: Option<String>,
1440 #[serde(default = "default_google_stt_language_code")]
1442 pub language_code: String,
1443}
1444
1445#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1449pub struct LocalWhisperConfig {
1450 pub url: String,
1452 #[serde(default)]
1455 pub bearer_token: Option<String>,
1456 #[serde(default = "default_local_whisper_max_audio_bytes")]
1462 pub max_audio_bytes: usize,
1463 #[serde(default = "default_local_whisper_timeout_secs")]
1465 pub timeout_secs: u64,
1466}
1467
1468fn default_local_whisper_max_audio_bytes() -> usize {
1469 25 * 1024 * 1024
1470}
1471
1472fn default_local_whisper_timeout_secs() -> u64 {
1473 300
1474}
1475
1476#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1478pub struct AgentConfig {
1479 #[serde(default)]
1481 pub compact_context: bool,
1482 #[serde(default = "default_agent_max_tool_iterations")]
1485 pub max_tool_iterations: usize,
1486 #[serde(default = "default_agent_max_history_messages")]
1488 pub max_history_messages: usize,
1489 #[serde(default = "default_agent_max_context_tokens")]
1493 pub max_context_tokens: usize,
1494 #[serde(default)]
1496 pub parallel_tools: bool,
1497 #[serde(default = "default_agent_tool_dispatcher")]
1499 pub tool_dispatcher: String,
1500 #[serde(default)]
1502 pub tool_call_dedup_exempt: Vec<String>,
1503 #[serde(default)]
1509 pub tool_filter_groups: Vec<ToolFilterGroup>,
1510 #[serde(default = "default_max_system_prompt_chars")]
1515 pub max_system_prompt_chars: usize,
1516 #[serde(default)]
1519 pub thinking: crate::agent::thinking::ThinkingConfig,
1520
1521 #[serde(default)]
1523 pub history_pruning: crate::agent::history_pruner::HistoryPrunerConfig,
1524
1525 #[serde(default)]
1527 pub context_aware_tools: bool,
1528
1529 #[serde(default)]
1531 pub eval: crate::agent::eval::EvalConfig,
1532
1533 #[serde(default)]
1535 pub auto_classify: Option<crate::agent::eval::AutoClassifyConfig>,
1536
1537 #[serde(default)]
1539 pub context_compression: crate::agent::context_compressor::ContextCompressionConfig,
1540
1541 #[serde(default = "default_max_tool_result_chars")]
1545 pub max_tool_result_chars: usize,
1546
1547 #[serde(default = "default_keep_tool_context_turns")]
1552 pub keep_tool_context_turns: usize,
1553}
1554
1555fn default_max_tool_result_chars() -> usize {
1556 50_000
1557}
1558
1559fn default_keep_tool_context_turns() -> usize {
1560 2
1561}
1562
1563fn default_agent_max_tool_iterations() -> usize {
1564 10
1565}
1566
1567fn default_agent_max_history_messages() -> usize {
1568 50
1569}
1570
1571fn default_agent_max_context_tokens() -> usize {
1572 32_000
1573}
1574
1575fn default_agent_tool_dispatcher() -> String {
1576 "auto".into()
1577}
1578
1579fn default_max_system_prompt_chars() -> usize {
1580 0
1581}
1582
1583impl Default for AgentConfig {
1584 fn default() -> Self {
1585 Self {
1586 compact_context: true,
1587 max_tool_iterations: default_agent_max_tool_iterations(),
1588 max_history_messages: default_agent_max_history_messages(),
1589 max_context_tokens: default_agent_max_context_tokens(),
1590 parallel_tools: false,
1591 tool_dispatcher: default_agent_tool_dispatcher(),
1592 tool_call_dedup_exempt: Vec::new(),
1593 tool_filter_groups: Vec::new(),
1594 max_system_prompt_chars: default_max_system_prompt_chars(),
1595 thinking: crate::agent::thinking::ThinkingConfig::default(),
1596 history_pruning: crate::agent::history_pruner::HistoryPrunerConfig::default(),
1597 context_aware_tools: false,
1598 eval: crate::agent::eval::EvalConfig::default(),
1599 auto_classify: None,
1600 context_compression:
1601 crate::agent::context_compressor::ContextCompressionConfig::default(),
1602 max_tool_result_chars: default_max_tool_result_chars(),
1603 keep_tool_context_turns: default_keep_tool_context_turns(),
1604 }
1605 }
1606}
1607
1608#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1616pub struct PacingConfig {
1617 #[serde(default)]
1621 pub step_timeout_secs: Option<u64>,
1622
1623 #[serde(default)]
1628 pub loop_detection_min_elapsed_secs: Option<u64>,
1629
1630 #[serde(default)]
1634 pub loop_ignore_tools: Vec<String>,
1635
1636 #[serde(default)]
1642 pub message_timeout_scale_max: Option<u64>,
1643
1644 #[serde(default = "default_loop_detection_enabled")]
1647 pub loop_detection_enabled: bool,
1648
1649 #[serde(default = "default_loop_detection_window_size")]
1652 pub loop_detection_window_size: usize,
1653
1654 #[serde(default = "default_loop_detection_max_repeats")]
1657 pub loop_detection_max_repeats: usize,
1658}
1659
1660fn default_loop_detection_enabled() -> bool {
1661 true
1662}
1663
1664fn default_loop_detection_window_size() -> usize {
1665 20
1666}
1667
1668fn default_loop_detection_max_repeats() -> usize {
1669 3
1670}
1671
1672impl Default for PacingConfig {
1673 fn default() -> Self {
1674 Self {
1675 step_timeout_secs: None,
1676 loop_detection_min_elapsed_secs: None,
1677 loop_ignore_tools: Vec::new(),
1678 message_timeout_scale_max: None,
1679 loop_detection_enabled: default_loop_detection_enabled(),
1680 loop_detection_window_size: default_loop_detection_window_size(),
1681 loop_detection_max_repeats: default_loop_detection_max_repeats(),
1682 }
1683 }
1684}
1685
1686#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1688#[serde(rename_all = "snake_case")]
1689pub enum SkillsPromptInjectionMode {
1690 #[default]
1692 Full,
1693 Compact,
1695}
1696
1697fn parse_skills_prompt_injection_mode(raw: &str) -> Option<SkillsPromptInjectionMode> {
1698 match raw.trim().to_ascii_lowercase().as_str() {
1699 "full" => Some(SkillsPromptInjectionMode::Full),
1700 "compact" => Some(SkillsPromptInjectionMode::Compact),
1701 _ => None,
1702 }
1703}
1704
1705#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
1707pub struct SkillsConfig {
1708 #[serde(default)]
1711 pub open_skills_enabled: bool,
1712 #[serde(default)]
1715 pub open_skills_dir: Option<String>,
1716 #[serde(default)]
1719 pub allow_scripts: bool,
1720 #[serde(default)]
1723 pub prompt_injection_mode: SkillsPromptInjectionMode,
1724 #[serde(default)]
1726 pub skill_creation: SkillCreationConfig,
1727 #[serde(default)]
1729 pub skill_improvement: SkillImprovementConfig,
1730}
1731
1732#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1734#[serde(default)]
1735pub struct SkillCreationConfig {
1736 pub enabled: bool,
1739 pub max_skills: usize,
1742 pub similarity_threshold: f64,
1745}
1746
1747impl Default for SkillCreationConfig {
1748 fn default() -> Self {
1749 Self {
1750 enabled: false,
1751 max_skills: 500,
1752 similarity_threshold: 0.85,
1753 }
1754 }
1755}
1756
1757#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1759pub struct SkillImprovementConfig {
1760 #[serde(default = "default_true")]
1763 pub enabled: bool,
1764 #[serde(default = "default_skill_improvement_cooldown")]
1767 pub cooldown_secs: u64,
1768}
1769
1770fn default_skill_improvement_cooldown() -> u64 {
1771 3600
1772}
1773
1774impl Default for SkillImprovementConfig {
1775 fn default() -> Self {
1776 Self {
1777 enabled: true,
1778 cooldown_secs: 3600,
1779 }
1780 }
1781}
1782
1783#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1785pub struct PipelineConfig {
1786 #[serde(default)]
1789 pub enabled: bool,
1790 #[serde(default = "default_pipeline_max_steps")]
1793 pub max_steps: usize,
1794 #[serde(default)]
1797 pub allowed_tools: Vec<String>,
1798}
1799
1800fn default_pipeline_max_steps() -> usize {
1801 20
1802}
1803
1804impl Default for PipelineConfig {
1805 fn default() -> Self {
1806 Self {
1807 enabled: false,
1808 max_steps: 20,
1809 allowed_tools: Vec::new(),
1810 }
1811 }
1812}
1813
1814#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1816pub struct MultimodalConfig {
1817 #[serde(default = "default_multimodal_max_images")]
1819 pub max_images: usize,
1820 #[serde(default = "default_multimodal_max_image_size_mb")]
1822 pub max_image_size_mb: usize,
1823 #[serde(default)]
1825 pub allow_remote_fetch: bool,
1826 #[serde(default)]
1830 pub vision_provider: Option<String>,
1831 #[serde(default)]
1834 pub vision_model: Option<String>,
1835}
1836
1837fn default_multimodal_max_images() -> usize {
1838 4
1839}
1840
1841fn default_multimodal_max_image_size_mb() -> usize {
1842 5
1843}
1844
1845impl MultimodalConfig {
1846 pub fn effective_limits(&self) -> (usize, usize) {
1848 let max_images = self.max_images.clamp(1, 16);
1849 let max_image_size_mb = self.max_image_size_mb.clamp(1, 20);
1850 (max_images, max_image_size_mb)
1851 }
1852}
1853
1854impl Default for MultimodalConfig {
1855 fn default() -> Self {
1856 Self {
1857 max_images: default_multimodal_max_images(),
1858 max_image_size_mb: default_multimodal_max_image_size_mb(),
1859 allow_remote_fetch: false,
1860 vision_provider: None,
1861 vision_model: None,
1862 }
1863 }
1864}
1865
1866#[allow(clippy::struct_excessive_bools)]
1874#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1875pub struct MediaPipelineConfig {
1876 #[serde(default)]
1878 pub enabled: bool,
1879
1880 #[serde(default = "default_true")]
1882 pub transcribe_audio: bool,
1883
1884 #[serde(default = "default_true")]
1886 pub describe_images: bool,
1887
1888 #[serde(default = "default_true")]
1890 pub summarize_video: bool,
1891}
1892
1893impl Default for MediaPipelineConfig {
1894 fn default() -> Self {
1895 Self {
1896 enabled: false,
1897 transcribe_audio: true,
1898 describe_images: true,
1899 summarize_video: true,
1900 }
1901 }
1902}
1903
1904#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1910pub struct IdentityConfig {
1911 #[serde(default = "default_identity_format")]
1913 pub format: String,
1914 #[serde(default)]
1916 pub aieos_path: Option<String>,
1917 #[serde(default)]
1919 pub aieos_inline: Option<String>,
1920}
1921
1922fn default_identity_format() -> String {
1923 "openclaw".into()
1924}
1925
1926impl Default for IdentityConfig {
1927 fn default() -> Self {
1928 Self {
1929 format: default_identity_format(),
1930 aieos_path: None,
1931 aieos_inline: None,
1932 }
1933 }
1934}
1935
1936#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1940pub struct CostConfig {
1941 #[serde(default = "default_cost_enabled")]
1943 pub enabled: bool,
1944
1945 #[serde(default = "default_daily_limit")]
1947 pub daily_limit_usd: f64,
1948
1949 #[serde(default = "default_monthly_limit")]
1951 pub monthly_limit_usd: f64,
1952
1953 #[serde(default = "default_warn_percent")]
1955 pub warn_at_percent: u8,
1956
1957 #[serde(default)]
1959 pub allow_override: bool,
1960
1961 #[serde(default)]
1963 pub prices: std::collections::HashMap<String, ModelPricing>,
1964
1965 #[serde(default)]
1967 pub enforcement: CostEnforcementConfig,
1968}
1969
1970#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1972pub struct CostEnforcementConfig {
1973 #[serde(default = "default_cost_enforcement_mode")]
1975 pub mode: String,
1976 #[serde(default)]
1978 pub route_down_model: Option<String>,
1979 #[serde(default = "default_reserve_percent")]
1981 pub reserve_percent: u8,
1982}
1983
1984fn default_cost_enforcement_mode() -> String {
1985 "warn".to_string()
1986}
1987
1988fn default_reserve_percent() -> u8 {
1989 10
1990}
1991
1992impl Default for CostEnforcementConfig {
1993 fn default() -> Self {
1994 Self {
1995 mode: default_cost_enforcement_mode(),
1996 route_down_model: None,
1997 reserve_percent: default_reserve_percent(),
1998 }
1999 }
2000}
2001
2002#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2004pub struct ModelPricing {
2005 #[serde(default)]
2007 pub input: f64,
2008
2009 #[serde(default)]
2011 pub output: f64,
2012}
2013
2014fn default_daily_limit() -> f64 {
2015 10.0
2016}
2017
2018fn default_monthly_limit() -> f64 {
2019 100.0
2020}
2021
2022fn default_warn_percent() -> u8 {
2023 80
2024}
2025
2026fn default_cost_enabled() -> bool {
2027 true
2028}
2029
2030impl Default for CostConfig {
2031 fn default() -> Self {
2032 Self {
2033 enabled: true,
2034 daily_limit_usd: default_daily_limit(),
2035 monthly_limit_usd: default_monthly_limit(),
2036 warn_at_percent: default_warn_percent(),
2037 allow_override: false,
2038 prices: get_default_pricing(),
2039 enforcement: CostEnforcementConfig::default(),
2040 }
2041 }
2042}
2043
2044fn get_default_pricing() -> std::collections::HashMap<String, ModelPricing> {
2046 let mut prices = std::collections::HashMap::new();
2047
2048 prices.insert(
2050 "anthropic/claude-sonnet-4-20250514".into(),
2051 ModelPricing {
2052 input: 3.0,
2053 output: 15.0,
2054 },
2055 );
2056 prices.insert(
2057 "anthropic/claude-opus-4-20250514".into(),
2058 ModelPricing {
2059 input: 15.0,
2060 output: 75.0,
2061 },
2062 );
2063 prices.insert(
2064 "anthropic/claude-3.5-sonnet".into(),
2065 ModelPricing {
2066 input: 3.0,
2067 output: 15.0,
2068 },
2069 );
2070 prices.insert(
2071 "anthropic/claude-3-haiku".into(),
2072 ModelPricing {
2073 input: 0.25,
2074 output: 1.25,
2075 },
2076 );
2077
2078 prices.insert(
2080 "openai/gpt-4o".into(),
2081 ModelPricing {
2082 input: 5.0,
2083 output: 15.0,
2084 },
2085 );
2086 prices.insert(
2087 "openai/gpt-4o-mini".into(),
2088 ModelPricing {
2089 input: 0.15,
2090 output: 0.60,
2091 },
2092 );
2093 prices.insert(
2094 "openai/o1-preview".into(),
2095 ModelPricing {
2096 input: 15.0,
2097 output: 60.0,
2098 },
2099 );
2100
2101 prices.insert(
2103 "openai-codex/gpt-5.4".into(),
2104 ModelPricing {
2105 input: 1.25,
2106 output: 10.0,
2107 },
2108 );
2109 prices.insert(
2110 "openai-codex/gpt-5".into(),
2111 ModelPricing {
2112 input: 1.25,
2113 output: 10.0,
2114 },
2115 );
2116
2117 prices.insert(
2119 "google/gemini-2.0-flash".into(),
2120 ModelPricing {
2121 input: 0.10,
2122 output: 0.40,
2123 },
2124 );
2125 prices.insert(
2126 "google/gemini-1.5-pro".into(),
2127 ModelPricing {
2128 input: 1.25,
2129 output: 5.0,
2130 },
2131 );
2132
2133 prices
2134}
2135
2136#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2142pub struct PeripheralsConfig {
2143 #[serde(default)]
2145 pub enabled: bool,
2146 #[serde(default)]
2148 pub boards: Vec<PeripheralBoardConfig>,
2149 #[serde(default)]
2152 pub datasheet_dir: Option<String>,
2153}
2154
2155#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2157pub struct PeripheralBoardConfig {
2158 pub board: String,
2160 #[serde(default = "default_peripheral_transport")]
2162 pub transport: String,
2163 #[serde(default)]
2165 pub path: Option<String>,
2166 #[serde(default = "default_peripheral_baud")]
2168 pub baud: u32,
2169}
2170
2171fn default_peripheral_transport() -> String {
2172 "serial".into()
2173}
2174
2175fn default_peripheral_baud() -> u32 {
2176 115_200
2177}
2178
2179impl Default for PeripheralBoardConfig {
2180 fn default() -> Self {
2181 Self {
2182 board: String::new(),
2183 transport: default_peripheral_transport(),
2184 path: None,
2185 baud: default_peripheral_baud(),
2186 }
2187 }
2188}
2189
2190#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2196#[allow(clippy::struct_excessive_bools)]
2197pub struct GatewayConfig {
2198 #[serde(default = "default_gateway_port")]
2200 pub port: u16,
2201 #[serde(default = "default_gateway_host")]
2203 pub host: String,
2204 #[serde(default = "default_true")]
2206 pub require_pairing: bool,
2207 #[serde(default)]
2209 pub allow_public_bind: bool,
2210 #[serde(default)]
2212 pub paired_tokens: Vec<String>,
2213
2214 #[serde(default = "default_pair_rate_limit")]
2216 pub pair_rate_limit_per_minute: u32,
2217
2218 #[serde(default = "default_webhook_rate_limit")]
2220 pub webhook_rate_limit_per_minute: u32,
2221
2222 #[serde(default)]
2225 pub trust_forwarded_headers: bool,
2226
2227 #[serde(default)]
2231 pub path_prefix: Option<String>,
2232
2233 #[serde(default = "default_gateway_rate_limit_max_keys")]
2235 pub rate_limit_max_keys: usize,
2236
2237 #[serde(default = "default_idempotency_ttl_secs")]
2239 pub idempotency_ttl_secs: u64,
2240
2241 #[serde(default = "default_gateway_idempotency_max_keys")]
2243 pub idempotency_max_keys: usize,
2244
2245 #[serde(default = "default_true")]
2247 pub session_persistence: bool,
2248
2249 #[serde(default)]
2251 pub session_ttl_hours: u32,
2252
2253 #[serde(default)]
2255 pub pairing_dashboard: PairingDashboardConfig,
2256
2257 #[serde(default)]
2259 pub tls: Option<GatewayTlsConfig>,
2260}
2261
2262fn default_gateway_port() -> u16 {
2263 42617
2264}
2265
2266fn default_gateway_host() -> String {
2267 "127.0.0.1".into()
2268}
2269
2270fn default_pair_rate_limit() -> u32 {
2271 10
2272}
2273
2274fn default_webhook_rate_limit() -> u32 {
2275 60
2276}
2277
2278fn default_idempotency_ttl_secs() -> u64 {
2279 300
2280}
2281
2282fn default_gateway_rate_limit_max_keys() -> usize {
2283 10_000
2284}
2285
2286fn default_gateway_idempotency_max_keys() -> usize {
2287 10_000
2288}
2289
2290fn default_true() -> bool {
2291 true
2292}
2293
2294fn default_false() -> bool {
2295 false
2296}
2297
2298impl Default for GatewayConfig {
2299 fn default() -> Self {
2300 Self {
2301 port: default_gateway_port(),
2302 host: default_gateway_host(),
2303 require_pairing: true,
2304 allow_public_bind: false,
2305 paired_tokens: Vec::new(),
2306 pair_rate_limit_per_minute: default_pair_rate_limit(),
2307 webhook_rate_limit_per_minute: default_webhook_rate_limit(),
2308 trust_forwarded_headers: false,
2309 path_prefix: None,
2310 rate_limit_max_keys: default_gateway_rate_limit_max_keys(),
2311 idempotency_ttl_secs: default_idempotency_ttl_secs(),
2312 idempotency_max_keys: default_gateway_idempotency_max_keys(),
2313 session_persistence: true,
2314 session_ttl_hours: 0,
2315 pairing_dashboard: PairingDashboardConfig::default(),
2316 tls: None,
2317 }
2318 }
2319}
2320
2321#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2323pub struct PairingDashboardConfig {
2324 #[serde(default = "default_pairing_code_length")]
2326 pub code_length: usize,
2327 #[serde(default = "default_pairing_ttl")]
2329 pub code_ttl_secs: u64,
2330 #[serde(default = "default_max_pending_codes")]
2332 pub max_pending_codes: usize,
2333 #[serde(default = "default_max_failed_attempts")]
2335 pub max_failed_attempts: u32,
2336 #[serde(default = "default_pairing_lockout_secs")]
2338 pub lockout_secs: u64,
2339}
2340
2341fn default_pairing_code_length() -> usize {
2342 8
2343}
2344fn default_pairing_ttl() -> u64 {
2345 3600
2346}
2347fn default_max_pending_codes() -> usize {
2348 3
2349}
2350fn default_max_failed_attempts() -> u32 {
2351 5
2352}
2353fn default_pairing_lockout_secs() -> u64 {
2354 300
2355}
2356
2357impl Default for PairingDashboardConfig {
2358 fn default() -> Self {
2359 Self {
2360 code_length: default_pairing_code_length(),
2361 code_ttl_secs: default_pairing_ttl(),
2362 max_pending_codes: default_max_pending_codes(),
2363 max_failed_attempts: default_max_failed_attempts(),
2364 lockout_secs: default_pairing_lockout_secs(),
2365 }
2366 }
2367}
2368
2369#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2371pub struct GatewayTlsConfig {
2372 #[serde(default)]
2374 pub enabled: bool,
2375 pub cert_path: String,
2377 pub key_path: String,
2379 #[serde(default)]
2381 pub client_auth: Option<GatewayClientAuthConfig>,
2382}
2383
2384#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2386pub struct GatewayClientAuthConfig {
2387 #[serde(default)]
2389 pub enabled: bool,
2390 pub ca_cert_path: String,
2392 #[serde(default = "default_true")]
2394 pub require_client_cert: bool,
2395 #[serde(default)]
2398 pub pinned_certs: Vec<String>,
2399}
2400
2401#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2403pub struct NodeTransportConfig {
2404 #[serde(default = "default_node_transport_enabled")]
2406 pub enabled: bool,
2407 #[serde(default)]
2409 pub shared_secret: String,
2410 #[serde(default = "default_max_request_age")]
2412 pub max_request_age_secs: i64,
2413 #[serde(default = "default_require_https")]
2415 pub require_https: bool,
2416 #[serde(default)]
2418 pub allowed_peers: Vec<String>,
2419 #[serde(default)]
2421 pub tls_cert_path: Option<String>,
2422 #[serde(default)]
2424 pub tls_key_path: Option<String>,
2425 #[serde(default)]
2427 pub mutual_tls: bool,
2428 #[serde(default = "default_connection_pool_size")]
2430 pub connection_pool_size: usize,
2431}
2432
2433fn default_node_transport_enabled() -> bool {
2434 true
2435}
2436fn default_max_request_age() -> i64 {
2437 300
2438}
2439fn default_require_https() -> bool {
2440 true
2441}
2442fn default_connection_pool_size() -> usize {
2443 4
2444}
2445
2446impl Default for NodeTransportConfig {
2447 fn default() -> Self {
2448 Self {
2449 enabled: default_node_transport_enabled(),
2450 shared_secret: String::new(),
2451 max_request_age_secs: default_max_request_age(),
2452 require_https: default_require_https(),
2453 allowed_peers: Vec::new(),
2454 tls_cert_path: None,
2455 tls_key_path: None,
2456 mutual_tls: false,
2457 connection_pool_size: default_connection_pool_size(),
2458 }
2459 }
2460}
2461
2462#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2468pub struct ComposioConfig {
2469 #[serde(default, alias = "enable")]
2471 pub enabled: bool,
2472 #[serde(default)]
2474 pub api_key: Option<String>,
2475 #[serde(default = "default_entity_id")]
2477 pub entity_id: String,
2478}
2479
2480fn default_entity_id() -> String {
2481 "default".into()
2482}
2483
2484impl Default for ComposioConfig {
2485 fn default() -> Self {
2486 Self {
2487 enabled: false,
2488 api_key: None,
2489 entity_id: default_entity_id(),
2490 }
2491 }
2492}
2493
2494#[derive(Clone, Serialize, Deserialize, JsonSchema)]
2501pub struct Microsoft365Config {
2502 #[serde(default, alias = "enable")]
2504 pub enabled: bool,
2505 #[serde(default)]
2507 pub tenant_id: Option<String>,
2508 #[serde(default)]
2510 pub client_id: Option<String>,
2511 #[serde(default)]
2513 pub client_secret: Option<String>,
2514 #[serde(default = "default_ms365_auth_flow")]
2516 pub auth_flow: String,
2517 #[serde(default = "default_ms365_scopes")]
2519 pub scopes: Vec<String>,
2520 #[serde(default = "default_true")]
2522 pub token_cache_encrypted: bool,
2523 #[serde(default)]
2525 pub user_id: Option<String>,
2526}
2527
2528fn default_ms365_auth_flow() -> String {
2529 "client_credentials".to_string()
2530}
2531
2532fn default_ms365_scopes() -> Vec<String> {
2533 vec!["https://graph.microsoft.com/.default".to_string()]
2534}
2535
2536impl std::fmt::Debug for Microsoft365Config {
2537 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2538 f.debug_struct("Microsoft365Config")
2539 .field("enabled", &self.enabled)
2540 .field("tenant_id", &self.tenant_id)
2541 .field("client_id", &self.client_id)
2542 .field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
2543 .field("auth_flow", &self.auth_flow)
2544 .field("scopes", &self.scopes)
2545 .field("token_cache_encrypted", &self.token_cache_encrypted)
2546 .field("user_id", &self.user_id)
2547 .finish()
2548 }
2549}
2550
2551impl Default for Microsoft365Config {
2552 fn default() -> Self {
2553 Self {
2554 enabled: false,
2555 tenant_id: None,
2556 client_id: None,
2557 client_secret: None,
2558 auth_flow: default_ms365_auth_flow(),
2559 scopes: default_ms365_scopes(),
2560 token_cache_encrypted: true,
2561 user_id: None,
2562 }
2563 }
2564}
2565
2566#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2570pub struct SecretsConfig {
2571 #[serde(default = "default_true")]
2573 pub encrypt: bool,
2574}
2575
2576impl Default for SecretsConfig {
2577 fn default() -> Self {
2578 Self { encrypt: true }
2579 }
2580}
2581
2582#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2588pub struct BrowserComputerUseConfig {
2589 #[serde(default = "default_browser_computer_use_endpoint")]
2591 pub endpoint: String,
2592 #[serde(default)]
2594 pub api_key: Option<String>,
2595 #[serde(default = "default_browser_computer_use_timeout_ms")]
2597 pub timeout_ms: u64,
2598 #[serde(default)]
2600 pub allow_remote_endpoint: bool,
2601 #[serde(default)]
2603 pub window_allowlist: Vec<String>,
2604 #[serde(default)]
2606 pub max_coordinate_x: Option<i64>,
2607 #[serde(default)]
2609 pub max_coordinate_y: Option<i64>,
2610}
2611
2612fn default_browser_computer_use_endpoint() -> String {
2613 "http://127.0.0.1:8787/v1/actions".into()
2614}
2615
2616fn default_browser_computer_use_timeout_ms() -> u64 {
2617 15_000
2618}
2619
2620impl Default for BrowserComputerUseConfig {
2621 fn default() -> Self {
2622 Self {
2623 endpoint: default_browser_computer_use_endpoint(),
2624 api_key: None,
2625 timeout_ms: default_browser_computer_use_timeout_ms(),
2626 allow_remote_endpoint: false,
2627 window_allowlist: Vec::new(),
2628 max_coordinate_x: None,
2629 max_coordinate_y: None,
2630 }
2631 }
2632}
2633
2634#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2638pub struct BrowserConfig {
2639 #[serde(default)]
2641 pub enabled: bool,
2642 #[serde(default)]
2644 pub allowed_domains: Vec<String>,
2645 #[serde(default)]
2647 pub session_name: Option<String>,
2648 #[serde(default = "default_browser_backend")]
2650 pub backend: String,
2651 #[serde(default = "default_true")]
2653 pub native_headless: bool,
2654 #[serde(default = "default_browser_webdriver_url")]
2656 pub native_webdriver_url: String,
2657 #[serde(default)]
2659 pub native_chrome_path: Option<String>,
2660 #[serde(default)]
2662 pub computer_use: BrowserComputerUseConfig,
2663}
2664
2665fn default_browser_backend() -> String {
2666 "agent_browser".into()
2667}
2668
2669fn default_browser_webdriver_url() -> String {
2670 "http://127.0.0.1:9515".into()
2671}
2672
2673impl Default for BrowserConfig {
2674 fn default() -> Self {
2675 Self {
2676 enabled: true,
2677 allowed_domains: vec!["*".into()],
2678 session_name: None,
2679 backend: default_browser_backend(),
2680 native_headless: default_true(),
2681 native_webdriver_url: default_browser_webdriver_url(),
2682 native_chrome_path: None,
2683 computer_use: BrowserComputerUseConfig::default(),
2684 }
2685 }
2686}
2687
2688#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2696pub struct HttpRequestConfig {
2697 #[serde(default)]
2699 pub enabled: bool,
2700 #[serde(default)]
2702 pub allowed_domains: Vec<String>,
2703 #[serde(default = "default_http_max_response_size")]
2705 pub max_response_size: usize,
2706 #[serde(default = "default_http_timeout_secs")]
2708 pub timeout_secs: u64,
2709 #[serde(default)]
2712 pub allow_private_hosts: bool,
2713}
2714
2715impl Default for HttpRequestConfig {
2716 fn default() -> Self {
2717 Self {
2718 enabled: true,
2719 allowed_domains: vec!["*".into()],
2720 max_response_size: default_http_max_response_size(),
2721 timeout_secs: default_http_timeout_secs(),
2722 allow_private_hosts: false,
2723 }
2724 }
2725}
2726
2727fn default_http_max_response_size() -> usize {
2728 1_000_000 }
2730
2731fn default_http_timeout_secs() -> u64 {
2732 30
2733}
2734
2735#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2744pub struct WebFetchConfig {
2745 #[serde(default)]
2747 pub enabled: bool,
2748 #[serde(default = "default_web_fetch_allowed_domains")]
2750 pub allowed_domains: Vec<String>,
2751 #[serde(default)]
2753 pub blocked_domains: Vec<String>,
2754 #[serde(default)]
2756 pub allowed_private_hosts: Vec<String>,
2757 #[serde(default = "default_web_fetch_max_response_size")]
2759 pub max_response_size: usize,
2760 #[serde(default = "default_web_fetch_timeout_secs")]
2762 pub timeout_secs: u64,
2763 #[serde(default)]
2765 pub firecrawl: FirecrawlConfig,
2766}
2767
2768#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
2770#[serde(rename_all = "lowercase")]
2771pub enum FirecrawlMode {
2772 #[default]
2773 Scrape,
2774 Crawl,
2778}
2779
2780#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2786pub struct FirecrawlConfig {
2787 #[serde(default)]
2789 pub enabled: bool,
2790 #[serde(default = "default_firecrawl_api_key_env")]
2792 pub api_key_env: String,
2793 #[serde(default = "default_firecrawl_api_url")]
2795 pub api_url: String,
2796 #[serde(default)]
2798 pub mode: FirecrawlMode,
2799}
2800
2801fn default_firecrawl_api_key_env() -> String {
2802 "FIRECRAWL_API_KEY".into()
2803}
2804
2805fn default_firecrawl_api_url() -> String {
2806 "https://api.firecrawl.dev/v1".into()
2807}
2808
2809impl Default for FirecrawlConfig {
2810 fn default() -> Self {
2811 Self {
2812 enabled: false,
2813 api_key_env: default_firecrawl_api_key_env(),
2814 api_url: default_firecrawl_api_url(),
2815 mode: FirecrawlMode::default(),
2816 }
2817 }
2818}
2819
2820fn default_web_fetch_max_response_size() -> usize {
2821 500_000 }
2823
2824fn default_web_fetch_timeout_secs() -> u64 {
2825 30
2826}
2827
2828fn default_web_fetch_allowed_domains() -> Vec<String> {
2829 vec!["*".into()]
2830}
2831
2832impl Default for WebFetchConfig {
2833 fn default() -> Self {
2834 Self {
2835 enabled: true,
2836 allowed_domains: vec!["*".into()],
2837 blocked_domains: vec![],
2838 allowed_private_hosts: vec![],
2839 max_response_size: default_web_fetch_max_response_size(),
2840 timeout_secs: default_web_fetch_timeout_secs(),
2841 firecrawl: FirecrawlConfig::default(),
2842 }
2843 }
2844}
2845
2846#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2855pub struct LinkEnricherConfig {
2856 #[serde(default)]
2858 pub enabled: bool,
2859 #[serde(default = "default_link_enricher_max_links")]
2861 pub max_links: usize,
2862 #[serde(default = "default_link_enricher_timeout_secs")]
2864 pub timeout_secs: u64,
2865}
2866
2867fn default_link_enricher_max_links() -> usize {
2868 3
2869}
2870
2871fn default_link_enricher_timeout_secs() -> u64 {
2872 10
2873}
2874
2875impl Default for LinkEnricherConfig {
2876 fn default() -> Self {
2877 Self {
2878 enabled: false,
2879 max_links: default_link_enricher_max_links(),
2880 timeout_secs: default_link_enricher_timeout_secs(),
2881 }
2882 }
2883}
2884
2885#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2892pub struct TextBrowserConfig {
2893 #[serde(default)]
2895 pub enabled: bool,
2896 #[serde(default)]
2898 pub preferred_browser: Option<String>,
2899 #[serde(default = "default_text_browser_timeout_secs")]
2901 pub timeout_secs: u64,
2902}
2903
2904fn default_text_browser_timeout_secs() -> u64 {
2905 30
2906}
2907
2908impl Default for TextBrowserConfig {
2909 fn default() -> Self {
2910 Self {
2911 enabled: false,
2912 preferred_browser: None,
2913 timeout_secs: default_text_browser_timeout_secs(),
2914 }
2915 }
2916}
2917
2918#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2926pub struct ShellToolConfig {
2927 #[serde(default = "default_shell_tool_timeout_secs")]
2929 pub timeout_secs: u64,
2930}
2931
2932fn default_shell_tool_timeout_secs() -> u64 {
2933 60
2934}
2935
2936impl Default for ShellToolConfig {
2937 fn default() -> Self {
2938 Self {
2939 timeout_secs: default_shell_tool_timeout_secs(),
2940 }
2941 }
2942}
2943
2944#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2948pub struct WebSearchConfig {
2949 #[serde(default)]
2951 pub enabled: bool,
2952 #[serde(default = "default_web_search_provider")]
2954 pub provider: String,
2955 #[serde(default)]
2957 pub brave_api_key: Option<String>,
2958 #[serde(default)]
2960 pub searxng_instance_url: Option<String>,
2961 #[serde(default = "default_web_search_max_results")]
2963 pub max_results: usize,
2964 #[serde(default = "default_web_search_timeout_secs")]
2966 pub timeout_secs: u64,
2967}
2968
2969fn default_web_search_provider() -> String {
2970 "duckduckgo".into()
2971}
2972
2973fn default_web_search_max_results() -> usize {
2974 5
2975}
2976
2977fn default_web_search_timeout_secs() -> u64 {
2978 15
2979}
2980
2981impl Default for WebSearchConfig {
2982 fn default() -> Self {
2983 Self {
2984 enabled: true,
2985 provider: default_web_search_provider(),
2986 brave_api_key: None,
2987 searxng_instance_url: None,
2988 max_results: default_web_search_max_results(),
2989 timeout_secs: default_web_search_timeout_secs(),
2990 }
2991 }
2992}
2993
2994#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2998pub struct ProjectIntelConfig {
2999 #[serde(default)]
3001 pub enabled: bool,
3002 #[serde(default = "default_project_intel_language")]
3004 pub default_language: String,
3005 #[serde(default = "default_project_intel_report_dir")]
3007 pub report_output_dir: String,
3008 #[serde(default)]
3010 pub templates_dir: Option<String>,
3011 #[serde(default = "default_project_intel_risk_sensitivity")]
3013 pub risk_sensitivity: String,
3014 #[serde(default = "default_true")]
3016 pub include_git_data: bool,
3017 #[serde(default)]
3019 pub include_jira_data: bool,
3020 #[serde(default)]
3022 pub jira_base_url: Option<String>,
3023}
3024
3025fn default_project_intel_language() -> String {
3026 "en".into()
3027}
3028
3029fn default_project_intel_report_dir() -> String {
3030 "~/.construct/project-reports".into()
3031}
3032
3033fn default_project_intel_risk_sensitivity() -> String {
3034 "medium".into()
3035}
3036
3037impl Default for ProjectIntelConfig {
3038 fn default() -> Self {
3039 Self {
3040 enabled: false,
3041 default_language: default_project_intel_language(),
3042 report_output_dir: default_project_intel_report_dir(),
3043 templates_dir: None,
3044 risk_sensitivity: default_project_intel_risk_sensitivity(),
3045 include_git_data: true,
3046 include_jira_data: false,
3047 jira_base_url: None,
3048 }
3049 }
3050}
3051
3052#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3056pub struct BackupConfig {
3057 #[serde(default = "default_true")]
3059 pub enabled: bool,
3060 #[serde(default = "default_backup_max_keep")]
3062 pub max_keep: usize,
3063 #[serde(default = "default_backup_include_dirs")]
3065 pub include_dirs: Vec<String>,
3066 #[serde(default = "default_backup_destination_dir")]
3068 pub destination_dir: String,
3069 #[serde(default)]
3071 pub schedule_cron: Option<String>,
3072 #[serde(default)]
3074 pub schedule_timezone: Option<String>,
3075 #[serde(default = "default_true")]
3077 pub compress: bool,
3078 #[serde(default)]
3080 pub encrypt: bool,
3081}
3082
3083fn default_backup_max_keep() -> usize {
3084 10
3085}
3086
3087fn default_backup_include_dirs() -> Vec<String> {
3088 vec![
3089 "config".into(),
3090 "memory".into(),
3091 "audit".into(),
3092 "knowledge".into(),
3093 ]
3094}
3095
3096fn default_backup_destination_dir() -> String {
3097 "state/backups".into()
3098}
3099
3100impl Default for BackupConfig {
3101 fn default() -> Self {
3102 Self {
3103 enabled: true,
3104 max_keep: default_backup_max_keep(),
3105 include_dirs: default_backup_include_dirs(),
3106 destination_dir: default_backup_destination_dir(),
3107 schedule_cron: None,
3108 schedule_timezone: None,
3109 compress: true,
3110 encrypt: false,
3111 }
3112 }
3113}
3114
3115#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3119pub struct DataRetentionConfig {
3120 #[serde(default)]
3122 pub enabled: bool,
3123 #[serde(default = "default_retention_days")]
3125 pub retention_days: u64,
3126 #[serde(default)]
3128 pub dry_run: bool,
3129 #[serde(default)]
3131 pub categories: Vec<String>,
3132}
3133
3134fn default_retention_days() -> u64 {
3135 90
3136}
3137
3138impl Default for DataRetentionConfig {
3139 fn default() -> Self {
3140 Self {
3141 enabled: false,
3142 retention_days: default_retention_days(),
3143 dry_run: false,
3144 categories: Vec::new(),
3145 }
3146 }
3147}
3148
3149pub const DEFAULT_GWS_SERVICES: &[&str] = &[
3158 "drive",
3159 "sheets",
3160 "gmail",
3161 "calendar",
3162 "docs",
3163 "slides",
3164 "tasks",
3165 "people",
3166 "chat",
3167 "classroom",
3168 "forms",
3169 "keep",
3170 "meet",
3171 "events",
3172];
3173
3174#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3202pub struct GoogleWorkspaceAllowedOperation {
3203 pub service: String,
3205 pub resource: String,
3207 #[serde(default)]
3212 pub sub_resource: Option<String>,
3213 #[serde(default)]
3215 pub methods: Vec<String>,
3216}
3217
3218#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3243pub struct GoogleWorkspaceConfig {
3244 #[serde(default)]
3246 pub enabled: bool,
3247 #[serde(default)]
3254 pub allowed_services: Vec<String>,
3255 #[serde(default)]
3268 pub allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
3269 #[serde(default)]
3275 pub credentials_path: Option<String>,
3276 #[serde(default)]
3280 pub default_account: Option<String>,
3281 #[serde(default = "default_gws_rate_limit")]
3283 pub rate_limit_per_minute: u32,
3284 #[serde(default = "default_gws_timeout_secs")]
3286 pub timeout_secs: u64,
3287 #[serde(default)]
3290 pub audit_log: bool,
3291}
3292
3293fn default_gws_rate_limit() -> u32 {
3294 60
3295}
3296
3297fn default_gws_timeout_secs() -> u64 {
3298 30
3299}
3300
3301impl Default for GoogleWorkspaceConfig {
3302 fn default() -> Self {
3303 Self {
3304 enabled: false,
3305 allowed_services: Vec::new(),
3306 allowed_operations: Vec::new(),
3307 credentials_path: None,
3308 default_account: None,
3309 rate_limit_per_minute: default_gws_rate_limit(),
3310 timeout_secs: default_gws_timeout_secs(),
3311 audit_log: false,
3312 }
3313 }
3314}
3315
3316#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3323pub struct LinkedInConfig {
3324 #[serde(default)]
3326 pub enabled: bool,
3327
3328 #[serde(default = "default_linkedin_api_version")]
3330 pub api_version: String,
3331
3332 #[serde(default)]
3334 pub content: LinkedInContentConfig,
3335
3336 #[serde(default)]
3338 pub image: LinkedInImageConfig,
3339}
3340
3341impl Default for LinkedInConfig {
3342 fn default() -> Self {
3343 Self {
3344 enabled: false,
3345 api_version: default_linkedin_api_version(),
3346 content: LinkedInContentConfig::default(),
3347 image: LinkedInImageConfig::default(),
3348 }
3349 }
3350}
3351
3352fn default_linkedin_api_version() -> String {
3353 "202602".to_string()
3354}
3355
3356#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3358pub struct PluginsConfig {
3359 #[serde(default)]
3361 pub enabled: bool,
3362 #[serde(default = "default_plugins_dir")]
3364 pub plugins_dir: String,
3365 #[serde(default)]
3367 pub auto_discover: bool,
3368 #[serde(default = "default_max_plugins")]
3370 pub max_plugins: usize,
3371 #[serde(default)]
3373 pub security: PluginSecurityConfig,
3374}
3375
3376#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3383pub struct PluginSecurityConfig {
3384 #[serde(default = "default_signature_mode")]
3386 pub signature_mode: String,
3387 #[serde(default)]
3389 pub trusted_publisher_keys: Vec<String>,
3390}
3391
3392fn default_signature_mode() -> String {
3393 "disabled".to_string()
3394}
3395
3396impl Default for PluginSecurityConfig {
3397 fn default() -> Self {
3398 Self {
3399 signature_mode: default_signature_mode(),
3400 trusted_publisher_keys: Vec::new(),
3401 }
3402 }
3403}
3404
3405fn default_plugins_dir() -> String {
3406 "~/.construct/plugins".to_string()
3407}
3408
3409fn default_max_plugins() -> usize {
3410 50
3411}
3412
3413impl Default for PluginsConfig {
3414 fn default() -> Self {
3415 Self {
3416 enabled: false,
3417 plugins_dir: default_plugins_dir(),
3418 auto_discover: false,
3419 max_plugins: default_max_plugins(),
3420 security: PluginSecurityConfig::default(),
3421 }
3422 }
3423}
3424
3425#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
3430pub struct LinkedInContentConfig {
3431 #[serde(default)]
3433 pub rss_feeds: Vec<String>,
3434
3435 #[serde(default)]
3437 pub github_users: Vec<String>,
3438
3439 #[serde(default)]
3441 pub github_repos: Vec<String>,
3442
3443 #[serde(default)]
3445 pub topics: Vec<String>,
3446
3447 #[serde(default)]
3449 pub persona: String,
3450
3451 #[serde(default)]
3453 pub instructions: String,
3454}
3455
3456#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3458pub struct LinkedInImageConfig {
3459 #[serde(default)]
3461 pub enabled: bool,
3462
3463 #[serde(default = "default_image_providers")]
3465 pub providers: Vec<String>,
3466
3467 #[serde(default = "default_true")]
3469 pub fallback_card: bool,
3470
3471 #[serde(default = "default_card_accent_color")]
3473 pub card_accent_color: String,
3474
3475 #[serde(default = "default_image_temp_dir")]
3477 pub temp_dir: String,
3478
3479 #[serde(default)]
3481 pub stability: ImageProviderStabilityConfig,
3482
3483 #[serde(default)]
3485 pub imagen: ImageProviderImagenConfig,
3486
3487 #[serde(default)]
3489 pub dalle: ImageProviderDalleConfig,
3490
3491 #[serde(default)]
3493 pub flux: ImageProviderFluxConfig,
3494}
3495
3496fn default_image_providers() -> Vec<String> {
3497 vec![
3498 "stability".into(),
3499 "imagen".into(),
3500 "dalle".into(),
3501 "flux".into(),
3502 ]
3503}
3504
3505fn default_card_accent_color() -> String {
3506 "#0A66C2".into()
3507}
3508
3509fn default_image_temp_dir() -> String {
3510 "linkedin/images".into()
3511}
3512
3513impl Default for LinkedInImageConfig {
3514 fn default() -> Self {
3515 Self {
3516 enabled: false,
3517 providers: default_image_providers(),
3518 fallback_card: true,
3519 card_accent_color: default_card_accent_color(),
3520 temp_dir: default_image_temp_dir(),
3521 stability: ImageProviderStabilityConfig::default(),
3522 imagen: ImageProviderImagenConfig::default(),
3523 dalle: ImageProviderDalleConfig::default(),
3524 flux: ImageProviderFluxConfig::default(),
3525 }
3526 }
3527}
3528
3529#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3531pub struct ImageProviderStabilityConfig {
3532 #[serde(default = "default_stability_api_key_env")]
3534 pub api_key_env: String,
3535 #[serde(default = "default_stability_model")]
3537 pub model: String,
3538}
3539
3540fn default_stability_api_key_env() -> String {
3541 "STABILITY_API_KEY".into()
3542}
3543fn default_stability_model() -> String {
3544 "stable-diffusion-xl-1024-v1-0".into()
3545}
3546
3547impl Default for ImageProviderStabilityConfig {
3548 fn default() -> Self {
3549 Self {
3550 api_key_env: default_stability_api_key_env(),
3551 model: default_stability_model(),
3552 }
3553 }
3554}
3555
3556#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3558pub struct ImageProviderImagenConfig {
3559 #[serde(default = "default_imagen_api_key_env")]
3561 pub api_key_env: String,
3562 #[serde(default = "default_imagen_project_id_env")]
3564 pub project_id_env: String,
3565 #[serde(default = "default_imagen_region")]
3567 pub region: String,
3568}
3569
3570fn default_imagen_api_key_env() -> String {
3571 "GOOGLE_VERTEX_API_KEY".into()
3572}
3573fn default_imagen_project_id_env() -> String {
3574 "GOOGLE_CLOUD_PROJECT".into()
3575}
3576fn default_imagen_region() -> String {
3577 "us-central1".into()
3578}
3579
3580impl Default for ImageProviderImagenConfig {
3581 fn default() -> Self {
3582 Self {
3583 api_key_env: default_imagen_api_key_env(),
3584 project_id_env: default_imagen_project_id_env(),
3585 region: default_imagen_region(),
3586 }
3587 }
3588}
3589
3590#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3592pub struct ImageProviderDalleConfig {
3593 #[serde(default = "default_dalle_api_key_env")]
3595 pub api_key_env: String,
3596 #[serde(default = "default_dalle_model")]
3598 pub model: String,
3599 #[serde(default = "default_dalle_size")]
3601 pub size: String,
3602}
3603
3604fn default_dalle_api_key_env() -> String {
3605 "OPENAI_API_KEY".into()
3606}
3607fn default_dalle_model() -> String {
3608 "dall-e-3".into()
3609}
3610fn default_dalle_size() -> String {
3611 "1024x1024".into()
3612}
3613
3614impl Default for ImageProviderDalleConfig {
3615 fn default() -> Self {
3616 Self {
3617 api_key_env: default_dalle_api_key_env(),
3618 model: default_dalle_model(),
3619 size: default_dalle_size(),
3620 }
3621 }
3622}
3623
3624#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3626pub struct ImageProviderFluxConfig {
3627 #[serde(default = "default_flux_api_key_env")]
3629 pub api_key_env: String,
3630 #[serde(default = "default_flux_model")]
3632 pub model: String,
3633}
3634
3635fn default_flux_api_key_env() -> String {
3636 "FAL_API_KEY".into()
3637}
3638fn default_flux_model() -> String {
3639 "fal-ai/flux/schnell".into()
3640}
3641
3642impl Default for ImageProviderFluxConfig {
3643 fn default() -> Self {
3644 Self {
3645 api_key_env: default_flux_api_key_env(),
3646 model: default_flux_model(),
3647 }
3648 }
3649}
3650
3651#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3659pub struct ImageGenConfig {
3660 #[serde(default)]
3662 pub enabled: bool,
3663
3664 #[serde(default = "default_image_gen_model")]
3666 pub default_model: String,
3667
3668 #[serde(default = "default_image_gen_api_key_env")]
3670 pub api_key_env: String,
3671}
3672
3673fn default_image_gen_model() -> String {
3674 "fal-ai/flux/schnell".into()
3675}
3676
3677fn default_image_gen_api_key_env() -> String {
3678 "FAL_API_KEY".into()
3679}
3680
3681impl Default for ImageGenConfig {
3682 fn default() -> Self {
3683 Self {
3684 enabled: false,
3685 default_model: default_image_gen_model(),
3686 api_key_env: default_image_gen_api_key_env(),
3687 }
3688 }
3689}
3690
3691#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3699pub struct ClaudeCodeConfig {
3700 #[serde(default)]
3702 pub enabled: bool,
3703 #[serde(default = "default_claude_code_timeout_secs")]
3705 pub timeout_secs: u64,
3706 #[serde(default = "default_claude_code_allowed_tools")]
3708 pub allowed_tools: Vec<String>,
3709 #[serde(default)]
3711 pub system_prompt: Option<String>,
3712 #[serde(default = "default_claude_code_max_output_bytes")]
3714 pub max_output_bytes: usize,
3715 #[serde(default)]
3717 pub env_passthrough: Vec<String>,
3718}
3719
3720fn default_claude_code_timeout_secs() -> u64 {
3721 600
3722}
3723
3724fn default_claude_code_allowed_tools() -> Vec<String> {
3725 vec!["Read".into(), "Edit".into(), "Bash".into(), "Write".into()]
3726}
3727
3728fn default_claude_code_max_output_bytes() -> usize {
3729 2_097_152
3730}
3731
3732impl Default for ClaudeCodeConfig {
3733 fn default() -> Self {
3734 Self {
3735 enabled: false,
3736 timeout_secs: default_claude_code_timeout_secs(),
3737 allowed_tools: default_claude_code_allowed_tools(),
3738 system_prompt: None,
3739 max_output_bytes: default_claude_code_max_output_bytes(),
3740 env_passthrough: Vec::new(),
3741 }
3742 }
3743}
3744
3745#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3753pub struct ClaudeCodeRunnerConfig {
3754 #[serde(default)]
3756 pub enabled: bool,
3757 #[serde(default)]
3759 pub ssh_host: Option<String>,
3760 #[serde(default = "default_claude_code_runner_tmux_prefix")]
3762 pub tmux_prefix: String,
3763 #[serde(default = "default_claude_code_runner_session_ttl")]
3765 pub session_ttl: u64,
3766}
3767
3768fn default_claude_code_runner_tmux_prefix() -> String {
3769 "zc-claude-".into()
3770}
3771
3772fn default_claude_code_runner_session_ttl() -> u64 {
3773 3600
3774}
3775
3776impl Default for ClaudeCodeRunnerConfig {
3777 fn default() -> Self {
3778 Self {
3779 enabled: false,
3780 ssh_host: None,
3781 tmux_prefix: default_claude_code_runner_tmux_prefix(),
3782 session_ttl: default_claude_code_runner_session_ttl(),
3783 }
3784 }
3785}
3786
3787#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3795pub struct CodexCliConfig {
3796 #[serde(default)]
3798 pub enabled: bool,
3799 #[serde(default = "default_codex_cli_timeout_secs")]
3801 pub timeout_secs: u64,
3802 #[serde(default = "default_codex_cli_max_output_bytes")]
3804 pub max_output_bytes: usize,
3805 #[serde(default)]
3807 pub env_passthrough: Vec<String>,
3808}
3809
3810fn default_codex_cli_timeout_secs() -> u64 {
3811 600
3812}
3813
3814fn default_codex_cli_max_output_bytes() -> usize {
3815 2_097_152
3816}
3817
3818impl Default for CodexCliConfig {
3819 fn default() -> Self {
3820 Self {
3821 enabled: false,
3822 timeout_secs: default_codex_cli_timeout_secs(),
3823 max_output_bytes: default_codex_cli_max_output_bytes(),
3824 env_passthrough: Vec::new(),
3825 }
3826 }
3827}
3828
3829#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3837pub struct GeminiCliConfig {
3838 #[serde(default)]
3840 pub enabled: bool,
3841 #[serde(default = "default_gemini_cli_timeout_secs")]
3843 pub timeout_secs: u64,
3844 #[serde(default = "default_gemini_cli_max_output_bytes")]
3846 pub max_output_bytes: usize,
3847 #[serde(default)]
3849 pub env_passthrough: Vec<String>,
3850}
3851
3852fn default_gemini_cli_timeout_secs() -> u64 {
3853 600
3854}
3855
3856fn default_gemini_cli_max_output_bytes() -> usize {
3857 2_097_152
3858}
3859
3860impl Default for GeminiCliConfig {
3861 fn default() -> Self {
3862 Self {
3863 enabled: false,
3864 timeout_secs: default_gemini_cli_timeout_secs(),
3865 max_output_bytes: default_gemini_cli_max_output_bytes(),
3866 env_passthrough: Vec::new(),
3867 }
3868 }
3869}
3870
3871#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3879pub struct OpenCodeCliConfig {
3880 #[serde(default)]
3882 pub enabled: bool,
3883 #[serde(default = "default_opencode_cli_timeout_secs")]
3885 pub timeout_secs: u64,
3886 #[serde(default = "default_opencode_cli_max_output_bytes")]
3888 pub max_output_bytes: usize,
3889 #[serde(default)]
3891 pub env_passthrough: Vec<String>,
3892}
3893
3894fn default_opencode_cli_timeout_secs() -> u64 {
3895 600
3896}
3897
3898fn default_opencode_cli_max_output_bytes() -> usize {
3899 2_097_152
3900}
3901
3902impl Default for OpenCodeCliConfig {
3903 fn default() -> Self {
3904 Self {
3905 enabled: false,
3906 timeout_secs: default_opencode_cli_timeout_secs(),
3907 max_output_bytes: default_opencode_cli_max_output_bytes(),
3908 env_passthrough: Vec::new(),
3909 }
3910 }
3911}
3912
3913#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, JsonSchema)]
3917#[serde(rename_all = "snake_case")]
3918pub enum ProxyScope {
3919 Environment,
3921 #[default]
3923 Construct,
3924 Services,
3926}
3927
3928#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3930pub struct ProxyConfig {
3931 #[serde(default)]
3933 pub enabled: bool,
3934 #[serde(default)]
3936 pub http_proxy: Option<String>,
3937 #[serde(default)]
3939 pub https_proxy: Option<String>,
3940 #[serde(default)]
3942 pub all_proxy: Option<String>,
3943 #[serde(default)]
3945 pub no_proxy: Vec<String>,
3946 #[serde(default)]
3948 pub scope: ProxyScope,
3949 #[serde(default)]
3951 pub services: Vec<String>,
3952}
3953
3954impl Default for ProxyConfig {
3955 fn default() -> Self {
3956 Self {
3957 enabled: false,
3958 http_proxy: None,
3959 https_proxy: None,
3960 all_proxy: None,
3961 no_proxy: Vec::new(),
3962 scope: ProxyScope::Construct,
3963 services: Vec::new(),
3964 }
3965 }
3966}
3967
3968impl ProxyConfig {
3969 pub fn supported_service_keys() -> &'static [&'static str] {
3970 SUPPORTED_PROXY_SERVICE_KEYS
3971 }
3972
3973 pub fn supported_service_selectors() -> &'static [&'static str] {
3974 SUPPORTED_PROXY_SERVICE_SELECTORS
3975 }
3976
3977 pub fn has_any_proxy_url(&self) -> bool {
3978 normalize_proxy_url_option(self.http_proxy.as_deref()).is_some()
3979 || normalize_proxy_url_option(self.https_proxy.as_deref()).is_some()
3980 || normalize_proxy_url_option(self.all_proxy.as_deref()).is_some()
3981 }
3982
3983 pub fn normalized_services(&self) -> Vec<String> {
3984 normalize_service_list(self.services.clone())
3985 }
3986
3987 pub fn normalized_no_proxy(&self) -> Vec<String> {
3988 normalize_no_proxy_list(self.no_proxy.clone())
3989 }
3990
3991 pub fn validate(&self) -> Result<()> {
3992 for (field, value) in [
3993 ("http_proxy", self.http_proxy.as_deref()),
3994 ("https_proxy", self.https_proxy.as_deref()),
3995 ("all_proxy", self.all_proxy.as_deref()),
3996 ] {
3997 if let Some(url) = normalize_proxy_url_option(value) {
3998 validate_proxy_url(field, &url)?;
3999 }
4000 }
4001
4002 for selector in self.normalized_services() {
4003 if !is_supported_proxy_service_selector(&selector) {
4004 anyhow::bail!(
4005 "Unsupported proxy service selector '{selector}'. Use tool `proxy_config` action `list_services` for valid values"
4006 );
4007 }
4008 }
4009
4010 if self.enabled && !self.has_any_proxy_url() {
4011 anyhow::bail!(
4012 "Proxy is enabled but no proxy URL is configured. Set at least one of http_proxy, https_proxy, or all_proxy"
4013 );
4014 }
4015
4016 if self.enabled
4017 && self.scope == ProxyScope::Services
4018 && self.normalized_services().is_empty()
4019 {
4020 anyhow::bail!(
4021 "proxy.scope='services' requires a non-empty proxy.services list when proxy is enabled"
4022 );
4023 }
4024
4025 Ok(())
4026 }
4027
4028 pub fn should_apply_to_service(&self, service_key: &str) -> bool {
4029 if !self.enabled {
4030 return false;
4031 }
4032
4033 match self.scope {
4034 ProxyScope::Environment => false,
4035 ProxyScope::Construct => true,
4036 ProxyScope::Services => {
4037 let service_key = service_key.trim().to_ascii_lowercase();
4038 if service_key.is_empty() {
4039 return false;
4040 }
4041
4042 self.normalized_services()
4043 .iter()
4044 .any(|selector| service_selector_matches(selector, &service_key))
4045 }
4046 }
4047 }
4048
4049 pub fn apply_to_reqwest_builder(
4050 &self,
4051 mut builder: reqwest::ClientBuilder,
4052 service_key: &str,
4053 ) -> reqwest::ClientBuilder {
4054 if !self.should_apply_to_service(service_key) {
4055 return builder;
4056 }
4057
4058 let no_proxy = self.no_proxy_value();
4059
4060 if let Some(url) = normalize_proxy_url_option(self.all_proxy.as_deref()) {
4061 match reqwest::Proxy::all(&url) {
4062 Ok(proxy) => {
4063 builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
4064 }
4065 Err(error) => {
4066 tracing::warn!(
4067 proxy_url = %url,
4068 service_key,
4069 "Ignoring invalid all_proxy URL: {error}"
4070 );
4071 }
4072 }
4073 }
4074
4075 if let Some(url) = normalize_proxy_url_option(self.http_proxy.as_deref()) {
4076 match reqwest::Proxy::http(&url) {
4077 Ok(proxy) => {
4078 builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
4079 }
4080 Err(error) => {
4081 tracing::warn!(
4082 proxy_url = %url,
4083 service_key,
4084 "Ignoring invalid http_proxy URL: {error}"
4085 );
4086 }
4087 }
4088 }
4089
4090 if let Some(url) = normalize_proxy_url_option(self.https_proxy.as_deref()) {
4091 match reqwest::Proxy::https(&url) {
4092 Ok(proxy) => {
4093 builder = builder.proxy(apply_no_proxy(proxy, no_proxy));
4094 }
4095 Err(error) => {
4096 tracing::warn!(
4097 proxy_url = %url,
4098 service_key,
4099 "Ignoring invalid https_proxy URL: {error}"
4100 );
4101 }
4102 }
4103 }
4104
4105 builder
4106 }
4107
4108 pub fn apply_to_process_env(&self) {
4109 set_proxy_env_pair("HTTP_PROXY", self.http_proxy.as_deref());
4110 set_proxy_env_pair("HTTPS_PROXY", self.https_proxy.as_deref());
4111 set_proxy_env_pair("ALL_PROXY", self.all_proxy.as_deref());
4112
4113 let no_proxy_joined = {
4114 let list = self.normalized_no_proxy();
4115 (!list.is_empty()).then(|| list.join(","))
4116 };
4117 set_proxy_env_pair("NO_PROXY", no_proxy_joined.as_deref());
4118 }
4119
4120 pub fn clear_process_env() {
4121 clear_proxy_env_pair("HTTP_PROXY");
4122 clear_proxy_env_pair("HTTPS_PROXY");
4123 clear_proxy_env_pair("ALL_PROXY");
4124 clear_proxy_env_pair("NO_PROXY");
4125 }
4126
4127 fn no_proxy_value(&self) -> Option<reqwest::NoProxy> {
4128 let joined = {
4129 let list = self.normalized_no_proxy();
4130 (!list.is_empty()).then(|| list.join(","))
4131 };
4132 joined.as_deref().and_then(reqwest::NoProxy::from_string)
4133 }
4134}
4135
4136fn apply_no_proxy(proxy: reqwest::Proxy, no_proxy: Option<reqwest::NoProxy>) -> reqwest::Proxy {
4137 proxy.no_proxy(no_proxy)
4138}
4139
4140fn normalize_proxy_url_option(raw: Option<&str>) -> Option<String> {
4141 let value = raw?.trim();
4142 (!value.is_empty()).then(|| value.to_string())
4143}
4144
4145fn normalize_no_proxy_list(values: Vec<String>) -> Vec<String> {
4146 normalize_comma_values(values)
4147}
4148
4149fn normalize_service_list(values: Vec<String>) -> Vec<String> {
4150 let mut normalized = normalize_comma_values(values)
4151 .into_iter()
4152 .map(|value| value.to_ascii_lowercase())
4153 .collect::<Vec<_>>();
4154 normalized.sort_unstable();
4155 normalized.dedup();
4156 normalized
4157}
4158
4159fn normalize_comma_values(values: Vec<String>) -> Vec<String> {
4160 let mut output = Vec::new();
4161 for value in values {
4162 for part in value.split(',') {
4163 let normalized = part.trim();
4164 if normalized.is_empty() {
4165 continue;
4166 }
4167 output.push(normalized.to_string());
4168 }
4169 }
4170 output.sort_unstable();
4171 output.dedup();
4172 output
4173}
4174
4175fn is_supported_proxy_service_selector(selector: &str) -> bool {
4176 if SUPPORTED_PROXY_SERVICE_KEYS
4177 .iter()
4178 .any(|known| known.eq_ignore_ascii_case(selector))
4179 {
4180 return true;
4181 }
4182
4183 SUPPORTED_PROXY_SERVICE_SELECTORS
4184 .iter()
4185 .any(|known| known.eq_ignore_ascii_case(selector))
4186}
4187
4188fn service_selector_matches(selector: &str, service_key: &str) -> bool {
4189 if selector == service_key {
4190 return true;
4191 }
4192
4193 if let Some(prefix) = selector.strip_suffix(".*") {
4194 return service_key.starts_with(prefix)
4195 && service_key
4196 .strip_prefix(prefix)
4197 .is_some_and(|suffix| suffix.starts_with('.'));
4198 }
4199
4200 false
4201}
4202
4203const MCP_MAX_TOOL_TIMEOUT_SECS: u64 = 600;
4204
4205fn validate_mcp_config(config: &McpConfig) -> Result<()> {
4206 let mut seen_names = std::collections::HashSet::new();
4207 for (i, server) in config.servers.iter().enumerate() {
4208 let name = server.name.trim();
4209 if name.is_empty() {
4210 anyhow::bail!("mcp.servers[{i}].name must not be empty");
4211 }
4212 if !seen_names.insert(name.to_ascii_lowercase()) {
4213 anyhow::bail!("mcp.servers contains duplicate name: {name}");
4214 }
4215
4216 if let Some(timeout) = server.tool_timeout_secs {
4217 if timeout == 0 {
4218 anyhow::bail!("mcp.servers[{i}].tool_timeout_secs must be greater than 0");
4219 }
4220 if timeout > MCP_MAX_TOOL_TIMEOUT_SECS {
4221 anyhow::bail!(
4222 "mcp.servers[{i}].tool_timeout_secs exceeds max {MCP_MAX_TOOL_TIMEOUT_SECS}"
4223 );
4224 }
4225 }
4226
4227 match server.transport {
4228 McpTransport::Stdio => {
4229 if server.command.trim().is_empty() {
4230 anyhow::bail!(
4231 "mcp.servers[{i}] with transport=stdio requires non-empty command"
4232 );
4233 }
4234 }
4235 McpTransport::Http | McpTransport::Sse => {
4236 let url = server
4237 .url
4238 .as_deref()
4239 .map(str::trim)
4240 .filter(|value| !value.is_empty())
4241 .ok_or_else(|| {
4242 anyhow::anyhow!(
4243 "mcp.servers[{i}] with transport={} requires url",
4244 match server.transport {
4245 McpTransport::Http => "http",
4246 McpTransport::Sse => "sse",
4247 McpTransport::Stdio => "stdio",
4248 }
4249 )
4250 })?;
4251 let parsed = reqwest::Url::parse(url)
4252 .with_context(|| format!("mcp.servers[{i}].url is not a valid URL"))?;
4253 if !matches!(parsed.scheme(), "http" | "https") {
4254 anyhow::bail!("mcp.servers[{i}].url must use http/https");
4255 }
4256 }
4257 }
4258 }
4259 Ok(())
4260}
4261
4262fn validate_proxy_url(field: &str, url: &str) -> Result<()> {
4263 let parsed = reqwest::Url::parse(url)
4264 .with_context(|| format!("Invalid {field} URL: '{url}' is not a valid URL"))?;
4265
4266 match parsed.scheme() {
4267 "http" | "https" | "socks5" | "socks5h" | "socks" => {}
4268 scheme => {
4269 anyhow::bail!(
4270 "Invalid {field} URL scheme '{scheme}'. Allowed: http, https, socks5, socks5h, socks"
4271 );
4272 }
4273 }
4274
4275 if parsed.host_str().is_none() {
4276 anyhow::bail!("Invalid {field} URL: host is required");
4277 }
4278
4279 Ok(())
4280}
4281
4282fn set_proxy_env_pair(key: &str, value: Option<&str>) {
4283 let lowercase_key = key.to_ascii_lowercase();
4284 if let Some(value) = value.and_then(|candidate| normalize_proxy_url_option(Some(candidate))) {
4285 unsafe {
4287 std::env::set_var(key, &value);
4288 std::env::set_var(lowercase_key, value);
4289 }
4290 } else {
4291 unsafe {
4293 std::env::remove_var(key);
4294 std::env::remove_var(lowercase_key);
4295 }
4296 }
4297}
4298
4299fn clear_proxy_env_pair(key: &str) {
4300 unsafe {
4302 std::env::remove_var(key);
4303 std::env::remove_var(key.to_ascii_lowercase());
4304 }
4305}
4306
4307fn runtime_proxy_state() -> &'static RwLock<ProxyConfig> {
4308 RUNTIME_PROXY_CONFIG.get_or_init(|| RwLock::new(ProxyConfig::default()))
4309}
4310
4311fn runtime_proxy_client_cache() -> &'static RwLock<HashMap<String, reqwest::Client>> {
4312 RUNTIME_PROXY_CLIENT_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
4313}
4314
4315fn clear_runtime_proxy_client_cache() {
4316 match runtime_proxy_client_cache().write() {
4317 Ok(mut guard) => {
4318 guard.clear();
4319 }
4320 Err(poisoned) => {
4321 poisoned.into_inner().clear();
4322 }
4323 }
4324}
4325
4326fn runtime_proxy_cache_key(
4327 service_key: &str,
4328 timeout_secs: Option<u64>,
4329 connect_timeout_secs: Option<u64>,
4330) -> String {
4331 format!(
4332 "{}|timeout={}|connect_timeout={}",
4333 service_key.trim().to_ascii_lowercase(),
4334 timeout_secs
4335 .map(|value| value.to_string())
4336 .unwrap_or_else(|| "none".to_string()),
4337 connect_timeout_secs
4338 .map(|value| value.to_string())
4339 .unwrap_or_else(|| "none".to_string())
4340 )
4341}
4342
4343fn runtime_proxy_cached_client(cache_key: &str) -> Option<reqwest::Client> {
4344 match runtime_proxy_client_cache().read() {
4345 Ok(guard) => guard.get(cache_key).cloned(),
4346 Err(poisoned) => poisoned.into_inner().get(cache_key).cloned(),
4347 }
4348}
4349
4350fn set_runtime_proxy_cached_client(cache_key: String, client: reqwest::Client) {
4351 match runtime_proxy_client_cache().write() {
4352 Ok(mut guard) => {
4353 guard.insert(cache_key, client);
4354 }
4355 Err(poisoned) => {
4356 poisoned.into_inner().insert(cache_key, client);
4357 }
4358 }
4359}
4360
4361pub fn set_runtime_proxy_config(config: ProxyConfig) {
4362 match runtime_proxy_state().write() {
4363 Ok(mut guard) => {
4364 *guard = config;
4365 }
4366 Err(poisoned) => {
4367 *poisoned.into_inner() = config;
4368 }
4369 }
4370
4371 clear_runtime_proxy_client_cache();
4372}
4373
4374pub fn runtime_proxy_config() -> ProxyConfig {
4375 match runtime_proxy_state().read() {
4376 Ok(guard) => guard.clone(),
4377 Err(poisoned) => poisoned.into_inner().clone(),
4378 }
4379}
4380
4381pub fn apply_runtime_proxy_to_builder(
4382 builder: reqwest::ClientBuilder,
4383 service_key: &str,
4384) -> reqwest::ClientBuilder {
4385 runtime_proxy_config().apply_to_reqwest_builder(builder, service_key)
4386}
4387
4388pub fn build_runtime_proxy_client(service_key: &str) -> reqwest::Client {
4389 let cache_key = runtime_proxy_cache_key(service_key, None, None);
4390 if let Some(client) = runtime_proxy_cached_client(&cache_key) {
4391 return client;
4392 }
4393
4394 let builder = apply_runtime_proxy_to_builder(reqwest::Client::builder(), service_key);
4395 let client = builder.build().unwrap_or_else(|error| {
4396 tracing::warn!(service_key, "Failed to build proxied client: {error}");
4397 reqwest::Client::new()
4398 });
4399 set_runtime_proxy_cached_client(cache_key, client.clone());
4400 client
4401}
4402
4403pub fn build_runtime_proxy_client_with_timeouts(
4404 service_key: &str,
4405 timeout_secs: u64,
4406 connect_timeout_secs: u64,
4407) -> reqwest::Client {
4408 let cache_key =
4409 runtime_proxy_cache_key(service_key, Some(timeout_secs), Some(connect_timeout_secs));
4410 if let Some(client) = runtime_proxy_cached_client(&cache_key) {
4411 return client;
4412 }
4413
4414 let builder = reqwest::Client::builder()
4415 .timeout(std::time::Duration::from_secs(timeout_secs))
4416 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs));
4417 let builder = apply_runtime_proxy_to_builder(builder, service_key);
4418 let client = builder.build().unwrap_or_else(|error| {
4419 tracing::warn!(
4420 service_key,
4421 "Failed to build proxied timeout client: {error}"
4422 );
4423 reqwest::Client::new()
4424 });
4425 set_runtime_proxy_cached_client(cache_key, client.clone());
4426 client
4427}
4428
4429pub fn build_channel_proxy_client(service_key: &str, proxy_url: Option<&str>) -> reqwest::Client {
4433 match normalize_proxy_url_option(proxy_url) {
4434 Some(url) => build_explicit_proxy_client(service_key, &url, None, None),
4435 None => build_runtime_proxy_client(service_key),
4436 }
4437}
4438
4439pub fn build_channel_proxy_client_with_timeouts(
4443 service_key: &str,
4444 proxy_url: Option<&str>,
4445 timeout_secs: u64,
4446 connect_timeout_secs: u64,
4447) -> reqwest::Client {
4448 match normalize_proxy_url_option(proxy_url) {
4449 Some(url) => build_explicit_proxy_client(
4450 service_key,
4451 &url,
4452 Some(timeout_secs),
4453 Some(connect_timeout_secs),
4454 ),
4455 None => build_runtime_proxy_client_with_timeouts(
4456 service_key,
4457 timeout_secs,
4458 connect_timeout_secs,
4459 ),
4460 }
4461}
4462
4463pub fn apply_channel_proxy_to_builder(
4466 builder: reqwest::ClientBuilder,
4467 service_key: &str,
4468 proxy_url: Option<&str>,
4469) -> reqwest::ClientBuilder {
4470 match normalize_proxy_url_option(proxy_url) {
4471 Some(url) => apply_explicit_proxy_to_builder(builder, service_key, &url),
4472 None => apply_runtime_proxy_to_builder(builder, service_key),
4473 }
4474}
4475
4476fn build_explicit_proxy_client(
4478 service_key: &str,
4479 proxy_url: &str,
4480 timeout_secs: Option<u64>,
4481 connect_timeout_secs: Option<u64>,
4482) -> reqwest::Client {
4483 let cache_key = format!(
4484 "explicit|{}|{}|timeout={}|connect_timeout={}",
4485 service_key.trim().to_ascii_lowercase(),
4486 proxy_url,
4487 timeout_secs
4488 .map(|v| v.to_string())
4489 .unwrap_or_else(|| "none".to_string()),
4490 connect_timeout_secs
4491 .map(|v| v.to_string())
4492 .unwrap_or_else(|| "none".to_string()),
4493 );
4494 if let Some(client) = runtime_proxy_cached_client(&cache_key) {
4495 return client;
4496 }
4497
4498 let mut builder = reqwest::Client::builder();
4499 if let Some(t) = timeout_secs {
4500 builder = builder.timeout(std::time::Duration::from_secs(t));
4501 }
4502 if let Some(ct) = connect_timeout_secs {
4503 builder = builder.connect_timeout(std::time::Duration::from_secs(ct));
4504 }
4505 builder = apply_explicit_proxy_to_builder(builder, service_key, proxy_url);
4506 let client = builder.build().unwrap_or_else(|error| {
4507 tracing::warn!(
4508 service_key,
4509 proxy_url,
4510 "Failed to build channel proxy client: {error}"
4511 );
4512 reqwest::Client::new()
4513 });
4514 set_runtime_proxy_cached_client(cache_key, client.clone());
4515 client
4516}
4517
4518fn apply_explicit_proxy_to_builder(
4520 mut builder: reqwest::ClientBuilder,
4521 service_key: &str,
4522 proxy_url: &str,
4523) -> reqwest::ClientBuilder {
4524 match reqwest::Proxy::all(proxy_url) {
4525 Ok(proxy) => {
4526 builder = builder.proxy(proxy);
4527 }
4528 Err(error) => {
4529 tracing::warn!(
4530 proxy_url,
4531 service_key,
4532 "Ignoring invalid channel proxy_url: {error}"
4533 );
4534 }
4535 }
4536 builder
4537}
4538
4539trait AsyncReadWrite: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
4550impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send> AsyncReadWrite for T {}
4551
4552pub struct BoxedIo(Box<dyn AsyncReadWrite>);
4560
4561impl tokio::io::AsyncRead for BoxedIo {
4562 fn poll_read(
4563 mut self: std::pin::Pin<&mut Self>,
4564 cx: &mut std::task::Context<'_>,
4565 buf: &mut tokio::io::ReadBuf<'_>,
4566 ) -> std::task::Poll<std::io::Result<()>> {
4567 std::pin::Pin::new(&mut *self.0).poll_read(cx, buf)
4568 }
4569}
4570
4571impl tokio::io::AsyncWrite for BoxedIo {
4572 fn poll_write(
4573 mut self: std::pin::Pin<&mut Self>,
4574 cx: &mut std::task::Context<'_>,
4575 buf: &[u8],
4576 ) -> std::task::Poll<std::io::Result<usize>> {
4577 std::pin::Pin::new(&mut *self.0).poll_write(cx, buf)
4578 }
4579
4580 fn poll_flush(
4581 mut self: std::pin::Pin<&mut Self>,
4582 cx: &mut std::task::Context<'_>,
4583 ) -> std::task::Poll<std::io::Result<()>> {
4584 std::pin::Pin::new(&mut *self.0).poll_flush(cx)
4585 }
4586
4587 fn poll_shutdown(
4588 mut self: std::pin::Pin<&mut Self>,
4589 cx: &mut std::task::Context<'_>,
4590 ) -> std::task::Poll<std::io::Result<()>> {
4591 std::pin::Pin::new(&mut *self.0).poll_shutdown(cx)
4592 }
4593}
4594
4595impl Unpin for BoxedIo {}
4596
4597pub type ProxiedWsStream = tokio_tungstenite::WebSocketStream<BoxedIo>;
4600
4601fn resolve_ws_proxy_url(
4605 service_key: &str,
4606 ws_url: &str,
4607 channel_proxy_url: Option<&str>,
4608) -> Option<String> {
4609 if let Some(url) = normalize_proxy_url_option(channel_proxy_url) {
4611 return Some(url);
4612 }
4613
4614 let cfg = runtime_proxy_config();
4616 if !cfg.should_apply_to_service(service_key) {
4617 return None;
4618 }
4619
4620 if let Ok(parsed) = reqwest::Url::parse(ws_url) {
4622 if let Some(host) = parsed.host_str() {
4623 let no_proxy_entries = cfg.normalized_no_proxy();
4624 if !no_proxy_entries.is_empty() {
4625 let host_lower = host.to_ascii_lowercase();
4626 let matches_no_proxy = no_proxy_entries.iter().any(|entry| {
4627 let entry = entry.trim().to_ascii_lowercase();
4628 if entry == "*" {
4629 return true;
4630 }
4631 if host_lower == entry {
4632 return true;
4633 }
4634 if let Some(suffix) = entry.strip_prefix('.') {
4636 return host_lower.ends_with(suffix) || host_lower == suffix;
4637 }
4638 host_lower.ends_with(&format!(".{entry}"))
4640 });
4641 if matches_no_proxy {
4642 return None;
4643 }
4644 }
4645 }
4646 }
4647
4648 let is_secure = ws_url.starts_with("wss://") || ws_url.starts_with("wss:");
4651 let preferred = if is_secure {
4652 normalize_proxy_url_option(cfg.https_proxy.as_deref())
4653 } else {
4654 normalize_proxy_url_option(cfg.http_proxy.as_deref())
4655 };
4656 preferred.or_else(|| normalize_proxy_url_option(cfg.all_proxy.as_deref()))
4657}
4658
4659pub async fn ws_connect_with_proxy(
4669 ws_url: &str,
4670 service_key: &str,
4671 channel_proxy_url: Option<&str>,
4672) -> anyhow::Result<(
4673 ProxiedWsStream,
4674 tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
4675)> {
4676 let proxy_url = resolve_ws_proxy_url(service_key, ws_url, channel_proxy_url);
4677
4678 match proxy_url {
4679 None => {
4680 let (stream, resp) = tokio_tungstenite::connect_async(ws_url).await?;
4682 let inner = stream.into_inner();
4685 let boxed = BoxedIo(Box::new(inner));
4686 let ws = tokio_tungstenite::WebSocketStream::from_raw_socket(
4687 boxed,
4688 tokio_tungstenite::tungstenite::protocol::Role::Client,
4689 None,
4690 )
4691 .await;
4692 Ok((ws, resp))
4693 }
4694 Some(proxy) => ws_connect_via_proxy(ws_url, &proxy).await,
4695 }
4696}
4697
4698async fn ws_connect_via_proxy(
4700 ws_url: &str,
4701 proxy_url: &str,
4702) -> anyhow::Result<(
4703 ProxiedWsStream,
4704 tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
4705)> {
4706 use tokio::io::{AsyncReadExt, AsyncWriteExt as _};
4707 use tokio::net::TcpStream;
4708
4709 let target =
4710 reqwest::Url::parse(ws_url).with_context(|| format!("Invalid WebSocket URL: {ws_url}"))?;
4711 let target_host = target
4712 .host_str()
4713 .ok_or_else(|| anyhow::anyhow!("WebSocket URL has no host: {ws_url}"))?
4714 .to_string();
4715 let target_port = target
4716 .port_or_known_default()
4717 .unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });
4718
4719 let proxy = reqwest::Url::parse(proxy_url)
4720 .with_context(|| format!("Invalid proxy URL: {proxy_url}"))?;
4721
4722 let stream: BoxedIo = match proxy.scheme() {
4723 "socks5" | "socks5h" | "socks" => {
4724 let proxy_addr = format!(
4725 "{}:{}",
4726 proxy.host_str().unwrap_or("127.0.0.1"),
4727 proxy.port_or_known_default().unwrap_or(1080)
4728 );
4729 let target_addr = format!("{target_host}:{target_port}");
4730 let socks_stream = if proxy.username().is_empty() {
4731 tokio_socks::tcp::Socks5Stream::connect(proxy_addr.as_str(), target_addr.as_str())
4732 .await
4733 .with_context(|| format!("SOCKS5 connect to {target_addr} via {proxy_addr}"))?
4734 } else {
4735 let password = proxy.password().unwrap_or("");
4736 tokio_socks::tcp::Socks5Stream::connect_with_password(
4737 proxy_addr.as_str(),
4738 target_addr.as_str(),
4739 proxy.username(),
4740 password,
4741 )
4742 .await
4743 .with_context(|| format!("SOCKS5 auth connect to {target_addr} via {proxy_addr}"))?
4744 };
4745 let tcp: TcpStream = socks_stream.into_inner();
4746 BoxedIo(Box::new(tcp))
4747 }
4748 "http" | "https" => {
4749 let proxy_host = proxy.host_str().unwrap_or("127.0.0.1");
4750 let proxy_port = proxy.port_or_known_default().unwrap_or(8080);
4751 let proxy_addr = format!("{proxy_host}:{proxy_port}");
4752
4753 let mut tcp = TcpStream::connect(&proxy_addr)
4754 .await
4755 .with_context(|| format!("TCP connect to HTTP proxy {proxy_addr}"))?;
4756
4757 let connect_req = format!(
4759 "CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n\r\n"
4760 );
4761 tcp.write_all(connect_req.as_bytes()).await?;
4762
4763 let mut buf = vec![0u8; 4096];
4765 let mut total = 0usize;
4766 loop {
4767 let n = tcp.read(&mut buf[total..]).await?;
4768 if n == 0 {
4769 anyhow::bail!("HTTP CONNECT proxy closed connection before response");
4770 }
4771 total += n;
4772 if let Some(pos) = find_header_end(&buf[..total]) {
4774 let status_line = std::str::from_utf8(&buf[..pos])
4775 .unwrap_or("")
4776 .lines()
4777 .next()
4778 .unwrap_or("");
4779 if !status_line.contains("200") {
4780 anyhow::bail!(
4781 "HTTP CONNECT proxy returned non-200 response: {status_line}"
4782 );
4783 }
4784 break;
4785 }
4786 if total >= buf.len() {
4787 anyhow::bail!("HTTP CONNECT proxy response too large");
4788 }
4789 }
4790
4791 BoxedIo(Box::new(tcp))
4792 }
4793 scheme => {
4794 anyhow::bail!("Unsupported proxy scheme '{scheme}' for WebSocket connections");
4795 }
4796 };
4797
4798 let is_secure = target.scheme() == "wss";
4800 let stream: BoxedIo = if is_secure {
4801 let mut root_store = rustls::RootCertStore::empty();
4802 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
4803 let tls_config = std::sync::Arc::new(
4804 rustls::ClientConfig::builder()
4805 .with_root_certificates(root_store)
4806 .with_no_client_auth(),
4807 );
4808 let connector = tokio_rustls::TlsConnector::from(tls_config);
4809 let server_name = rustls_pki_types::ServerName::try_from(target_host.clone())
4810 .with_context(|| format!("Invalid TLS server name: {target_host}"))?;
4811
4812 let tls_stream = connector
4816 .connect(server_name, stream)
4817 .await
4818 .with_context(|| format!("TLS handshake with {target_host}"))?;
4819 BoxedIo(Box::new(tls_stream))
4820 } else {
4821 stream
4822 };
4823
4824 let ws_request = tokio_tungstenite::tungstenite::http::Request::builder()
4826 .uri(ws_url)
4827 .header("Host", format!("{target_host}:{target_port}"))
4828 .header("Connection", "Upgrade")
4829 .header("Upgrade", "websocket")
4830 .header(
4831 "Sec-WebSocket-Key",
4832 tokio_tungstenite::tungstenite::handshake::client::generate_key(),
4833 )
4834 .header("Sec-WebSocket-Version", "13")
4835 .body(())
4836 .with_context(|| "Failed to build WebSocket upgrade request")?;
4837
4838 let (ws_stream, response) = tokio_tungstenite::client_async(ws_request, stream)
4839 .await
4840 .with_context(|| format!("WebSocket handshake failed for {ws_url}"))?;
4841
4842 Ok((ws_stream, response))
4843}
4844
4845fn find_header_end(buf: &[u8]) -> Option<usize> {
4847 buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
4848}
4849
4850fn parse_proxy_scope(raw: &str) -> Option<ProxyScope> {
4851 match raw.trim().to_ascii_lowercase().as_str() {
4852 "environment" | "env" => Some(ProxyScope::Environment),
4853 "construct" | "internal" | "core" => Some(ProxyScope::Construct),
4854 "services" | "service" => Some(ProxyScope::Services),
4855 _ => None,
4856 }
4857}
4858
4859fn parse_proxy_enabled(raw: &str) -> Option<bool> {
4860 match raw.trim().to_ascii_lowercase().as_str() {
4861 "1" | "true" | "yes" | "on" => Some(true),
4862 "0" | "false" | "no" | "off" => Some(false),
4863 _ => None,
4864 }
4865}
4866#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4870pub struct StorageConfig {
4871 #[serde(default)]
4873 pub provider: StorageProviderSection,
4874}
4875
4876#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4878pub struct StorageProviderSection {
4879 #[serde(default)]
4881 pub config: StorageProviderConfig,
4882}
4883
4884#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
4886pub struct StorageProviderConfig {
4887 #[serde(default)]
4889 pub provider: String,
4890
4891 #[serde(
4894 default,
4895 alias = "dbURL",
4896 alias = "database_url",
4897 alias = "databaseUrl"
4898 )]
4899 pub db_url: Option<String>,
4900
4901 #[serde(default = "default_storage_schema")]
4903 pub schema: String,
4904
4905 #[serde(default = "default_storage_table")]
4907 pub table: String,
4908
4909 #[serde(default)]
4911 pub connect_timeout_secs: Option<u64>,
4912}
4913
4914fn default_storage_schema() -> String {
4915 "public".into()
4916}
4917
4918fn default_storage_table() -> String {
4919 "memories".into()
4920}
4921
4922impl Default for StorageProviderConfig {
4923 fn default() -> Self {
4924 Self {
4925 provider: String::new(),
4926 db_url: None,
4927 schema: default_storage_schema(),
4928 table: default_storage_table(),
4929 connect_timeout_secs: None,
4930 }
4931 }
4932}
4933
4934#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
4939#[allow(clippy::struct_excessive_bools)]
4940pub struct MemoryConfig {
4941 pub backend: String,
4946 pub auto_save: bool,
4948 #[serde(default = "default_hygiene_enabled")]
4950 pub hygiene_enabled: bool,
4951 #[serde(default = "default_archive_after_days")]
4953 pub archive_after_days: u32,
4954 #[serde(default = "default_purge_after_days")]
4956 pub purge_after_days: u32,
4957 #[serde(default = "default_conversation_retention_days")]
4959 pub conversation_retention_days: u32,
4960 #[serde(default = "default_min_relevance_score")]
4964 pub min_relevance_score: f64,
4965
4966 #[serde(default)]
4969 pub response_cache_enabled: bool,
4970 #[serde(default = "default_response_cache_ttl")]
4972 pub response_cache_ttl_minutes: u32,
4973 #[serde(default = "default_response_cache_max")]
4975 pub response_cache_max_entries: usize,
4976 #[serde(default = "default_response_cache_hot_entries")]
4978 pub response_cache_hot_entries: usize,
4979
4980 #[serde(default)]
4983 pub snapshot_enabled: bool,
4984 #[serde(default)]
4986 pub snapshot_on_hygiene: bool,
4987 #[serde(default = "default_true")]
4989 pub auto_hydrate: bool,
4990
4991 #[serde(default = "default_namespace")]
4994 pub default_namespace: String,
4995
4996 #[serde(default)]
4999 pub audit_enabled: bool,
5000 #[serde(default = "default_audit_retention_days")]
5002 pub audit_retention_days: u32,
5003
5004 #[serde(default)]
5007 pub policy: MemoryPolicyConfig,
5008}
5009
5010#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
5012pub struct MemoryPolicyConfig {
5013 #[serde(default)]
5015 pub max_entries_per_namespace: usize,
5016 #[serde(default)]
5018 pub max_entries_per_category: usize,
5019 #[serde(default)]
5021 pub retention_days_by_category: std::collections::HashMap<String, u32>,
5022 #[serde(default)]
5024 pub read_only_namespaces: Vec<String>,
5025}
5026
5027fn default_namespace() -> String {
5028 "default".into()
5029}
5030fn default_audit_retention_days() -> u32 {
5031 30
5032}
5033fn default_hygiene_enabled() -> bool {
5034 true
5035}
5036fn default_archive_after_days() -> u32 {
5037 7
5038}
5039fn default_purge_after_days() -> u32 {
5040 30
5041}
5042fn default_conversation_retention_days() -> u32 {
5043 30
5044}
5045fn default_min_relevance_score() -> f64 {
5046 0.4
5047}
5048fn default_response_cache_ttl() -> u32 {
5049 60
5050}
5051fn default_response_cache_max() -> usize {
5052 5_000
5053}
5054
5055fn default_response_cache_hot_entries() -> usize {
5056 256
5057}
5058
5059impl Default for MemoryConfig {
5060 fn default() -> Self {
5061 Self {
5062 backend: "none".into(),
5063 auto_save: true,
5064 hygiene_enabled: default_hygiene_enabled(),
5065 archive_after_days: default_archive_after_days(),
5066 purge_after_days: default_purge_after_days(),
5067 conversation_retention_days: default_conversation_retention_days(),
5068 min_relevance_score: default_min_relevance_score(),
5069 response_cache_enabled: false,
5070 response_cache_ttl_minutes: default_response_cache_ttl(),
5071 response_cache_max_entries: default_response_cache_max(),
5072 response_cache_hot_entries: default_response_cache_hot_entries(),
5073 snapshot_enabled: false,
5074 snapshot_on_hygiene: false,
5075 auto_hydrate: true,
5076 default_namespace: default_namespace(),
5077 audit_enabled: false,
5078 audit_retention_days: default_audit_retention_days(),
5079 policy: MemoryPolicyConfig::default(),
5080 }
5081 }
5082}
5083
5084#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5088pub struct ObservabilityConfig {
5089 pub backend: String,
5091
5092 #[serde(default)]
5094 pub otel_endpoint: Option<String>,
5095
5096 #[serde(default)]
5098 pub otel_service_name: Option<String>,
5099
5100 #[serde(default = "default_runtime_trace_mode")]
5103 pub runtime_trace_mode: String,
5104
5105 #[serde(default = "default_runtime_trace_path")]
5107 pub runtime_trace_path: String,
5108
5109 #[serde(default = "default_runtime_trace_max_entries")]
5111 pub runtime_trace_max_entries: usize,
5112}
5113
5114impl Default for ObservabilityConfig {
5115 fn default() -> Self {
5116 Self {
5117 backend: "none".into(),
5118 otel_endpoint: None,
5119 otel_service_name: None,
5120 runtime_trace_mode: default_runtime_trace_mode(),
5121 runtime_trace_path: default_runtime_trace_path(),
5122 runtime_trace_max_entries: default_runtime_trace_max_entries(),
5123 }
5124 }
5125}
5126
5127fn default_runtime_trace_mode() -> String {
5128 "none".to_string()
5129}
5130
5131fn default_runtime_trace_path() -> String {
5132 "state/runtime-trace.jsonl".to_string()
5133}
5134
5135fn default_runtime_trace_max_entries() -> usize {
5136 200
5137}
5138
5139#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5142pub struct HooksConfig {
5143 pub enabled: bool,
5148 #[serde(default)]
5149 pub builtin: BuiltinHooksConfig,
5150}
5151
5152impl Default for HooksConfig {
5153 fn default() -> Self {
5154 Self {
5155 enabled: true,
5156 builtin: BuiltinHooksConfig::default(),
5157 }
5158 }
5159}
5160
5161#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
5162pub struct BuiltinHooksConfig {
5163 pub command_logger: bool,
5165 #[serde(default)]
5170 pub webhook_audit: WebhookAuditConfig,
5171}
5172
5173#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5179pub struct WebhookAuditConfig {
5180 #[serde(default)]
5182 pub enabled: bool,
5183 #[serde(default)]
5185 pub url: String,
5186 #[serde(default)]
5189 pub tool_patterns: Vec<String>,
5190 #[serde(default)]
5194 pub include_args: bool,
5195 #[serde(default = "default_max_args_bytes")]
5199 pub max_args_bytes: u64,
5200}
5201
5202fn default_max_args_bytes() -> u64 {
5203 4096
5204}
5205
5206impl Default for WebhookAuditConfig {
5207 fn default() -> Self {
5208 Self {
5209 enabled: false,
5210 url: String::new(),
5211 tool_patterns: Vec::new(),
5212 include_args: false,
5213 max_args_bytes: default_max_args_bytes(),
5214 }
5215 }
5216}
5217
5218#[allow(clippy::struct_excessive_bools)]
5225#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5226#[serde(default)]
5227pub struct AutonomyConfig {
5228 pub level: AutonomyLevel,
5230 pub workspace_only: bool,
5233 pub allowed_commands: Vec<String>,
5235 pub forbidden_paths: Vec<String>,
5237 pub max_actions_per_hour: u32,
5239 pub max_cost_per_day_cents: u32,
5241
5242 #[serde(default = "default_true")]
5244 pub require_approval_for_medium_risk: bool,
5245
5246 #[serde(default = "default_true")]
5248 pub block_high_risk_commands: bool,
5249
5250 #[serde(default)]
5255 pub shell_env_passthrough: Vec<String>,
5256
5257 #[serde(default = "default_auto_approve")]
5259 pub auto_approve: Vec<String>,
5260
5261 #[serde(default = "default_always_ask")]
5263 pub always_ask: Vec<String>,
5264
5265 #[serde(default)]
5269 pub allowed_roots: Vec<String>,
5270
5271 #[serde(default)]
5276 pub non_cli_excluded_tools: Vec<String>,
5277}
5278
5279fn default_auto_approve() -> Vec<String> {
5280 vec![
5281 "file_read".into(),
5282 "memory_recall".into(),
5283 "web_search_tool".into(),
5284 "web_fetch".into(),
5285 "calculator".into(),
5286 "glob_search".into(),
5287 "content_search".into(),
5288 "image_info".into(),
5289 "weather".into(),
5290 ]
5291}
5292
5293fn default_always_ask() -> Vec<String> {
5294 vec![]
5295}
5296
5297impl AutonomyConfig {
5298 pub fn ensure_default_auto_approve(&mut self) {
5301 let defaults = default_auto_approve();
5302 for entry in defaults {
5303 if !self.auto_approve.iter().any(|existing| existing == &entry) {
5304 self.auto_approve.push(entry);
5305 }
5306 }
5307 }
5308}
5309
5310fn is_valid_env_var_name(name: &str) -> bool {
5311 let mut chars = name.chars();
5312 match chars.next() {
5313 Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
5314 _ => return false,
5315 }
5316 chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
5317}
5318
5319impl Default for AutonomyConfig {
5320 fn default() -> Self {
5321 Self {
5322 level: AutonomyLevel::Supervised,
5323 workspace_only: true,
5324 allowed_commands: vec![
5325 "git".into(),
5326 "npm".into(),
5327 "cargo".into(),
5328 "ls".into(),
5329 "cat".into(),
5330 "grep".into(),
5331 "find".into(),
5332 "echo".into(),
5333 "pwd".into(),
5334 "wc".into(),
5335 "head".into(),
5336 "tail".into(),
5337 "date".into(),
5338 "python".into(),
5339 "python3".into(),
5340 "pip".into(),
5341 "node".into(),
5342 ],
5343 forbidden_paths: vec![
5344 "/etc".into(),
5345 "/root".into(),
5346 "/home".into(),
5347 "/usr".into(),
5348 "/bin".into(),
5349 "/sbin".into(),
5350 "/lib".into(),
5351 "/opt".into(),
5352 "/boot".into(),
5353 "/dev".into(),
5354 "/proc".into(),
5355 "/sys".into(),
5356 "/var".into(),
5357 "/tmp".into(),
5358 "~/.ssh".into(),
5359 "~/.gnupg".into(),
5360 "~/.aws".into(),
5361 "~/.config".into(),
5362 ],
5363 max_actions_per_hour: 20,
5364 max_cost_per_day_cents: 500,
5365 require_approval_for_medium_risk: true,
5366 block_high_risk_commands: true,
5367 shell_env_passthrough: vec![],
5368 auto_approve: default_auto_approve(),
5369 always_ask: default_always_ask(),
5370 allowed_roots: Vec::new(),
5371 non_cli_excluded_tools: Vec::new(),
5372 }
5373 }
5374}
5375
5376#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5380pub struct RuntimeConfig {
5381 #[serde(default = "default_runtime_kind")]
5383 pub kind: String,
5384
5385 #[serde(default)]
5387 pub docker: DockerRuntimeConfig,
5388
5389 #[serde(default)]
5394 pub reasoning_enabled: Option<bool>,
5395 #[serde(default, deserialize_with = "deserialize_reasoning_effort_opt")]
5397 pub reasoning_effort: Option<String>,
5398}
5399
5400#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5402pub struct DockerRuntimeConfig {
5403 #[serde(default = "default_docker_image")]
5405 pub image: String,
5406
5407 #[serde(default = "default_docker_network")]
5409 pub network: String,
5410
5411 #[serde(default = "default_docker_memory_limit_mb")]
5413 pub memory_limit_mb: Option<u64>,
5414
5415 #[serde(default = "default_docker_cpu_limit")]
5417 pub cpu_limit: Option<f64>,
5418
5419 #[serde(default = "default_true")]
5421 pub read_only_rootfs: bool,
5422
5423 #[serde(default = "default_true")]
5425 pub mount_workspace: bool,
5426
5427 #[serde(default)]
5429 pub allowed_workspace_roots: Vec<String>,
5430}
5431
5432fn default_runtime_kind() -> String {
5433 "native".into()
5434}
5435
5436fn default_docker_image() -> String {
5437 "alpine:3.20".into()
5438}
5439
5440fn default_docker_network() -> String {
5441 "none".into()
5442}
5443
5444fn default_docker_memory_limit_mb() -> Option<u64> {
5445 Some(512)
5446}
5447
5448fn default_docker_cpu_limit() -> Option<f64> {
5449 Some(1.0)
5450}
5451
5452impl Default for DockerRuntimeConfig {
5453 fn default() -> Self {
5454 Self {
5455 image: default_docker_image(),
5456 network: default_docker_network(),
5457 memory_limit_mb: default_docker_memory_limit_mb(),
5458 cpu_limit: default_docker_cpu_limit(),
5459 read_only_rootfs: true,
5460 mount_workspace: true,
5461 allowed_workspace_roots: Vec::new(),
5462 }
5463 }
5464}
5465
5466impl Default for RuntimeConfig {
5467 fn default() -> Self {
5468 Self {
5469 kind: default_runtime_kind(),
5470 docker: DockerRuntimeConfig::default(),
5471 reasoning_enabled: None,
5472 reasoning_effort: None,
5473 }
5474 }
5475}
5476
5477#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5483pub struct ReliabilityConfig {
5484 #[serde(default = "default_provider_retries")]
5486 pub provider_retries: u32,
5487 #[serde(default = "default_provider_backoff_ms")]
5489 pub provider_backoff_ms: u64,
5490 #[serde(default)]
5492 pub fallback_providers: Vec<String>,
5493 #[serde(default)]
5496 pub api_keys: Vec<String>,
5497 #[serde(default)]
5500 pub model_fallbacks: std::collections::HashMap<String, Vec<String>>,
5501 #[serde(default = "default_channel_backoff_secs")]
5503 pub channel_initial_backoff_secs: u64,
5504 #[serde(default = "default_channel_backoff_max_secs")]
5506 pub channel_max_backoff_secs: u64,
5507 #[serde(default = "default_scheduler_poll_secs")]
5509 pub scheduler_poll_secs: u64,
5510 #[serde(default = "default_scheduler_retries")]
5512 pub scheduler_retries: u32,
5513}
5514
5515fn default_provider_retries() -> u32 {
5516 2
5517}
5518
5519fn default_provider_backoff_ms() -> u64 {
5520 500
5521}
5522
5523fn default_channel_backoff_secs() -> u64 {
5524 2
5525}
5526
5527fn default_channel_backoff_max_secs() -> u64 {
5528 60
5529}
5530
5531fn default_scheduler_poll_secs() -> u64 {
5532 15
5533}
5534
5535fn default_scheduler_retries() -> u32 {
5536 2
5537}
5538
5539impl Default for ReliabilityConfig {
5540 fn default() -> Self {
5541 Self {
5542 provider_retries: default_provider_retries(),
5543 provider_backoff_ms: default_provider_backoff_ms(),
5544 fallback_providers: Vec::new(),
5545 api_keys: Vec::new(),
5546 model_fallbacks: std::collections::HashMap::new(),
5547 channel_initial_backoff_secs: default_channel_backoff_secs(),
5548 channel_max_backoff_secs: default_channel_backoff_max_secs(),
5549 scheduler_poll_secs: default_scheduler_poll_secs(),
5550 scheduler_retries: default_scheduler_retries(),
5551 }
5552 }
5553}
5554
5555#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5559pub struct SchedulerConfig {
5560 #[serde(default = "default_scheduler_enabled")]
5562 pub enabled: bool,
5563 #[serde(default = "default_scheduler_max_tasks")]
5565 pub max_tasks: usize,
5566 #[serde(default = "default_scheduler_max_concurrent")]
5568 pub max_concurrent: usize,
5569}
5570
5571fn default_scheduler_enabled() -> bool {
5572 true
5573}
5574
5575fn default_scheduler_max_tasks() -> usize {
5576 64
5577}
5578
5579fn default_scheduler_max_concurrent() -> usize {
5580 4
5581}
5582
5583impl Default for SchedulerConfig {
5584 fn default() -> Self {
5585 Self {
5586 enabled: default_scheduler_enabled(),
5587 max_tasks: default_scheduler_max_tasks(),
5588 max_concurrent: default_scheduler_max_concurrent(),
5589 }
5590 }
5591}
5592
5593#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5611pub struct ModelRouteConfig {
5612 pub hint: String,
5614 pub provider: String,
5616 pub model: String,
5618 #[serde(default)]
5620 pub api_key: Option<String>,
5621}
5622
5623#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5638pub struct EmbeddingRouteConfig {
5639 pub hint: String,
5641 pub provider: String,
5643 pub model: String,
5645 #[serde(default)]
5647 pub dimensions: Option<usize>,
5648 #[serde(default)]
5650 pub api_key: Option<String>,
5651}
5652
5653#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5658pub struct QueryClassificationConfig {
5659 #[serde(default)]
5661 pub enabled: bool,
5662 #[serde(default)]
5664 pub rules: Vec<ClassificationRule>,
5665}
5666
5667#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5669pub struct ClassificationRule {
5670 pub hint: String,
5672 #[serde(default)]
5674 pub keywords: Vec<String>,
5675 #[serde(default)]
5677 pub patterns: Vec<String>,
5678 #[serde(default)]
5680 pub min_length: Option<usize>,
5681 #[serde(default)]
5683 pub max_length: Option<usize>,
5684 #[serde(default)]
5686 pub priority: i32,
5687}
5688
5689#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5693#[allow(clippy::struct_excessive_bools)]
5694pub struct HeartbeatConfig {
5695 pub enabled: bool,
5697 #[serde(default = "default_heartbeat_interval")]
5699 pub interval_minutes: u32,
5700 #[serde(default = "default_two_phase")]
5704 pub two_phase: bool,
5705 #[serde(default)]
5707 pub message: Option<String>,
5708 #[serde(default, alias = "channel")]
5711 pub target: Option<String>,
5712 #[serde(default, alias = "recipient")]
5715 pub to: Option<String>,
5716 #[serde(default)]
5719 pub adaptive: bool,
5720 #[serde(default = "default_heartbeat_min_interval")]
5722 pub min_interval_minutes: u32,
5723 #[serde(default = "default_heartbeat_max_interval")]
5725 pub max_interval_minutes: u32,
5726 #[serde(default)]
5729 pub deadman_timeout_minutes: u32,
5730 #[serde(default)]
5733 pub deadman_channel: Option<String>,
5734 #[serde(default)]
5736 pub deadman_to: Option<String>,
5737 #[serde(default = "default_heartbeat_max_run_history")]
5739 pub max_run_history: u32,
5740 #[serde(default)]
5747 pub load_session_context: bool,
5748}
5749
5750fn default_heartbeat_interval() -> u32 {
5751 30
5752}
5753
5754fn default_two_phase() -> bool {
5755 true
5756}
5757
5758fn default_heartbeat_min_interval() -> u32 {
5759 5
5760}
5761
5762fn default_heartbeat_max_interval() -> u32 {
5763 120
5764}
5765
5766fn default_heartbeat_max_run_history() -> u32 {
5767 100
5768}
5769
5770impl Default for HeartbeatConfig {
5771 fn default() -> Self {
5772 Self {
5773 enabled: false,
5774 interval_minutes: default_heartbeat_interval(),
5775 two_phase: true,
5776 message: None,
5777 target: None,
5778 to: None,
5779 adaptive: false,
5780 min_interval_minutes: default_heartbeat_min_interval(),
5781 max_interval_minutes: default_heartbeat_max_interval(),
5782 deadman_timeout_minutes: 0,
5783 deadman_channel: None,
5784 deadman_to: None,
5785 max_run_history: default_heartbeat_max_run_history(),
5786 load_session_context: false,
5787 }
5788 }
5789}
5790
5791#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5795pub struct CronConfig {
5796 #[serde(default = "default_true")]
5798 pub enabled: bool,
5799 #[serde(default = "default_true")]
5807 pub catch_up_on_startup: bool,
5808 #[serde(default = "default_max_run_history")]
5810 pub max_run_history: u32,
5811 #[serde(default)]
5819 pub jobs: Vec<CronJobDecl>,
5820}
5821
5822#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5824pub struct CronJobDecl {
5825 pub id: String,
5827 #[serde(default)]
5829 pub name: Option<String>,
5830 #[serde(default = "default_job_type_decl")]
5832 pub job_type: String,
5833 pub schedule: CronScheduleDecl,
5835 #[serde(default)]
5837 pub command: Option<String>,
5838 #[serde(default)]
5840 pub prompt: Option<String>,
5841 #[serde(default = "default_true")]
5843 pub enabled: bool,
5844 #[serde(default)]
5846 pub model: Option<String>,
5847 #[serde(default)]
5849 pub allowed_tools: Option<Vec<String>>,
5850 #[serde(default)]
5852 pub session_target: Option<String>,
5853 #[serde(default)]
5855 pub delivery: Option<DeliveryConfigDecl>,
5856}
5857
5858#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5860#[serde(tag = "kind", rename_all = "lowercase")]
5861pub enum CronScheduleDecl {
5862 Cron {
5864 expr: String,
5865 #[serde(default)]
5866 tz: Option<String>,
5867 },
5868 Every { every_ms: u64 },
5870 At { at: String },
5872}
5873
5874#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5876pub struct DeliveryConfigDecl {
5877 #[serde(default = "default_delivery_mode")]
5879 pub mode: String,
5880 #[serde(default)]
5882 pub channel: Option<String>,
5883 #[serde(default)]
5885 pub to: Option<String>,
5886 #[serde(default = "default_true")]
5888 pub best_effort: bool,
5889}
5890
5891fn default_job_type_decl() -> String {
5892 "shell".to_string()
5893}
5894
5895fn default_delivery_mode() -> String {
5896 "none".to_string()
5897}
5898
5899fn default_max_run_history() -> u32 {
5900 50
5901}
5902
5903impl Default for CronConfig {
5904 fn default() -> Self {
5905 Self {
5906 enabled: true,
5907 catch_up_on_startup: true,
5908 max_run_history: default_max_run_history(),
5909 jobs: Vec::new(),
5910 }
5911 }
5912}
5913
5914#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5920pub struct TunnelConfig {
5921 pub provider: String,
5923
5924 #[serde(default)]
5926 pub cloudflare: Option<CloudflareTunnelConfig>,
5927
5928 #[serde(default)]
5930 pub tailscale: Option<TailscaleTunnelConfig>,
5931
5932 #[serde(default)]
5934 pub ngrok: Option<NgrokTunnelConfig>,
5935
5936 #[serde(default)]
5938 pub openvpn: Option<OpenVpnTunnelConfig>,
5939
5940 #[serde(default)]
5942 pub custom: Option<CustomTunnelConfig>,
5943
5944 #[serde(default)]
5946 pub pinggy: Option<PinggyTunnelConfig>,
5947}
5948
5949impl Default for TunnelConfig {
5950 fn default() -> Self {
5951 Self {
5952 provider: "none".into(),
5953 cloudflare: None,
5954 tailscale: None,
5955 ngrok: None,
5956 openvpn: None,
5957 custom: None,
5958 pinggy: None,
5959 }
5960 }
5961}
5962
5963#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5964pub struct CloudflareTunnelConfig {
5965 pub token: String,
5967}
5968
5969#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5970pub struct TailscaleTunnelConfig {
5971 #[serde(default)]
5973 pub funnel: bool,
5974 pub hostname: Option<String>,
5976}
5977
5978#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5979pub struct NgrokTunnelConfig {
5980 pub auth_token: String,
5982 pub domain: Option<String>,
5984}
5985
5986#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5994pub struct OpenVpnTunnelConfig {
5995 pub config_file: String,
5997 #[serde(default)]
5999 pub auth_file: Option<String>,
6000 #[serde(default)]
6003 pub advertise_address: Option<String>,
6004 #[serde(default = "default_openvpn_timeout")]
6006 pub connect_timeout_secs: u64,
6007 #[serde(default)]
6009 pub extra_args: Vec<String>,
6010}
6011
6012fn default_openvpn_timeout() -> u64 {
6013 30
6014}
6015
6016#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6017pub struct PinggyTunnelConfig {
6018 #[serde(default)]
6020 pub token: Option<String>,
6021 #[serde(default)]
6023 pub region: Option<String>,
6024}
6025
6026#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6027pub struct CustomTunnelConfig {
6028 pub start_command: String,
6031 pub health_url: Option<String>,
6033 pub url_pattern: Option<String>,
6035}
6036
6037struct ConfigWrapper<T: ChannelConfig>(std::marker::PhantomData<T>);
6040
6041impl<T: ChannelConfig> ConfigWrapper<T> {
6042 fn new(_: Option<&T>) -> Self {
6043 Self(std::marker::PhantomData)
6044 }
6045}
6046
6047impl<T: ChannelConfig> crate::config::traits::ConfigHandle for ConfigWrapper<T> {
6048 fn name(&self) -> &'static str {
6049 T::name()
6050 }
6051 fn desc(&self) -> &'static str {
6052 T::desc()
6053 }
6054}
6055
6056#[allow(clippy::struct_excessive_bools)]
6061#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6062pub struct ChannelsConfig {
6063 #[serde(default = "default_true")]
6065 pub cli: bool,
6066 pub telegram: Option<TelegramConfig>,
6068 pub discord: Option<DiscordConfig>,
6070 pub discord_history: Option<DiscordHistoryConfig>,
6072 pub slack: Option<SlackConfig>,
6074 pub mattermost: Option<MattermostConfig>,
6076 pub webhook: Option<WebhookConfig>,
6078 pub imessage: Option<IMessageConfig>,
6080 pub matrix: Option<MatrixConfig>,
6082 pub signal: Option<SignalConfig>,
6084 pub whatsapp: Option<WhatsAppConfig>,
6086 pub linq: Option<LinqConfig>,
6088 pub wati: Option<WatiConfig>,
6090 pub nextcloud_talk: Option<NextcloudTalkConfig>,
6092 pub email: Option<crate::channels::email_channel::EmailConfig>,
6094 pub gmail_push: Option<crate::channels::gmail_push::GmailPushConfig>,
6096 pub irc: Option<IrcConfig>,
6098 pub lark: Option<LarkConfig>,
6100 pub feishu: Option<FeishuConfig>,
6102 pub dingtalk: Option<DingTalkConfig>,
6104 pub wecom: Option<WeComConfig>,
6106 pub qq: Option<QQConfig>,
6108 pub twitter: Option<TwitterConfig>,
6110 pub mochat: Option<MochatConfig>,
6112 #[cfg(feature = "channel-nostr")]
6113 pub nostr: Option<NostrConfig>,
6114 pub clawdtalk: Option<crate::channels::ClawdTalkConfig>,
6116 pub reddit: Option<RedditConfig>,
6118 pub bluesky: Option<BlueskyConfig>,
6120 pub voice_call: Option<crate::channels::voice_call::VoiceCallConfig>,
6122 #[cfg(feature = "voice-wake")]
6124 pub voice_wake: Option<VoiceWakeConfig>,
6125 #[serde(default = "default_channel_message_timeout_secs")]
6131 pub message_timeout_secs: u64,
6132 #[serde(default = "default_true")]
6135 pub ack_reactions: bool,
6136 #[serde(default = "default_false")]
6140 pub show_tool_calls: bool,
6141 #[serde(default = "default_true")]
6144 pub session_persistence: bool,
6145 #[serde(default = "default_session_backend")]
6148 pub session_backend: String,
6149 #[serde(default)]
6151 pub session_ttl_hours: u32,
6152 #[serde(default)]
6156 pub debounce_ms: u64,
6157}
6158
6159impl ChannelsConfig {
6160 #[rustfmt::skip]
6162 pub fn channels_except_webhook(&self) -> Vec<(Box<dyn super::traits::ConfigHandle>, bool)> {
6163 vec![
6164 (
6165 Box::new(ConfigWrapper::new(self.telegram.as_ref())),
6166 self.telegram.is_some(),
6167 ),
6168 (
6169 Box::new(ConfigWrapper::new(self.discord.as_ref())),
6170 self.discord.is_some(),
6171 ),
6172 (
6173 Box::new(ConfigWrapper::new(self.slack.as_ref())),
6174 self.slack.is_some(),
6175 ),
6176 (
6177 Box::new(ConfigWrapper::new(self.mattermost.as_ref())),
6178 self.mattermost.is_some(),
6179 ),
6180 (
6181 Box::new(ConfigWrapper::new(self.imessage.as_ref())),
6182 self.imessage.is_some(),
6183 ),
6184 (
6185 Box::new(ConfigWrapper::new(self.matrix.as_ref())),
6186 self.matrix.is_some(),
6187 ),
6188 (
6189 Box::new(ConfigWrapper::new(self.signal.as_ref())),
6190 self.signal.is_some(),
6191 ),
6192 (
6193 Box::new(ConfigWrapper::new(self.whatsapp.as_ref())),
6194 self.whatsapp.is_some(),
6195 ),
6196 (
6197 Box::new(ConfigWrapper::new(self.linq.as_ref())),
6198 self.linq.is_some(),
6199 ),
6200 (
6201 Box::new(ConfigWrapper::new(self.wati.as_ref())),
6202 self.wati.is_some(),
6203 ),
6204 (
6205 Box::new(ConfigWrapper::new(self.nextcloud_talk.as_ref())),
6206 self.nextcloud_talk.is_some(),
6207 ),
6208 (
6209 Box::new(ConfigWrapper::new(self.email.as_ref())),
6210 self.email.is_some(),
6211 ),
6212 (
6213 Box::new(ConfigWrapper::new(self.gmail_push.as_ref())),
6214 self.gmail_push.is_some(),
6215 ),
6216 (
6217 Box::new(ConfigWrapper::new(self.irc.as_ref())),
6218 self.irc.is_some()
6219 ),
6220 (
6221 Box::new(ConfigWrapper::new(self.lark.as_ref())),
6222 self.lark.is_some(),
6223 ),
6224 (
6225 Box::new(ConfigWrapper::new(self.feishu.as_ref())),
6226 self.feishu.is_some(),
6227 ),
6228 (
6229 Box::new(ConfigWrapper::new(self.dingtalk.as_ref())),
6230 self.dingtalk.is_some(),
6231 ),
6232 (
6233 Box::new(ConfigWrapper::new(self.wecom.as_ref())),
6234 self.wecom.is_some(),
6235 ),
6236 (
6237 Box::new(ConfigWrapper::new(self.qq.as_ref())),
6238 self.qq.is_some()
6239 ),
6240 #[cfg(feature = "channel-nostr")]
6241 (
6242 Box::new(ConfigWrapper::new(self.nostr.as_ref())),
6243 self.nostr.is_some(),
6244 ),
6245 (
6246 Box::new(ConfigWrapper::new(self.clawdtalk.as_ref())),
6247 self.clawdtalk.is_some(),
6248 ),
6249 (
6250 Box::new(ConfigWrapper::new(self.reddit.as_ref())),
6251 self.reddit.is_some(),
6252 ),
6253 (
6254 Box::new(ConfigWrapper::new(self.bluesky.as_ref())),
6255 self.bluesky.is_some(),
6256 ),
6257 #[cfg(feature = "voice-wake")]
6258 (
6259 Box::new(ConfigWrapper::new(self.voice_wake.as_ref())),
6260 self.voice_wake.is_some(),
6261 ),
6262 ]
6263 }
6264
6265 pub fn channels(&self) -> Vec<(Box<dyn super::traits::ConfigHandle>, bool)> {
6266 let mut ret = self.channels_except_webhook();
6267 ret.push((
6268 Box::new(ConfigWrapper::new(self.webhook.as_ref())),
6269 self.webhook.is_some(),
6270 ));
6271 ret
6272 }
6273}
6274
6275fn default_channel_message_timeout_secs() -> u64 {
6276 300
6277}
6278
6279fn default_session_backend() -> String {
6280 "sqlite".into()
6281}
6282
6283impl Default for ChannelsConfig {
6284 fn default() -> Self {
6285 Self {
6286 cli: true,
6287 telegram: None,
6288 discord: None,
6289 discord_history: None,
6290 slack: None,
6291 mattermost: None,
6292 webhook: None,
6293 imessage: None,
6294 matrix: None,
6295 signal: None,
6296 whatsapp: None,
6297 linq: None,
6298 wati: None,
6299 nextcloud_talk: None,
6300 email: None,
6301 gmail_push: None,
6302 irc: None,
6303 lark: None,
6304 feishu: None,
6305 dingtalk: None,
6306 wecom: None,
6307 qq: None,
6308 twitter: None,
6309 mochat: None,
6310 #[cfg(feature = "channel-nostr")]
6311 nostr: None,
6312 clawdtalk: None,
6313 reddit: None,
6314 bluesky: None,
6315 voice_call: None,
6316 #[cfg(feature = "voice-wake")]
6317 voice_wake: None,
6318 message_timeout_secs: default_channel_message_timeout_secs(),
6319 ack_reactions: true,
6320 show_tool_calls: false,
6321 session_persistence: true,
6322 session_backend: default_session_backend(),
6323 session_ttl_hours: 0,
6324 debounce_ms: 0,
6325 }
6326 }
6327}
6328
6329#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
6331#[serde(rename_all = "lowercase")]
6332pub enum StreamMode {
6333 #[default]
6335 Off,
6336 Partial,
6338 #[serde(rename = "multi_message")]
6340 MultiMessage,
6341}
6342
6343fn default_draft_update_interval_ms() -> u64 {
6344 1000
6345}
6346
6347fn default_multi_message_delay_ms() -> u64 {
6348 800
6349}
6350
6351fn default_matrix_draft_update_interval_ms() -> u64 {
6352 1500
6353}
6354
6355#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6357pub struct TelegramConfig {
6358 pub bot_token: String,
6360 pub allowed_users: Vec<String>,
6362 #[serde(default)]
6364 pub stream_mode: StreamMode,
6365 #[serde(default = "default_draft_update_interval_ms")]
6367 pub draft_update_interval_ms: u64,
6368 #[serde(default)]
6371 pub interrupt_on_new_message: bool,
6372 #[serde(default)]
6375 pub mention_only: bool,
6376 #[serde(default)]
6380 pub ack_reactions: Option<bool>,
6381 #[serde(default)]
6384 pub proxy_url: Option<String>,
6385 #[serde(default)]
6389 pub notification_chat_id: Option<String>,
6390}
6391
6392impl ChannelConfig for TelegramConfig {
6393 fn name() -> &'static str {
6394 "Telegram"
6395 }
6396 fn desc() -> &'static str {
6397 "connect your bot"
6398 }
6399}
6400
6401#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6403pub struct DiscordConfig {
6404 pub bot_token: String,
6406 pub guild_id: Option<String>,
6408 #[serde(default)]
6410 pub allowed_users: Vec<String>,
6411 #[serde(default)]
6414 pub listen_to_bots: bool,
6415 #[serde(default)]
6418 pub interrupt_on_new_message: bool,
6419 #[serde(default)]
6422 pub mention_only: bool,
6423 #[serde(default)]
6426 pub notification_channel_id: Option<String>,
6427 #[serde(default)]
6430 pub proxy_url: Option<String>,
6431 #[serde(default)]
6435 pub stream_mode: StreamMode,
6436 #[serde(default = "default_draft_update_interval_ms")]
6439 pub draft_update_interval_ms: u64,
6440 #[serde(default = "default_multi_message_delay_ms")]
6443 pub multi_message_delay_ms: u64,
6444}
6445
6446impl ChannelConfig for DiscordConfig {
6447 fn name() -> &'static str {
6448 "Discord"
6449 }
6450 fn desc() -> &'static str {
6451 "connect your bot"
6452 }
6453}
6454
6455#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6457pub struct DiscordHistoryConfig {
6458 pub bot_token: String,
6460 pub guild_id: Option<String>,
6462 #[serde(default)]
6464 pub allowed_users: Vec<String>,
6465 #[serde(default)]
6467 pub channel_ids: Vec<String>,
6468 #[serde(default = "default_true")]
6470 pub store_dms: bool,
6471 #[serde(default = "default_true")]
6473 pub respond_to_dms: bool,
6474 #[serde(default)]
6476 pub proxy_url: Option<String>,
6477}
6478
6479impl ChannelConfig for DiscordHistoryConfig {
6480 fn name() -> &'static str {
6481 "Discord History"
6482 }
6483 fn desc() -> &'static str {
6484 "log all messages and forward @mentions"
6485 }
6486}
6487
6488#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6490#[allow(clippy::struct_excessive_bools)]
6491pub struct SlackConfig {
6492 pub bot_token: String,
6494 pub app_token: Option<String>,
6496 pub channel_id: Option<String>,
6499 #[serde(default)]
6502 pub channel_ids: Vec<String>,
6503 #[serde(default)]
6505 pub allowed_users: Vec<String>,
6506 #[serde(default)]
6509 pub interrupt_on_new_message: bool,
6510 #[serde(default)]
6513 pub thread_replies: Option<bool>,
6514 #[serde(default)]
6517 pub mention_only: bool,
6518 #[serde(default)]
6522 pub use_markdown_blocks: bool,
6523 #[serde(default)]
6526 pub proxy_url: Option<String>,
6527 #[serde(default)]
6529 pub stream_drafts: bool,
6530 #[serde(default = "default_slack_draft_update_interval_ms")]
6532 pub draft_update_interval_ms: u64,
6533 #[serde(default)]
6537 pub cancel_reaction: Option<String>,
6538 #[serde(default)]
6542 pub notification_channel_id: Option<String>,
6543}
6544
6545fn default_slack_draft_update_interval_ms() -> u64 {
6546 1200
6547}
6548
6549impl ChannelConfig for SlackConfig {
6550 fn name() -> &'static str {
6551 "Slack"
6552 }
6553 fn desc() -> &'static str {
6554 "connect your bot"
6555 }
6556}
6557
6558#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6560pub struct MattermostConfig {
6561 pub url: String,
6563 pub bot_token: String,
6565 pub channel_id: Option<String>,
6567 #[serde(default)]
6569 pub allowed_users: Vec<String>,
6570 #[serde(default)]
6573 pub thread_replies: Option<bool>,
6574 #[serde(default)]
6577 pub mention_only: Option<bool>,
6578 #[serde(default)]
6581 pub interrupt_on_new_message: bool,
6582 #[serde(default)]
6585 pub proxy_url: Option<String>,
6586}
6587
6588impl ChannelConfig for MattermostConfig {
6589 fn name() -> &'static str {
6590 "Mattermost"
6591 }
6592 fn desc() -> &'static str {
6593 "connect to your bot"
6594 }
6595}
6596
6597#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6602pub struct WebhookConfig {
6603 pub port: u16,
6605 #[serde(default)]
6607 pub listen_path: Option<String>,
6608 #[serde(default)]
6610 pub send_url: Option<String>,
6611 #[serde(default)]
6613 pub send_method: Option<String>,
6614 #[serde(default)]
6616 pub auth_header: Option<String>,
6617 pub secret: Option<String>,
6619}
6620
6621impl ChannelConfig for WebhookConfig {
6622 fn name() -> &'static str {
6623 "Webhook"
6624 }
6625 fn desc() -> &'static str {
6626 "HTTP endpoint"
6627 }
6628}
6629
6630#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6632pub struct IMessageConfig {
6633 pub allowed_contacts: Vec<String>,
6635}
6636
6637impl ChannelConfig for IMessageConfig {
6638 fn name() -> &'static str {
6639 "iMessage"
6640 }
6641 fn desc() -> &'static str {
6642 "macOS only"
6643 }
6644}
6645
6646#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6648pub struct MatrixConfig {
6649 pub homeserver: String,
6651 pub access_token: String,
6653 #[serde(default)]
6655 pub user_id: Option<String>,
6656 #[serde(default)]
6658 pub device_id: Option<String>,
6659 pub room_id: String,
6661 pub allowed_users: Vec<String>,
6663 #[serde(default)]
6666 pub allowed_rooms: Vec<String>,
6667 #[serde(default)]
6669 pub interrupt_on_new_message: bool,
6670 #[serde(default)]
6674 pub stream_mode: StreamMode,
6675 #[serde(default = "default_matrix_draft_update_interval_ms")]
6677 pub draft_update_interval_ms: u64,
6678 #[serde(default = "default_multi_message_delay_ms")]
6680 pub multi_message_delay_ms: u64,
6681 #[serde(default)]
6684 pub recovery_key: Option<String>,
6685}
6686
6687impl ChannelConfig for MatrixConfig {
6688 fn name() -> &'static str {
6689 "Matrix"
6690 }
6691 fn desc() -> &'static str {
6692 "self-hosted chat"
6693 }
6694}
6695
6696#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6697pub struct SignalConfig {
6698 pub http_url: String,
6700 pub account: String,
6702 #[serde(default)]
6707 pub group_id: Option<String>,
6708 #[serde(default)]
6710 pub allowed_from: Vec<String>,
6711 #[serde(default)]
6713 pub ignore_attachments: bool,
6714 #[serde(default)]
6716 pub ignore_stories: bool,
6717 #[serde(default)]
6720 pub proxy_url: Option<String>,
6721}
6722
6723impl ChannelConfig for SignalConfig {
6724 fn name() -> &'static str {
6725 "Signal"
6726 }
6727 fn desc() -> &'static str {
6728 "An open-source, encrypted messaging service"
6729 }
6730}
6731
6732#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
6739#[serde(rename_all = "snake_case")]
6740pub enum WhatsAppWebMode {
6741 #[default]
6743 Business,
6744 Personal,
6746}
6747
6748#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
6751#[serde(rename_all = "snake_case")]
6752pub enum WhatsAppChatPolicy {
6753 #[default]
6755 Allowlist,
6756 Ignore,
6758 All,
6760}
6761
6762#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6766pub struct WhatsAppConfig {
6767 #[serde(default)]
6769 pub access_token: Option<String>,
6770 #[serde(default)]
6772 pub phone_number_id: Option<String>,
6773 #[serde(default)]
6776 pub verify_token: Option<String>,
6777 #[serde(default)]
6781 pub app_secret: Option<String>,
6782 #[serde(default)]
6785 pub session_path: Option<String>,
6786 #[serde(default)]
6790 pub pair_phone: Option<String>,
6791 #[serde(default)]
6794 pub pair_code: Option<String>,
6795 #[serde(default)]
6797 pub allowed_numbers: Vec<String>,
6798 #[serde(default)]
6802 pub mode: WhatsAppWebMode,
6803 #[serde(default)]
6806 pub dm_policy: WhatsAppChatPolicy,
6807 #[serde(default)]
6810 pub group_policy: WhatsAppChatPolicy,
6811 #[serde(default)]
6814 pub self_chat_mode: bool,
6815 #[serde(default)]
6820 pub dm_mention_patterns: Vec<String>,
6821 #[serde(default)]
6826 pub group_mention_patterns: Vec<String>,
6827 #[serde(default)]
6830 pub proxy_url: Option<String>,
6831}
6832
6833impl ChannelConfig for WhatsAppConfig {
6834 fn name() -> &'static str {
6835 "WhatsApp"
6836 }
6837 fn desc() -> &'static str {
6838 "Business Cloud API"
6839 }
6840}
6841
6842#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6843pub struct LinqConfig {
6844 pub api_token: String,
6846 pub from_phone: String,
6848 #[serde(default)]
6850 pub signing_secret: Option<String>,
6851 #[serde(default)]
6853 pub allowed_senders: Vec<String>,
6854}
6855
6856impl ChannelConfig for LinqConfig {
6857 fn name() -> &'static str {
6858 "Linq"
6859 }
6860 fn desc() -> &'static str {
6861 "iMessage/RCS/SMS via Linq API"
6862 }
6863}
6864
6865#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6867pub struct WatiConfig {
6868 pub api_token: String,
6870 #[serde(default = "default_wati_api_url")]
6872 pub api_url: String,
6873 #[serde(default)]
6875 pub tenant_id: Option<String>,
6876 #[serde(default)]
6878 pub allowed_numbers: Vec<String>,
6879 #[serde(default)]
6882 pub proxy_url: Option<String>,
6883}
6884
6885fn default_wati_api_url() -> String {
6886 "https://live-mt-server.wati.io".to_string()
6887}
6888
6889impl ChannelConfig for WatiConfig {
6890 fn name() -> &'static str {
6891 "WATI"
6892 }
6893 fn desc() -> &'static str {
6894 "WhatsApp via WATI Business API"
6895 }
6896}
6897
6898#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6900pub struct NextcloudTalkConfig {
6901 pub base_url: String,
6903 pub app_token: String,
6905 #[serde(default)]
6909 pub webhook_secret: Option<String>,
6910 #[serde(default)]
6912 pub allowed_users: Vec<String>,
6913 #[serde(default)]
6916 pub proxy_url: Option<String>,
6917 #[serde(default)]
6921 pub bot_name: Option<String>,
6922}
6923
6924impl ChannelConfig for NextcloudTalkConfig {
6925 fn name() -> &'static str {
6926 "NextCloud Talk"
6927 }
6928 fn desc() -> &'static str {
6929 "NextCloud Talk platform"
6930 }
6931}
6932
6933impl WhatsAppConfig {
6934 pub fn backend_type(&self) -> &'static str {
6937 if self.phone_number_id.is_some() {
6938 "cloud"
6939 } else if self.session_path.is_some() {
6940 "web"
6941 } else {
6942 "cloud"
6944 }
6945 }
6946
6947 pub fn is_cloud_config(&self) -> bool {
6949 self.phone_number_id.is_some() && self.access_token.is_some() && self.verify_token.is_some()
6950 }
6951
6952 pub fn is_web_config(&self) -> bool {
6954 self.session_path.is_some()
6955 }
6956
6957 pub fn is_ambiguous_config(&self) -> bool {
6961 self.phone_number_id.is_some() && self.session_path.is_some()
6962 }
6963}
6964
6965#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6967pub struct IrcConfig {
6968 pub server: String,
6970 #[serde(default = "default_irc_port")]
6972 pub port: u16,
6973 pub nickname: String,
6975 pub username: Option<String>,
6977 #[serde(default)]
6979 pub channels: Vec<String>,
6980 #[serde(default)]
6982 pub allowed_users: Vec<String>,
6983 pub server_password: Option<String>,
6985 pub nickserv_password: Option<String>,
6987 pub sasl_password: Option<String>,
6989 pub verify_tls: Option<bool>,
6991}
6992
6993impl ChannelConfig for IrcConfig {
6994 fn name() -> &'static str {
6995 "IRC"
6996 }
6997 fn desc() -> &'static str {
6998 "IRC over TLS"
6999 }
7000}
7001
7002fn default_irc_port() -> u16 {
7003 6697
7004}
7005
7006#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
7011#[serde(rename_all = "lowercase")]
7012pub enum LarkReceiveMode {
7013 #[default]
7014 Websocket,
7015 Webhook,
7016}
7017
7018#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7021pub struct LarkConfig {
7022 pub app_id: String,
7024 pub app_secret: String,
7026 #[serde(default)]
7028 pub encrypt_key: Option<String>,
7029 #[serde(default)]
7031 pub verification_token: Option<String>,
7032 #[serde(default)]
7034 pub allowed_users: Vec<String>,
7035 #[serde(default)]
7038 pub mention_only: bool,
7039 #[serde(default)]
7041 pub use_feishu: bool,
7042 #[serde(default)]
7044 pub receive_mode: LarkReceiveMode,
7045 #[serde(default)]
7048 pub port: Option<u16>,
7049 #[serde(default)]
7052 pub proxy_url: Option<String>,
7053}
7054
7055impl ChannelConfig for LarkConfig {
7056 fn name() -> &'static str {
7057 "Lark"
7058 }
7059 fn desc() -> &'static str {
7060 "Lark Bot"
7061 }
7062}
7063
7064#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7066pub struct FeishuConfig {
7067 pub app_id: String,
7069 pub app_secret: String,
7071 #[serde(default)]
7073 pub encrypt_key: Option<String>,
7074 #[serde(default)]
7076 pub verification_token: Option<String>,
7077 #[serde(default)]
7079 pub allowed_users: Vec<String>,
7080 #[serde(default)]
7082 pub receive_mode: LarkReceiveMode,
7083 #[serde(default)]
7086 pub port: Option<u16>,
7087 #[serde(default)]
7090 pub proxy_url: Option<String>,
7091}
7092
7093impl ChannelConfig for FeishuConfig {
7094 fn name() -> &'static str {
7095 "Feishu"
7096 }
7097 fn desc() -> &'static str {
7098 "Feishu Bot"
7099 }
7100}
7101
7102#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
7106pub struct SecurityConfig {
7107 #[serde(default)]
7109 pub sandbox: SandboxConfig,
7110
7111 #[serde(default)]
7113 pub resources: ResourceLimitsConfig,
7114
7115 #[serde(default)]
7117 pub audit: AuditConfig,
7118
7119 #[serde(default)]
7121 pub otp: OtpConfig,
7122
7123 #[serde(default)]
7125 pub estop: EstopConfig,
7126
7127 #[serde(default)]
7129 pub nevis: NevisConfig,
7130
7131 #[serde(default)]
7133 pub webauthn: WebAuthnConfig,
7134}
7135
7136#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7141pub struct WebAuthnConfig {
7142 #[serde(default)]
7144 pub enabled: bool,
7145 #[serde(default = "default_webauthn_rp_id")]
7147 pub rp_id: String,
7148 #[serde(default = "default_webauthn_rp_origin")]
7150 pub rp_origin: String,
7151 #[serde(default = "default_webauthn_rp_name")]
7153 pub rp_name: String,
7154}
7155
7156impl Default for WebAuthnConfig {
7157 fn default() -> Self {
7158 Self {
7159 enabled: false,
7160 rp_id: default_webauthn_rp_id(),
7161 rp_origin: default_webauthn_rp_origin(),
7162 rp_name: default_webauthn_rp_name(),
7163 }
7164 }
7165}
7166
7167fn default_webauthn_rp_id() -> String {
7168 "localhost".into()
7169}
7170
7171fn default_webauthn_rp_origin() -> String {
7172 "http://localhost:42617".into()
7173}
7174
7175fn default_webauthn_rp_name() -> String {
7176 "Construct".into()
7177}
7178
7179#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, JsonSchema, PartialEq, Eq)]
7181#[serde(rename_all = "kebab-case")]
7182pub enum OtpMethod {
7183 #[default]
7185 Totp,
7186 Pairing,
7188 CliPrompt,
7190}
7191
7192#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7194#[serde(deny_unknown_fields)]
7195pub struct OtpConfig {
7196 #[serde(default)]
7198 pub enabled: bool,
7199
7200 #[serde(default)]
7202 pub method: OtpMethod,
7203
7204 #[serde(default = "default_otp_token_ttl_secs")]
7206 pub token_ttl_secs: u64,
7207
7208 #[serde(default = "default_otp_cache_valid_secs")]
7210 pub cache_valid_secs: u64,
7211
7212 #[serde(default = "default_otp_gated_actions")]
7214 pub gated_actions: Vec<String>,
7215
7216 #[serde(default)]
7218 pub gated_domains: Vec<String>,
7219
7220 #[serde(default)]
7222 pub gated_domain_categories: Vec<String>,
7223
7224 #[serde(default = "default_otp_challenge_max_attempts")]
7226 pub challenge_max_attempts: u32,
7227}
7228
7229fn default_otp_token_ttl_secs() -> u64 {
7230 30
7231}
7232
7233fn default_otp_cache_valid_secs() -> u64 {
7234 300
7235}
7236
7237fn default_otp_challenge_max_attempts() -> u32 {
7238 3
7239}
7240
7241fn default_otp_gated_actions() -> Vec<String> {
7242 vec![
7243 "shell".to_string(),
7244 "file_write".to_string(),
7245 "browser_open".to_string(),
7246 "browser".to_string(),
7247 "memory_forget".to_string(),
7248 ]
7249}
7250
7251impl Default for OtpConfig {
7252 fn default() -> Self {
7253 Self {
7254 enabled: false,
7255 method: OtpMethod::Totp,
7256 token_ttl_secs: default_otp_token_ttl_secs(),
7257 cache_valid_secs: default_otp_cache_valid_secs(),
7258 gated_actions: default_otp_gated_actions(),
7259 gated_domains: Vec::new(),
7260 gated_domain_categories: Vec::new(),
7261 challenge_max_attempts: default_otp_challenge_max_attempts(),
7262 }
7263 }
7264}
7265
7266#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7268#[serde(deny_unknown_fields)]
7269pub struct EstopConfig {
7270 #[serde(default)]
7272 pub enabled: bool,
7273
7274 #[serde(default = "default_estop_state_file")]
7276 pub state_file: String,
7277
7278 #[serde(default = "default_true")]
7280 pub require_otp_to_resume: bool,
7281}
7282
7283fn default_estop_state_file() -> String {
7284 "~/.construct/estop-state.json".to_string()
7285}
7286
7287impl Default for EstopConfig {
7288 fn default() -> Self {
7289 Self {
7290 enabled: false,
7291 state_file: default_estop_state_file(),
7292 require_otp_to_resume: true,
7293 }
7294 }
7295}
7296
7297#[derive(Clone, Serialize, Deserialize, JsonSchema)]
7302#[serde(deny_unknown_fields)]
7303pub struct NevisConfig {
7304 #[serde(default)]
7306 pub enabled: bool,
7307
7308 #[serde(default)]
7310 pub instance_url: String,
7311
7312 #[serde(default = "default_nevis_realm")]
7314 pub realm: String,
7315
7316 #[serde(default)]
7318 pub client_id: String,
7319
7320 #[serde(default)]
7322 pub client_secret: Option<String>,
7323
7324 #[serde(default = "default_nevis_token_validation")]
7326 pub token_validation: String,
7327
7328 #[serde(default)]
7330 pub jwks_url: Option<String>,
7331
7332 #[serde(default)]
7334 pub role_mapping: Vec<NevisRoleMappingConfig>,
7335
7336 #[serde(default)]
7338 pub require_mfa: bool,
7339
7340 #[serde(default = "default_nevis_session_timeout_secs")]
7342 pub session_timeout_secs: u64,
7343}
7344
7345impl std::fmt::Debug for NevisConfig {
7346 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
7347 f.debug_struct("NevisConfig")
7348 .field("enabled", &self.enabled)
7349 .field("instance_url", &self.instance_url)
7350 .field("realm", &self.realm)
7351 .field("client_id", &self.client_id)
7352 .field(
7353 "client_secret",
7354 &self.client_secret.as_ref().map(|_| "[REDACTED]"),
7355 )
7356 .field("token_validation", &self.token_validation)
7357 .field("jwks_url", &self.jwks_url)
7358 .field("role_mapping", &self.role_mapping)
7359 .field("require_mfa", &self.require_mfa)
7360 .field("session_timeout_secs", &self.session_timeout_secs)
7361 .finish()
7362 }
7363}
7364
7365impl NevisConfig {
7366 pub fn validate(&self) -> Result<(), String> {
7371 if !self.enabled {
7372 return Ok(());
7373 }
7374
7375 if self.instance_url.trim().is_empty() {
7376 return Err("nevis.instance_url is required when Nevis IAM is enabled".into());
7377 }
7378
7379 if self.client_id.trim().is_empty() {
7380 return Err("nevis.client_id is required when Nevis IAM is enabled".into());
7381 }
7382
7383 if self.realm.trim().is_empty() {
7384 return Err("nevis.realm is required when Nevis IAM is enabled".into());
7385 }
7386
7387 match self.token_validation.as_str() {
7388 "local" | "remote" => {}
7389 other => {
7390 return Err(format!(
7391 "nevis.token_validation has invalid value '{other}': \
7392 expected 'local' or 'remote'"
7393 ));
7394 }
7395 }
7396
7397 if self.token_validation == "local" && self.jwks_url.is_none() {
7398 return Err("nevis.jwks_url is required when token_validation is 'local'".into());
7399 }
7400
7401 if self.session_timeout_secs == 0 {
7402 return Err("nevis.session_timeout_secs must be greater than 0".into());
7403 }
7404
7405 Ok(())
7406 }
7407}
7408
7409fn default_nevis_realm() -> String {
7410 "master".into()
7411}
7412
7413fn default_nevis_token_validation() -> String {
7414 "local".into()
7415}
7416
7417fn default_nevis_session_timeout_secs() -> u64 {
7418 3600
7419}
7420
7421impl Default for NevisConfig {
7422 fn default() -> Self {
7423 Self {
7424 enabled: false,
7425 instance_url: String::new(),
7426 realm: default_nevis_realm(),
7427 client_id: String::new(),
7428 client_secret: None,
7429 token_validation: default_nevis_token_validation(),
7430 jwks_url: None,
7431 role_mapping: Vec::new(),
7432 require_mfa: false,
7433 session_timeout_secs: default_nevis_session_timeout_secs(),
7434 }
7435 }
7436}
7437
7438#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7440#[serde(deny_unknown_fields)]
7441pub struct NevisRoleMappingConfig {
7442 pub nevis_role: String,
7444
7445 #[serde(default)]
7447 pub construct_permissions: Vec<String>,
7448
7449 #[serde(default)]
7451 pub workspace_access: Vec<String>,
7452}
7453
7454#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7456pub struct SandboxConfig {
7457 #[serde(default)]
7459 pub enabled: Option<bool>,
7460
7461 #[serde(default)]
7463 pub backend: SandboxBackend,
7464
7465 #[serde(default)]
7467 pub firejail_args: Vec<String>,
7468}
7469
7470impl Default for SandboxConfig {
7471 fn default() -> Self {
7472 Self {
7473 enabled: None, backend: SandboxBackend::Auto,
7475 firejail_args: Vec::new(),
7476 }
7477 }
7478}
7479
7480#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
7482#[serde(rename_all = "lowercase")]
7483pub enum SandboxBackend {
7484 #[default]
7486 Auto,
7487 Landlock,
7489 Firejail,
7491 Bubblewrap,
7493 Docker,
7495 #[serde(alias = "sandbox-exec")]
7497 SandboxExec,
7498 None,
7500}
7501
7502#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7504pub struct ResourceLimitsConfig {
7505 #[serde(default = "default_max_memory_mb")]
7507 pub max_memory_mb: u32,
7508
7509 #[serde(default = "default_max_cpu_time_seconds")]
7511 pub max_cpu_time_seconds: u64,
7512
7513 #[serde(default = "default_max_subprocesses")]
7515 pub max_subprocesses: u32,
7516
7517 #[serde(default = "default_memory_monitoring_enabled")]
7519 pub memory_monitoring: bool,
7520}
7521
7522fn default_max_memory_mb() -> u32 {
7523 512
7524}
7525
7526fn default_max_cpu_time_seconds() -> u64 {
7527 60
7528}
7529
7530fn default_max_subprocesses() -> u32 {
7531 10
7532}
7533
7534fn default_memory_monitoring_enabled() -> bool {
7535 true
7536}
7537
7538impl Default for ResourceLimitsConfig {
7539 fn default() -> Self {
7540 Self {
7541 max_memory_mb: default_max_memory_mb(),
7542 max_cpu_time_seconds: default_max_cpu_time_seconds(),
7543 max_subprocesses: default_max_subprocesses(),
7544 memory_monitoring: default_memory_monitoring_enabled(),
7545 }
7546 }
7547}
7548
7549#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7551pub struct AuditConfig {
7552 #[serde(default = "default_audit_enabled")]
7554 pub enabled: bool,
7555
7556 #[serde(default = "default_audit_log_path")]
7558 pub log_path: String,
7559
7560 #[serde(default = "default_audit_max_size_mb")]
7562 pub max_size_mb: u32,
7563
7564 #[serde(default)]
7566 pub sign_events: bool,
7567}
7568
7569fn default_audit_enabled() -> bool {
7570 true
7571}
7572
7573fn default_audit_log_path() -> String {
7574 "audit.log".to_string()
7575}
7576
7577fn default_audit_max_size_mb() -> u32 {
7578 100
7579}
7580
7581impl Default for AuditConfig {
7582 fn default() -> Self {
7583 Self {
7584 enabled: default_audit_enabled(),
7585 log_path: default_audit_log_path(),
7586 max_size_mb: default_audit_max_size_mb(),
7587 sign_events: false,
7588 }
7589 }
7590}
7591
7592#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7594pub struct DingTalkConfig {
7595 pub client_id: String,
7597 pub client_secret: String,
7599 #[serde(default)]
7601 pub allowed_users: Vec<String>,
7602 #[serde(default)]
7605 pub proxy_url: Option<String>,
7606}
7607
7608impl ChannelConfig for DingTalkConfig {
7609 fn name() -> &'static str {
7610 "DingTalk"
7611 }
7612 fn desc() -> &'static str {
7613 "DingTalk Stream Mode"
7614 }
7615}
7616
7617#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7619pub struct WeComConfig {
7620 pub webhook_key: String,
7622 #[serde(default)]
7624 pub allowed_users: Vec<String>,
7625}
7626
7627impl ChannelConfig for WeComConfig {
7628 fn name() -> &'static str {
7629 "WeCom"
7630 }
7631 fn desc() -> &'static str {
7632 "WeCom Bot Webhook"
7633 }
7634}
7635
7636#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7638pub struct QQConfig {
7639 pub app_id: String,
7641 pub app_secret: String,
7643 #[serde(default)]
7645 pub allowed_users: Vec<String>,
7646 #[serde(default)]
7649 pub proxy_url: Option<String>,
7650}
7651
7652impl ChannelConfig for QQConfig {
7653 fn name() -> &'static str {
7654 "QQ Official"
7655 }
7656 fn desc() -> &'static str {
7657 "Tencent QQ Bot"
7658 }
7659}
7660
7661#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7663pub struct TwitterConfig {
7664 pub bearer_token: String,
7666 #[serde(default)]
7668 pub allowed_users: Vec<String>,
7669}
7670
7671impl ChannelConfig for TwitterConfig {
7672 fn name() -> &'static str {
7673 "X/Twitter"
7674 }
7675 fn desc() -> &'static str {
7676 "X/Twitter Bot via API v2"
7677 }
7678}
7679
7680#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7682pub struct MochatConfig {
7683 pub api_url: String,
7685 pub api_token: String,
7687 #[serde(default)]
7689 pub allowed_users: Vec<String>,
7690 #[serde(default = "default_mochat_poll_interval")]
7692 pub poll_interval_secs: u64,
7693}
7694
7695fn default_mochat_poll_interval() -> u64 {
7696 5
7697}
7698
7699impl ChannelConfig for MochatConfig {
7700 fn name() -> &'static str {
7701 "Mochat"
7702 }
7703 fn desc() -> &'static str {
7704 "Mochat Customer Service"
7705 }
7706}
7707
7708#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7710pub struct RedditConfig {
7711 pub client_id: String,
7713 pub client_secret: String,
7715 pub refresh_token: String,
7717 pub username: String,
7719 #[serde(default)]
7722 pub subreddit: Option<String>,
7723}
7724
7725impl ChannelConfig for RedditConfig {
7726 fn name() -> &'static str {
7727 "Reddit"
7728 }
7729 fn desc() -> &'static str {
7730 "Reddit bot (OAuth2)"
7731 }
7732}
7733
7734#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7736pub struct BlueskyConfig {
7737 pub handle: String,
7739 pub app_password: String,
7741}
7742
7743impl ChannelConfig for BlueskyConfig {
7744 fn name() -> &'static str {
7745 "Bluesky"
7746 }
7747 fn desc() -> &'static str {
7748 "AT Protocol"
7749 }
7750}
7751
7752#[cfg(feature = "voice-wake")]
7758#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7759pub struct VoiceWakeConfig {
7760 #[serde(default = "default_voice_wake_word")]
7763 pub wake_word: String,
7764 #[serde(default = "default_voice_wake_silence_timeout_ms")]
7767 pub silence_timeout_ms: u32,
7768 #[serde(default = "default_voice_wake_energy_threshold")]
7771 pub energy_threshold: f32,
7772 #[serde(default = "default_voice_wake_max_capture_secs")]
7775 pub max_capture_secs: u32,
7776}
7777
7778#[cfg(feature = "voice-wake")]
7779fn default_voice_wake_word() -> String {
7780 "hey construct".into()
7781}
7782
7783#[cfg(feature = "voice-wake")]
7784fn default_voice_wake_silence_timeout_ms() -> u32 {
7785 2000
7786}
7787
7788#[cfg(feature = "voice-wake")]
7789fn default_voice_wake_energy_threshold() -> f32 {
7790 0.01
7791}
7792
7793#[cfg(feature = "voice-wake")]
7794fn default_voice_wake_max_capture_secs() -> u32 {
7795 30
7796}
7797
7798#[cfg(feature = "voice-wake")]
7799impl Default for VoiceWakeConfig {
7800 fn default() -> Self {
7801 Self {
7802 wake_word: default_voice_wake_word(),
7803 silence_timeout_ms: default_voice_wake_silence_timeout_ms(),
7804 energy_threshold: default_voice_wake_energy_threshold(),
7805 max_capture_secs: default_voice_wake_max_capture_secs(),
7806 }
7807 }
7808}
7809
7810#[cfg(feature = "voice-wake")]
7811impl ChannelConfig for VoiceWakeConfig {
7812 fn name() -> &'static str {
7813 "VoiceWake"
7814 }
7815 fn desc() -> &'static str {
7816 "voice wake word detection"
7817 }
7818}
7819
7820#[cfg(feature = "channel-nostr")]
7822#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7823pub struct NostrConfig {
7824 pub private_key: String,
7826 #[serde(default = "default_nostr_relays")]
7828 pub relays: Vec<String>,
7829 #[serde(default)]
7831 pub allowed_pubkeys: Vec<String>,
7832}
7833
7834#[cfg(feature = "channel-nostr")]
7835impl ChannelConfig for NostrConfig {
7836 fn name() -> &'static str {
7837 "Nostr"
7838 }
7839 fn desc() -> &'static str {
7840 "Nostr DMs"
7841 }
7842}
7843
7844#[cfg(feature = "channel-nostr")]
7845pub fn default_nostr_relays() -> Vec<String> {
7846 vec![
7847 "wss://relay.damus.io".to_string(),
7848 "wss://nos.lol".to_string(),
7849 "wss://relay.primal.net".to_string(),
7850 "wss://relay.snort.social".to_string(),
7851 ]
7852}
7853
7854#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7862pub struct NotionConfig {
7863 #[serde(default)]
7864 pub enabled: bool,
7865 #[serde(default)]
7866 pub api_key: String,
7867 #[serde(default)]
7868 pub database_id: String,
7869 #[serde(default = "default_notion_poll_interval")]
7870 pub poll_interval_secs: u64,
7871 #[serde(default = "default_notion_status_prop")]
7872 pub status_property: String,
7873 #[serde(default = "default_notion_input_prop")]
7874 pub input_property: String,
7875 #[serde(default = "default_notion_result_prop")]
7876 pub result_property: String,
7877 #[serde(default = "default_notion_max_concurrent")]
7878 pub max_concurrent: usize,
7879 #[serde(default = "default_notion_recover_stale")]
7880 pub recover_stale: bool,
7881}
7882
7883fn default_notion_poll_interval() -> u64 {
7884 5
7885}
7886fn default_notion_status_prop() -> String {
7887 "Status".into()
7888}
7889fn default_notion_input_prop() -> String {
7890 "Input".into()
7891}
7892fn default_notion_result_prop() -> String {
7893 "Result".into()
7894}
7895fn default_notion_max_concurrent() -> usize {
7896 4
7897}
7898fn default_notion_recover_stale() -> bool {
7899 true
7900}
7901
7902impl Default for NotionConfig {
7903 fn default() -> Self {
7904 Self {
7905 enabled: false,
7906 api_key: String::new(),
7907 database_id: String::new(),
7908 poll_interval_secs: default_notion_poll_interval(),
7909 status_property: default_notion_status_prop(),
7910 input_property: default_notion_input_prop(),
7911 result_property: default_notion_result_prop(),
7912 max_concurrent: default_notion_max_concurrent(),
7913 recover_stale: default_notion_recover_stale(),
7914 }
7915 }
7916}
7917
7918#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7934pub struct JiraConfig {
7935 #[serde(default)]
7937 pub enabled: bool,
7938 #[serde(default)]
7940 pub base_url: String,
7941 #[serde(default)]
7943 pub email: String,
7944 #[serde(default)]
7946 pub api_token: String,
7947 #[serde(default = "default_jira_allowed_actions")]
7951 pub allowed_actions: Vec<String>,
7952 #[serde(default = "default_jira_timeout_secs")]
7954 pub timeout_secs: u64,
7955}
7956
7957fn default_jira_allowed_actions() -> Vec<String> {
7958 vec!["get_ticket".to_string()]
7959}
7960
7961fn default_jira_timeout_secs() -> u64 {
7962 30
7963}
7964
7965impl Default for JiraConfig {
7966 fn default() -> Self {
7967 Self {
7968 enabled: false,
7969 base_url: String::new(),
7970 email: String::new(),
7971 api_token: String::new(),
7972 allowed_actions: default_jira_allowed_actions(),
7973 timeout_secs: default_jira_timeout_secs(),
7974 }
7975 }
7976}
7977
7978#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7982pub struct CloudOpsConfig {
7983 #[serde(default)]
7985 pub enabled: bool,
7986 #[serde(default = "default_cloud_ops_cloud")]
7988 pub default_cloud: String,
7989 #[serde(default = "default_cloud_ops_supported_clouds")]
7991 pub supported_clouds: Vec<String>,
7992 #[serde(default = "default_cloud_ops_iac_tools")]
7994 pub iac_tools: Vec<String>,
7995 #[serde(default = "default_cloud_ops_cost_threshold")]
7997 pub cost_threshold_monthly_usd: f64,
7998 #[serde(default = "default_cloud_ops_waf")]
8000 pub well_architected_frameworks: Vec<String>,
8001}
8002
8003impl Default for CloudOpsConfig {
8004 fn default() -> Self {
8005 Self {
8006 enabled: false,
8007 default_cloud: default_cloud_ops_cloud(),
8008 supported_clouds: default_cloud_ops_supported_clouds(),
8009 iac_tools: default_cloud_ops_iac_tools(),
8010 cost_threshold_monthly_usd: default_cloud_ops_cost_threshold(),
8011 well_architected_frameworks: default_cloud_ops_waf(),
8012 }
8013 }
8014}
8015
8016impl CloudOpsConfig {
8017 pub fn validate(&self) -> Result<()> {
8018 if self.enabled {
8019 if self.default_cloud.trim().is_empty() {
8020 anyhow::bail!(
8021 "cloud_ops.default_cloud must not be empty when cloud_ops is enabled"
8022 );
8023 }
8024 if self.supported_clouds.is_empty() {
8025 anyhow::bail!(
8026 "cloud_ops.supported_clouds must not be empty when cloud_ops is enabled"
8027 );
8028 }
8029 for (i, cloud) in self.supported_clouds.iter().enumerate() {
8030 if cloud.trim().is_empty() {
8031 anyhow::bail!("cloud_ops.supported_clouds[{i}] must not be empty");
8032 }
8033 }
8034 if !self.supported_clouds.contains(&self.default_cloud) {
8035 anyhow::bail!(
8036 "cloud_ops.default_cloud '{}' is not in cloud_ops.supported_clouds {:?}",
8037 self.default_cloud,
8038 self.supported_clouds
8039 );
8040 }
8041 if self.cost_threshold_monthly_usd < 0.0 {
8042 anyhow::bail!(
8043 "cloud_ops.cost_threshold_monthly_usd must be non-negative, got {}",
8044 self.cost_threshold_monthly_usd
8045 );
8046 }
8047 if self.iac_tools.is_empty() {
8048 anyhow::bail!("cloud_ops.iac_tools must not be empty when cloud_ops is enabled");
8049 }
8050 }
8051 Ok(())
8052 }
8053}
8054
8055fn default_cloud_ops_cloud() -> String {
8056 "aws".into()
8057}
8058
8059fn default_cloud_ops_supported_clouds() -> Vec<String> {
8060 vec!["aws".into(), "azure".into(), "gcp".into()]
8061}
8062
8063fn default_cloud_ops_iac_tools() -> Vec<String> {
8064 vec!["terraform".into()]
8065}
8066
8067fn default_cloud_ops_cost_threshold() -> f64 {
8068 100.0
8069}
8070
8071fn default_cloud_ops_waf() -> Vec<String> {
8072 vec!["aws-waf".into()]
8073}
8074
8075fn default_conversational_ai_language() -> String {
8078 "en".into()
8079}
8080
8081fn default_conversational_ai_supported_languages() -> Vec<String> {
8082 vec!["en".into(), "de".into(), "fr".into(), "it".into()]
8083}
8084
8085fn default_conversational_ai_escalation_threshold() -> f64 {
8086 0.3
8087}
8088
8089fn default_conversational_ai_max_turns() -> usize {
8090 50
8091}
8092
8093fn default_conversational_ai_timeout_secs() -> u64 {
8094 1800
8095}
8096
8097#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
8102pub struct ConversationalAiConfig {
8103 #[serde(default)]
8105 pub enabled: bool,
8106 #[serde(default = "default_conversational_ai_language")]
8108 pub default_language: String,
8109 #[serde(default = "default_conversational_ai_supported_languages")]
8111 pub supported_languages: Vec<String>,
8112 #[serde(default = "default_true")]
8114 pub auto_detect_language: bool,
8115 #[serde(default = "default_conversational_ai_escalation_threshold")]
8117 pub escalation_confidence_threshold: f64,
8118 #[serde(default = "default_conversational_ai_max_turns")]
8120 pub max_conversation_turns: usize,
8121 #[serde(default = "default_conversational_ai_timeout_secs")]
8123 pub conversation_timeout_secs: u64,
8124 #[serde(default)]
8126 pub analytics_enabled: bool,
8127 #[serde(default)]
8129 pub knowledge_base_tool: Option<String>,
8130}
8131
8132impl ConversationalAiConfig {
8133 pub fn is_disabled(&self) -> bool {
8139 !self.enabled
8140 }
8141}
8142
8143impl Default for ConversationalAiConfig {
8144 fn default() -> Self {
8145 Self {
8146 enabled: false,
8147 default_language: default_conversational_ai_language(),
8148 supported_languages: default_conversational_ai_supported_languages(),
8149 auto_detect_language: true,
8150 escalation_confidence_threshold: default_conversational_ai_escalation_threshold(),
8151 max_conversation_turns: default_conversational_ai_max_turns(),
8152 conversation_timeout_secs: default_conversational_ai_timeout_secs(),
8153 analytics_enabled: false,
8154 knowledge_base_tool: None,
8155 }
8156 }
8157}
8158
8159#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
8163pub struct SecurityOpsConfig {
8164 #[serde(default)]
8166 pub enabled: bool,
8167 #[serde(default = "default_playbooks_dir")]
8169 pub playbooks_dir: String,
8170 #[serde(default)]
8172 pub auto_triage: bool,
8173 #[serde(default = "default_require_approval")]
8175 pub require_approval_for_actions: bool,
8176 #[serde(default = "default_max_auto_severity")]
8179 pub max_auto_severity: String,
8180 #[serde(default = "default_report_output_dir")]
8182 pub report_output_dir: String,
8183 #[serde(default)]
8185 pub siem_integration: Option<String>,
8186}
8187
8188fn default_playbooks_dir() -> String {
8189 "~/.construct/playbooks".into()
8190}
8191
8192fn default_require_approval() -> bool {
8193 true
8194}
8195
8196fn default_max_auto_severity() -> String {
8197 "low".into()
8198}
8199
8200fn default_report_output_dir() -> String {
8201 "~/.construct/security-reports".into()
8202}
8203
8204impl Default for SecurityOpsConfig {
8205 fn default() -> Self {
8206 Self {
8207 enabled: false,
8208 playbooks_dir: default_playbooks_dir(),
8209 auto_triage: false,
8210 require_approval_for_actions: true,
8211 max_auto_severity: default_max_auto_severity(),
8212 report_output_dir: default_report_output_dir(),
8213 siem_integration: None,
8214 }
8215 }
8216}
8217
8218impl Default for Config {
8221 fn default() -> Self {
8222 let home =
8223 UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
8224 let construct_dir = home.join(".construct");
8225
8226 Self {
8227 workspace_dir: construct_dir.join("workspace"),
8228 config_path: construct_dir.join("config.toml"),
8229 api_key: None,
8230 api_url: None,
8231 api_path: None,
8232 default_provider: Some("openrouter".to_string()),
8233 default_model: Some("anthropic/claude-sonnet-4.6".to_string()),
8234 model_providers: HashMap::new(),
8235 default_temperature: default_temperature(),
8236 provider_timeout_secs: default_provider_timeout_secs(),
8237 provider_max_tokens: None,
8238 extra_headers: HashMap::new(),
8239 observability: ObservabilityConfig::default(),
8240 autonomy: AutonomyConfig::default(),
8241 trust: crate::trust::TrustConfig::default(),
8242 backup: BackupConfig::default(),
8243 data_retention: DataRetentionConfig::default(),
8244 cloud_ops: CloudOpsConfig::default(),
8245 conversational_ai: ConversationalAiConfig::default(),
8246 security: SecurityConfig::default(),
8247 security_ops: SecurityOpsConfig::default(),
8248 runtime: RuntimeConfig::default(),
8249 reliability: ReliabilityConfig::default(),
8250 scheduler: SchedulerConfig::default(),
8251 agent: AgentConfig::default(),
8252 pacing: PacingConfig::default(),
8253 skills: SkillsConfig::default(),
8254 pipeline: PipelineConfig::default(),
8255 model_routes: Vec::new(),
8256 embedding_routes: Vec::new(),
8257 heartbeat: HeartbeatConfig::default(),
8258 cron: CronConfig::default(),
8259 channels_config: ChannelsConfig::default(),
8260 memory: MemoryConfig::default(),
8261 storage: StorageConfig::default(),
8262 tunnel: TunnelConfig::default(),
8263 gateway: GatewayConfig::default(),
8264 composio: ComposioConfig::default(),
8265 microsoft365: Microsoft365Config::default(),
8266 secrets: SecretsConfig::default(),
8267 browser: BrowserConfig::default(),
8268 browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig::default(),
8269 http_request: HttpRequestConfig::default(),
8270 multimodal: MultimodalConfig::default(),
8271 media_pipeline: MediaPipelineConfig::default(),
8272 web_fetch: WebFetchConfig::default(),
8273 link_enricher: LinkEnricherConfig::default(),
8274 text_browser: TextBrowserConfig::default(),
8275 web_search: WebSearchConfig::default(),
8276 project_intel: ProjectIntelConfig::default(),
8277 google_workspace: GoogleWorkspaceConfig::default(),
8278 proxy: ProxyConfig::default(),
8279 identity: IdentityConfig::default(),
8280 cost: CostConfig::default(),
8281 peripherals: PeripheralsConfig::default(),
8282 delegate: DelegateToolConfig::default(),
8283 agents: HashMap::new(),
8284 swarms: HashMap::new(),
8285 hooks: HooksConfig::default(),
8286 hardware: HardwareConfig::default(),
8287 query_classification: QueryClassificationConfig::default(),
8288 transcription: TranscriptionConfig::default(),
8289 tts: TtsConfig::default(),
8290 mcp: McpConfig::default(),
8291 kumiho: KumihoConfig::default(),
8292 operator: OperatorConfig::default(),
8293 nodes: NodesConfig::default(),
8294 clawhub: ClawHubConfig::default(),
8295 workspace: WorkspaceConfig::default(),
8296 notion: NotionConfig::default(),
8297 jira: JiraConfig::default(),
8298 node_transport: NodeTransportConfig::default(),
8299 linkedin: LinkedInConfig::default(),
8300 image_gen: ImageGenConfig::default(),
8301 plugins: PluginsConfig::default(),
8302 locale: None,
8303 verifiable_intent: VerifiableIntentConfig::default(),
8304 claude_code: ClaudeCodeConfig::default(),
8305 claude_code_runner: ClaudeCodeRunnerConfig::default(),
8306 codex_cli: CodexCliConfig::default(),
8307 gemini_cli: GeminiCliConfig::default(),
8308 opencode_cli: OpenCodeCliConfig::default(),
8309 sop: SopConfig::default(),
8310 shell_tool: ShellToolConfig::default(),
8311 }
8312 }
8313}
8314
8315fn default_config_and_workspace_dirs() -> Result<(PathBuf, PathBuf)> {
8316 let config_dir = default_config_dir()?;
8317 Ok((config_dir.clone(), config_dir.join("workspace")))
8318}
8319
8320const ACTIVE_WORKSPACE_STATE_FILE: &str = "active_workspace.toml";
8321
8322#[derive(Debug, Serialize, Deserialize)]
8323struct ActiveWorkspaceState {
8324 config_dir: String,
8325}
8326
8327fn default_config_dir() -> Result<PathBuf> {
8328 if let Ok(home) = std::env::var("HOME") {
8329 if !home.is_empty() {
8330 return Ok(PathBuf::from(home).join(".construct"));
8331 }
8332 }
8333
8334 let home = UserDirs::new()
8335 .map(|u| u.home_dir().to_path_buf())
8336 .context("Could not find home directory")?;
8337 Ok(home.join(".construct"))
8338}
8339
8340fn active_workspace_state_path(default_dir: &Path) -> PathBuf {
8341 default_dir.join(ACTIVE_WORKSPACE_STATE_FILE)
8342}
8343
8344fn is_temp_directory(path: &Path) -> bool {
8346 let temp = std::env::temp_dir();
8347 let canon_temp = temp.canonicalize().unwrap_or_else(|_| temp.clone());
8349 let canon_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
8350 canon_path.starts_with(&canon_temp)
8351}
8352
8353async fn load_persisted_workspace_dirs(
8354 default_config_dir: &Path,
8355) -> Result<Option<(PathBuf, PathBuf)>> {
8356 let state_path = active_workspace_state_path(default_config_dir);
8357 if !state_path.exists() {
8358 return Ok(None);
8359 }
8360
8361 let contents = match fs::read_to_string(&state_path).await {
8362 Ok(contents) => contents,
8363 Err(error) => {
8364 tracing::warn!(
8365 "Failed to read active workspace marker {}: {error}",
8366 state_path.display()
8367 );
8368 return Ok(None);
8369 }
8370 };
8371
8372 let state: ActiveWorkspaceState = match toml::from_str(&contents) {
8373 Ok(state) => state,
8374 Err(error) => {
8375 tracing::warn!(
8376 "Failed to parse active workspace marker {}: {error}",
8377 state_path.display()
8378 );
8379 return Ok(None);
8380 }
8381 };
8382
8383 let raw_config_dir = state.config_dir.trim();
8384 if raw_config_dir.is_empty() {
8385 tracing::warn!(
8386 "Ignoring active workspace marker {} because config_dir is empty",
8387 state_path.display()
8388 );
8389 return Ok(None);
8390 }
8391
8392 let parsed_dir = expand_tilde_path(raw_config_dir);
8393 let config_dir = if parsed_dir.is_absolute() {
8394 parsed_dir
8395 } else {
8396 default_config_dir.join(parsed_dir)
8397 };
8398 Ok(Some((config_dir.clone(), config_dir.join("workspace"))))
8399}
8400
8401pub(crate) async fn persist_active_workspace_config_dir(config_dir: &Path) -> Result<()> {
8402 persist_active_workspace_config_dir_in(config_dir, &default_config_dir()?).await
8403}
8404
8405async fn persist_active_workspace_config_dir_in(
8409 config_dir: &Path,
8410 default_config_dir: &Path,
8411) -> Result<()> {
8412 let state_path = active_workspace_state_path(default_config_dir);
8413
8414 if is_temp_directory(config_dir) && !is_temp_directory(default_config_dir) {
8419 tracing::warn!(
8420 path = %config_dir.display(),
8421 "Refusing to persist temp directory as active workspace marker"
8422 );
8423 return Ok(());
8424 }
8425
8426 if config_dir == default_config_dir {
8427 if state_path.exists() {
8428 fs::remove_file(&state_path).await.with_context(|| {
8429 format!(
8430 "Failed to clear active workspace marker: {}",
8431 state_path.display()
8432 )
8433 })?;
8434 }
8435 return Ok(());
8436 }
8437
8438 fs::create_dir_all(&default_config_dir)
8439 .await
8440 .with_context(|| {
8441 format!(
8442 "Failed to create default config directory: {}",
8443 default_config_dir.display()
8444 )
8445 })?;
8446
8447 let state = ActiveWorkspaceState {
8448 config_dir: config_dir.to_string_lossy().into_owned(),
8449 };
8450 let serialized =
8451 toml::to_string_pretty(&state).context("Failed to serialize active workspace marker")?;
8452
8453 let temp_path = default_config_dir.join(format!(
8454 ".{ACTIVE_WORKSPACE_STATE_FILE}.tmp-{}",
8455 uuid::Uuid::new_v4()
8456 ));
8457 fs::write(&temp_path, serialized).await.with_context(|| {
8458 format!(
8459 "Failed to write temporary active workspace marker: {}",
8460 temp_path.display()
8461 )
8462 })?;
8463
8464 if let Err(error) = fs::rename(&temp_path, &state_path).await {
8465 let _ = fs::remove_file(&temp_path).await;
8466 anyhow::bail!(
8467 "Failed to atomically persist active workspace marker {}: {error}",
8468 state_path.display()
8469 );
8470 }
8471
8472 sync_directory(default_config_dir).await?;
8473 Ok(())
8474}
8475
8476pub(crate) fn resolve_config_dir_for_workspace(workspace_dir: &Path) -> (PathBuf, PathBuf) {
8477 let workspace_config_dir = workspace_dir.to_path_buf();
8478 if workspace_config_dir.join("config.toml").exists() {
8479 return (
8480 workspace_config_dir.clone(),
8481 workspace_config_dir.join("workspace"),
8482 );
8483 }
8484
8485 let legacy_config_dir = workspace_dir
8486 .parent()
8487 .map(|parent| parent.join(".construct"));
8488 if let Some(legacy_dir) = legacy_config_dir {
8489 if legacy_dir.join("config.toml").exists() {
8490 return (legacy_dir, workspace_config_dir);
8491 }
8492
8493 if workspace_dir
8494 .file_name()
8495 .is_some_and(|name| name == std::ffi::OsStr::new("workspace"))
8496 {
8497 return (legacy_dir, workspace_config_dir);
8498 }
8499 }
8500
8501 (
8502 workspace_config_dir.clone(),
8503 workspace_config_dir.join("workspace"),
8504 )
8505}
8506
8507pub async fn resolve_runtime_dirs_for_onboarding() -> Result<(PathBuf, PathBuf)> {
8512 let (default_construct_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
8513 let (config_dir, workspace_dir, _) =
8514 resolve_runtime_config_dirs(&default_construct_dir, &default_workspace_dir).await?;
8515 Ok((config_dir, workspace_dir))
8516}
8517
8518#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8519enum ConfigResolutionSource {
8520 EnvConfigDir,
8521 EnvWorkspace,
8522 ActiveWorkspaceMarker,
8523 DefaultConfigDir,
8524}
8525
8526impl ConfigResolutionSource {
8527 const fn as_str(self) -> &'static str {
8528 match self {
8529 Self::EnvConfigDir => "CONSTRUCT_CONFIG_DIR",
8530 Self::EnvWorkspace => "CONSTRUCT_WORKSPACE",
8531 Self::ActiveWorkspaceMarker => "active_workspace.toml",
8532 Self::DefaultConfigDir => "default",
8533 }
8534 }
8535}
8536
8537fn expand_tilde_path(path: &str) -> PathBuf {
8543 let expanded = shellexpand::tilde(path);
8544 let expanded_str = expanded.as_ref();
8545
8546 if expanded_str.starts_with('~') {
8548 if let Some(user_dirs) = UserDirs::new() {
8549 let home = user_dirs.home_dir();
8550 if let Some(rest) = expanded_str.strip_prefix('~') {
8552 return home.join(rest.trim_start_matches(['/', '\\']));
8553 }
8554 }
8555 tracing::warn!(
8557 path = path,
8558 "Failed to expand tilde: HOME environment variable is not set and UserDirs failed. \
8559 In cron/non-TTY environments, use absolute paths or set HOME explicitly."
8560 );
8561 }
8562
8563 PathBuf::from(expanded_str)
8564}
8565
8566async fn resolve_runtime_config_dirs(
8567 default_construct_dir: &Path,
8568 default_workspace_dir: &Path,
8569) -> Result<(PathBuf, PathBuf, ConfigResolutionSource)> {
8570 if let Ok(custom_config_dir) = std::env::var("CONSTRUCT_CONFIG_DIR") {
8571 let custom_config_dir = custom_config_dir.trim();
8572 if !custom_config_dir.is_empty() {
8573 let construct_dir = expand_tilde_path(custom_config_dir);
8574 return Ok((
8575 construct_dir.clone(),
8576 construct_dir.join("workspace"),
8577 ConfigResolutionSource::EnvConfigDir,
8578 ));
8579 }
8580 }
8581
8582 if let Ok(custom_workspace) = std::env::var("CONSTRUCT_WORKSPACE") {
8583 if !custom_workspace.is_empty() {
8584 let expanded = expand_tilde_path(&custom_workspace);
8585 let (construct_dir, workspace_dir) = resolve_config_dir_for_workspace(&expanded);
8586 return Ok((
8587 construct_dir,
8588 workspace_dir,
8589 ConfigResolutionSource::EnvWorkspace,
8590 ));
8591 }
8592 }
8593
8594 if let Some((construct_dir, workspace_dir)) =
8595 load_persisted_workspace_dirs(default_construct_dir).await?
8596 {
8597 return Ok((
8598 construct_dir,
8599 workspace_dir,
8600 ConfigResolutionSource::ActiveWorkspaceMarker,
8601 ));
8602 }
8603
8604 Ok((
8605 default_construct_dir.to_path_buf(),
8606 default_workspace_dir.to_path_buf(),
8607 ConfigResolutionSource::DefaultConfigDir,
8608 ))
8609}
8610
8611fn decrypt_optional_secret(
8612 store: &crate::security::SecretStore,
8613 value: &mut Option<String>,
8614 field_name: &str,
8615) -> Result<()> {
8616 if let Some(raw) = value.clone() {
8617 if crate::security::SecretStore::is_encrypted(&raw) {
8618 *value = Some(
8619 store
8620 .decrypt(&raw)
8621 .with_context(|| format!("Failed to decrypt {field_name}"))?,
8622 );
8623 }
8624 }
8625 Ok(())
8626}
8627
8628fn decrypt_secret(
8629 store: &crate::security::SecretStore,
8630 value: &mut String,
8631 field_name: &str,
8632) -> Result<()> {
8633 if crate::security::SecretStore::is_encrypted(value) {
8634 *value = store
8635 .decrypt(value)
8636 .with_context(|| format!("Failed to decrypt {field_name}"))?;
8637 }
8638 Ok(())
8639}
8640
8641fn encrypt_optional_secret(
8642 store: &crate::security::SecretStore,
8643 value: &mut Option<String>,
8644 field_name: &str,
8645) -> Result<()> {
8646 if let Some(raw) = value.clone() {
8647 if !crate::security::SecretStore::is_encrypted(&raw) {
8648 *value = Some(
8649 store
8650 .encrypt(&raw)
8651 .with_context(|| format!("Failed to encrypt {field_name}"))?,
8652 );
8653 }
8654 }
8655 Ok(())
8656}
8657
8658fn encrypt_secret(
8659 store: &crate::security::SecretStore,
8660 value: &mut String,
8661 field_name: &str,
8662) -> Result<()> {
8663 if !crate::security::SecretStore::is_encrypted(value) {
8664 *value = store
8665 .encrypt(value)
8666 .with_context(|| format!("Failed to encrypt {field_name}"))?;
8667 }
8668 Ok(())
8669}
8670
8671fn config_dir_creation_error(path: &Path) -> String {
8672 format!(
8673 "Failed to create config directory: {}. If running as an OpenRC service, \
8674 ensure this path is writable by user 'construct'.",
8675 path.display()
8676 )
8677}
8678
8679fn is_local_ollama_endpoint(api_url: Option<&str>) -> bool {
8680 let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
8681 return true;
8682 };
8683
8684 reqwest::Url::parse(raw)
8685 .ok()
8686 .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
8687 .is_some_and(|host| matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1" | "0.0.0.0"))
8688}
8689
8690fn has_ollama_cloud_credential(config_api_key: Option<&str>) -> bool {
8691 let config_key_present = config_api_key
8692 .map(str::trim)
8693 .is_some_and(|value| !value.is_empty());
8694 if config_key_present {
8695 return true;
8696 }
8697
8698 ["OLLAMA_API_KEY", "CONSTRUCT_API_KEY", "API_KEY"]
8699 .iter()
8700 .any(|name| {
8701 std::env::var(name)
8702 .ok()
8703 .is_some_and(|value| !value.trim().is_empty())
8704 })
8705}
8706
8707pub fn parse_extra_headers_env(raw: &str) -> Vec<(String, String)> {
8714 let mut result = Vec::new();
8715 for entry in raw.split(',') {
8716 let entry = entry.trim();
8717 if entry.is_empty() {
8718 continue;
8719 }
8720 if let Some((key, value)) = entry.split_once(':') {
8721 let key = key.trim();
8722 let value = value.trim();
8723 if key.is_empty() {
8724 tracing::warn!("Ignoring extra header with empty name in CONSTRUCT_EXTRA_HEADERS");
8725 continue;
8726 }
8727 result.push((key.to_string(), value.to_string()));
8728 } else {
8729 tracing::warn!("Ignoring malformed extra header entry (missing ':'): {entry}");
8730 }
8731 }
8732 result
8733}
8734
8735fn normalize_wire_api(raw: &str) -> Option<&'static str> {
8736 match raw.trim().to_ascii_lowercase().as_str() {
8737 "responses" | "openai-responses" | "open-ai-responses" => Some("responses"),
8738 "chat_completions"
8739 | "chat-completions"
8740 | "chat"
8741 | "chatcompletions"
8742 | "openai-chat-completions"
8743 | "open-ai-chat-completions" => Some("chat_completions"),
8744 _ => None,
8745 }
8746}
8747
8748fn read_codex_openai_api_key() -> Option<String> {
8749 let home = UserDirs::new()?.home_dir().to_path_buf();
8750 let auth_path = home.join(".codex").join("auth.json");
8751 let raw = std::fs::read_to_string(auth_path).ok()?;
8752 let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
8753
8754 parsed
8755 .get("OPENAI_API_KEY")
8756 .and_then(serde_json::Value::as_str)
8757 .map(str::trim)
8758 .filter(|value| !value.is_empty())
8759 .map(ToString::to_string)
8760}
8761
8762async fn ensure_bootstrap_files(workspace_dir: &Path) -> Result<()> {
8768 let defaults: &[(&str, &str)] = &[
8769 (
8770 "IDENTITY.md",
8771 "# IDENTITY.md — Who Am I?\n\n\
8772 I am Construct, an autonomous AI agent.\n\n\
8773 ## Traits\n\
8774 - Helpful, precise, and safety-conscious\n\
8775 - I prioritize clarity and correctness\n",
8776 ),
8777 (
8778 "SOUL.md",
8779 "# SOUL.md — Who You Are\n\n\
8780 You are Construct, an autonomous AI agent.\n\n\
8781 ## Core Principles\n\
8782 - Be helpful and accurate\n\
8783 - Respect user intent and boundaries\n\
8784 - Ask before taking destructive actions\n\
8785 - Prefer safe, reversible operations\n",
8786 ),
8787 ];
8788
8789 for (filename, content) in defaults {
8790 let path = workspace_dir.join(filename);
8791 if !path.exists() {
8792 fs::write(&path, content)
8793 .await
8794 .with_context(|| format!("Failed to create default {filename} in workspace"))?;
8795 }
8796 }
8797
8798 Ok(())
8799}
8800
8801impl Config {
8802 pub async fn load_or_init() -> Result<Self> {
8803 let (default_construct_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
8804
8805 let (construct_dir, workspace_dir, resolution_source) =
8806 resolve_runtime_config_dirs(&default_construct_dir, &default_workspace_dir).await?;
8807
8808 let config_path = construct_dir.join("config.toml");
8809
8810 fs::create_dir_all(&construct_dir)
8811 .await
8812 .with_context(|| config_dir_creation_error(&construct_dir))?;
8813 fs::create_dir_all(&workspace_dir)
8814 .await
8815 .context("Failed to create workspace directory")?;
8816
8817 ensure_bootstrap_files(&workspace_dir).await?;
8818
8819 if config_path.exists() {
8820 #[cfg(unix)]
8822 {
8823 use std::os::unix::fs::PermissionsExt;
8824 if let Ok(meta) = fs::metadata(&config_path).await {
8825 if meta.permissions().mode() & 0o004 != 0 {
8826 tracing::warn!(
8827 "Config file {:?} is world-readable (mode {:o}). \
8828 Consider restricting with: chmod 600 {:?}",
8829 config_path,
8830 meta.permissions().mode() & 0o777,
8831 config_path,
8832 );
8833 }
8834 }
8835 }
8836
8837 let contents = fs::read_to_string(&config_path)
8838 .await
8839 .context("Failed to read config file")?;
8840
8841 let mut config: Config =
8853 toml::from_str(&contents).context("Failed to deserialize config file")?;
8854
8855 config.autonomy.ensure_default_auto_approve();
8867
8868 if let Ok(raw) = contents.parse::<toml::Table>() {
8873 static KNOWN_KEYS: OnceLock<Vec<String>> = OnceLock::new();
8876 let known = KNOWN_KEYS.get_or_init(|| {
8877 toml::to_string(&Config::default())
8878 .ok()
8879 .and_then(|s| s.parse::<toml::Table>().ok())
8880 .map(|t| t.keys().cloned().collect())
8881 .unwrap_or_default()
8882 });
8883 for key in raw.keys() {
8884 if !known.contains(key) {
8885 tracing::warn!(
8886 "Unknown config key ignored: \"{key}\". Check config.toml for typos or deprecated options.",
8887 );
8888 }
8889 }
8890 }
8891 config.config_path = config_path.clone();
8893 config.workspace_dir = workspace_dir;
8894 let store = crate::security::SecretStore::new(&construct_dir, config.secrets.encrypt);
8895 decrypt_optional_secret(&store, &mut config.api_key, "config.api_key")?;
8896 decrypt_optional_secret(
8897 &store,
8898 &mut config.composio.api_key,
8899 "config.composio.api_key",
8900 )?;
8901 if let Some(ref mut pinggy) = config.tunnel.pinggy {
8902 decrypt_optional_secret(&store, &mut pinggy.token, "config.tunnel.pinggy.token")?;
8903 }
8904 decrypt_optional_secret(
8905 &store,
8906 &mut config.microsoft365.client_secret,
8907 "config.microsoft365.client_secret",
8908 )?;
8909
8910 decrypt_optional_secret(
8911 &store,
8912 &mut config.browser.computer_use.api_key,
8913 "config.browser.computer_use.api_key",
8914 )?;
8915
8916 decrypt_optional_secret(
8917 &store,
8918 &mut config.web_search.brave_api_key,
8919 "config.web_search.brave_api_key",
8920 )?;
8921
8922 decrypt_optional_secret(
8923 &store,
8924 &mut config.storage.provider.config.db_url,
8925 "config.storage.provider.config.db_url",
8926 )?;
8927
8928 for agent in config.agents.values_mut() {
8929 decrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
8930 }
8931
8932 if let Some(ref mut openai) = config.tts.openai {
8934 decrypt_optional_secret(&store, &mut openai.api_key, "config.tts.openai.api_key")?;
8935 }
8936 if let Some(ref mut elevenlabs) = config.tts.elevenlabs {
8937 decrypt_optional_secret(
8938 &store,
8939 &mut elevenlabs.api_key,
8940 "config.tts.elevenlabs.api_key",
8941 )?;
8942 }
8943 if let Some(ref mut google) = config.tts.google {
8944 decrypt_optional_secret(&store, &mut google.api_key, "config.tts.google.api_key")?;
8945 }
8946
8947 decrypt_optional_secret(
8949 &store,
8950 &mut config.transcription.api_key,
8951 "config.transcription.api_key",
8952 )?;
8953 if let Some(ref mut openai) = config.transcription.openai {
8954 decrypt_optional_secret(
8955 &store,
8956 &mut openai.api_key,
8957 "config.transcription.openai.api_key",
8958 )?;
8959 }
8960 if let Some(ref mut deepgram) = config.transcription.deepgram {
8961 decrypt_optional_secret(
8962 &store,
8963 &mut deepgram.api_key,
8964 "config.transcription.deepgram.api_key",
8965 )?;
8966 }
8967 if let Some(ref mut assemblyai) = config.transcription.assemblyai {
8968 decrypt_optional_secret(
8969 &store,
8970 &mut assemblyai.api_key,
8971 "config.transcription.assemblyai.api_key",
8972 )?;
8973 }
8974 if let Some(ref mut google) = config.transcription.google {
8975 decrypt_optional_secret(
8976 &store,
8977 &mut google.api_key,
8978 "config.transcription.google.api_key",
8979 )?;
8980 }
8981 if let Some(ref mut local) = config.transcription.local_whisper {
8982 decrypt_optional_secret(
8983 &store,
8984 &mut local.bearer_token,
8985 "config.transcription.local_whisper.bearer_token",
8986 )?;
8987 }
8988
8989 #[cfg(feature = "channel-nostr")]
8990 if let Some(ref mut ns) = config.channels_config.nostr {
8991 decrypt_secret(
8992 &store,
8993 &mut ns.private_key,
8994 "config.channels_config.nostr.private_key",
8995 )?;
8996 }
8997 if let Some(ref mut fs) = config.channels_config.feishu {
8998 decrypt_secret(
8999 &store,
9000 &mut fs.app_secret,
9001 "config.channels_config.feishu.app_secret",
9002 )?;
9003 decrypt_optional_secret(
9004 &store,
9005 &mut fs.encrypt_key,
9006 "config.channels_config.feishu.encrypt_key",
9007 )?;
9008 decrypt_optional_secret(
9009 &store,
9010 &mut fs.verification_token,
9011 "config.channels_config.feishu.verification_token",
9012 )?;
9013 }
9014
9015 if let Some(ref mut tg) = config.channels_config.telegram {
9017 decrypt_secret(
9018 &store,
9019 &mut tg.bot_token,
9020 "config.channels_config.telegram.bot_token",
9021 )?;
9022 }
9023 if let Some(ref mut dc) = config.channels_config.discord {
9024 decrypt_secret(
9025 &store,
9026 &mut dc.bot_token,
9027 "config.channels_config.discord.bot_token",
9028 )?;
9029 }
9030 if let Some(ref mut sl) = config.channels_config.slack {
9031 decrypt_secret(
9032 &store,
9033 &mut sl.bot_token,
9034 "config.channels_config.slack.bot_token",
9035 )?;
9036 decrypt_optional_secret(
9037 &store,
9038 &mut sl.app_token,
9039 "config.channels_config.slack.app_token",
9040 )?;
9041 }
9042 if let Some(ref mut mm) = config.channels_config.mattermost {
9043 decrypt_secret(
9044 &store,
9045 &mut mm.bot_token,
9046 "config.channels_config.mattermost.bot_token",
9047 )?;
9048 }
9049 if let Some(ref mut mx) = config.channels_config.matrix {
9050 decrypt_secret(
9051 &store,
9052 &mut mx.access_token,
9053 "config.channels_config.matrix.access_token",
9054 )?;
9055 decrypt_optional_secret(
9056 &store,
9057 &mut mx.recovery_key,
9058 "config.channels_config.matrix.recovery_key",
9059 )?;
9060 }
9061 if let Some(ref mut wa) = config.channels_config.whatsapp {
9062 decrypt_optional_secret(
9063 &store,
9064 &mut wa.access_token,
9065 "config.channels_config.whatsapp.access_token",
9066 )?;
9067 decrypt_optional_secret(
9068 &store,
9069 &mut wa.app_secret,
9070 "config.channels_config.whatsapp.app_secret",
9071 )?;
9072 decrypt_optional_secret(
9073 &store,
9074 &mut wa.verify_token,
9075 "config.channels_config.whatsapp.verify_token",
9076 )?;
9077 }
9078 if let Some(ref mut lq) = config.channels_config.linq {
9079 decrypt_secret(
9080 &store,
9081 &mut lq.api_token,
9082 "config.channels_config.linq.api_token",
9083 )?;
9084 decrypt_optional_secret(
9085 &store,
9086 &mut lq.signing_secret,
9087 "config.channels_config.linq.signing_secret",
9088 )?;
9089 }
9090 if let Some(ref mut wt) = config.channels_config.wati {
9091 decrypt_secret(
9092 &store,
9093 &mut wt.api_token,
9094 "config.channels_config.wati.api_token",
9095 )?;
9096 }
9097 if let Some(ref mut nc) = config.channels_config.nextcloud_talk {
9098 decrypt_secret(
9099 &store,
9100 &mut nc.app_token,
9101 "config.channels_config.nextcloud_talk.app_token",
9102 )?;
9103 decrypt_optional_secret(
9104 &store,
9105 &mut nc.webhook_secret,
9106 "config.channels_config.nextcloud_talk.webhook_secret",
9107 )?;
9108 }
9109 if let Some(ref mut em) = config.channels_config.email {
9110 decrypt_secret(
9111 &store,
9112 &mut em.password,
9113 "config.channels_config.email.password",
9114 )?;
9115 }
9116 if let Some(ref mut gp) = config.channels_config.gmail_push {
9117 decrypt_secret(
9118 &store,
9119 &mut gp.oauth_token,
9120 "config.channels_config.gmail_push.oauth_token",
9121 )?;
9122 }
9123 if let Some(ref mut irc) = config.channels_config.irc {
9124 decrypt_optional_secret(
9125 &store,
9126 &mut irc.server_password,
9127 "config.channels_config.irc.server_password",
9128 )?;
9129 decrypt_optional_secret(
9130 &store,
9131 &mut irc.nickserv_password,
9132 "config.channels_config.irc.nickserv_password",
9133 )?;
9134 decrypt_optional_secret(
9135 &store,
9136 &mut irc.sasl_password,
9137 "config.channels_config.irc.sasl_password",
9138 )?;
9139 }
9140 if let Some(ref mut lk) = config.channels_config.lark {
9141 decrypt_secret(
9142 &store,
9143 &mut lk.app_secret,
9144 "config.channels_config.lark.app_secret",
9145 )?;
9146 decrypt_optional_secret(
9147 &store,
9148 &mut lk.encrypt_key,
9149 "config.channels_config.lark.encrypt_key",
9150 )?;
9151 decrypt_optional_secret(
9152 &store,
9153 &mut lk.verification_token,
9154 "config.channels_config.lark.verification_token",
9155 )?;
9156 }
9157 if let Some(ref mut fs) = config.channels_config.feishu {
9158 decrypt_secret(
9159 &store,
9160 &mut fs.app_secret,
9161 "config.channels_config.feishu.app_secret",
9162 )?;
9163 decrypt_optional_secret(
9164 &store,
9165 &mut fs.encrypt_key,
9166 "config.channels_config.feishu.encrypt_key",
9167 )?;
9168 decrypt_optional_secret(
9169 &store,
9170 &mut fs.verification_token,
9171 "config.channels_config.feishu.verification_token",
9172 )?;
9173 }
9174 if let Some(ref mut dt) = config.channels_config.dingtalk {
9175 decrypt_secret(
9176 &store,
9177 &mut dt.client_secret,
9178 "config.channels_config.dingtalk.client_secret",
9179 )?;
9180 }
9181 if let Some(ref mut wc) = config.channels_config.wecom {
9182 decrypt_secret(
9183 &store,
9184 &mut wc.webhook_key,
9185 "config.channels_config.wecom.webhook_key",
9186 )?;
9187 }
9188 if let Some(ref mut qq) = config.channels_config.qq {
9189 decrypt_secret(
9190 &store,
9191 &mut qq.app_secret,
9192 "config.channels_config.qq.app_secret",
9193 )?;
9194 }
9195 if let Some(ref mut wh) = config.channels_config.webhook {
9196 decrypt_optional_secret(
9197 &store,
9198 &mut wh.secret,
9199 "config.channels_config.webhook.secret",
9200 )?;
9201 }
9202 if let Some(ref mut ct) = config.channels_config.clawdtalk {
9203 decrypt_secret(
9204 &store,
9205 &mut ct.api_key,
9206 "config.channels_config.clawdtalk.api_key",
9207 )?;
9208 decrypt_optional_secret(
9209 &store,
9210 &mut ct.webhook_secret,
9211 "config.channels_config.clawdtalk.webhook_secret",
9212 )?;
9213 }
9214
9215 for token in &mut config.gateway.paired_tokens {
9217 decrypt_secret(&store, token, "config.gateway.paired_tokens[]")?;
9218 }
9219
9220 decrypt_optional_secret(
9222 &store,
9223 &mut config.security.nevis.client_secret,
9224 "config.security.nevis.client_secret",
9225 )?;
9226
9227 if !config.notion.api_key.is_empty() {
9229 decrypt_secret(&store, &mut config.notion.api_key, "config.notion.api_key")?;
9230 }
9231
9232 if !config.jira.api_token.is_empty() {
9234 decrypt_secret(&store, &mut config.jira.api_token, "config.jira.api_token")?;
9235 }
9236
9237 config.apply_env_overrides();
9238 config.validate()?;
9239 tracing::info!(
9240 path = %config.config_path.display(),
9241 workspace = %config.workspace_dir.display(),
9242 source = resolution_source.as_str(),
9243 initialized = true,
9244 "Config loaded"
9245 );
9246 Ok(config)
9247 } else {
9248 let mut config = Config::default();
9249 config.config_path = config_path.clone();
9250 config.workspace_dir = workspace_dir;
9251 config.save().await?;
9252
9253 #[cfg(unix)]
9255 {
9256 use std::{fs::Permissions, os::unix::fs::PermissionsExt};
9257 let _ = fs::set_permissions(&config_path, Permissions::from_mode(0o600)).await;
9258 }
9259
9260 config.apply_env_overrides();
9261 config.validate()?;
9262 tracing::info!(
9263 path = %config.config_path.display(),
9264 workspace = %config.workspace_dir.display(),
9265 source = resolution_source.as_str(),
9266 initialized = true,
9267 "Config loaded"
9268 );
9269 Ok(config)
9270 }
9271 }
9272
9273 fn lookup_model_provider_profile(
9274 &self,
9275 provider_name: &str,
9276 ) -> Option<(String, ModelProviderConfig)> {
9277 let needle = provider_name.trim();
9278 if needle.is_empty() {
9279 return None;
9280 }
9281
9282 self.model_providers
9283 .iter()
9284 .find(|(name, _)| name.eq_ignore_ascii_case(needle))
9285 .map(|(name, profile)| (name.clone(), profile.clone()))
9286 }
9287
9288 fn apply_named_model_provider_profile(&mut self) {
9289 let Some(current_provider) = self.default_provider.clone() else {
9290 return;
9291 };
9292
9293 let Some((profile_key, profile)) = self.lookup_model_provider_profile(¤t_provider)
9294 else {
9295 return;
9296 };
9297
9298 let base_url = profile
9299 .base_url
9300 .as_deref()
9301 .map(str::trim)
9302 .filter(|value| !value.is_empty())
9303 .map(ToString::to_string);
9304
9305 if self
9306 .api_url
9307 .as_deref()
9308 .map(str::trim)
9309 .is_none_or(|value| value.is_empty())
9310 {
9311 if let Some(base_url) = base_url.as_ref() {
9312 self.api_url = Some(base_url.clone());
9313 }
9314 }
9315
9316 if self.api_path.is_none() {
9318 if let Some(ref path) = profile.api_path {
9319 let trimmed = path.trim();
9320 if !trimmed.is_empty() {
9321 self.api_path = Some(trimmed.to_string());
9322 }
9323 }
9324 }
9325
9326 if self.provider_max_tokens.is_none() {
9328 if let Some(max_tokens) = profile.max_tokens {
9329 self.provider_max_tokens = Some(max_tokens);
9330 }
9331 }
9332
9333 if profile.requires_openai_auth
9334 && self
9335 .api_key
9336 .as_deref()
9337 .map(str::trim)
9338 .is_none_or(|value| value.is_empty())
9339 {
9340 let codex_key = std::env::var("OPENAI_API_KEY")
9341 .ok()
9342 .map(|value| value.trim().to_string())
9343 .filter(|value| !value.is_empty())
9344 .or_else(read_codex_openai_api_key);
9345 if let Some(codex_key) = codex_key {
9346 self.api_key = Some(codex_key);
9347 }
9348 }
9349
9350 let normalized_wire_api = profile.wire_api.as_deref().and_then(normalize_wire_api);
9351 let profile_name = profile
9352 .name
9353 .as_deref()
9354 .map(str::trim)
9355 .filter(|value| !value.is_empty());
9356
9357 if normalized_wire_api == Some("responses") {
9358 self.default_provider = Some("openai-codex".to_string());
9359 return;
9360 }
9361
9362 if let Some(profile_name) = profile_name {
9363 if !profile_name.eq_ignore_ascii_case(&profile_key) {
9364 self.default_provider = Some(profile_name.to_string());
9365 return;
9366 }
9367 }
9368
9369 if let Some(base_url) = base_url {
9370 self.default_provider = Some(format!("custom:{base_url}"));
9371 }
9372 }
9373
9374 pub fn validate(&self) -> Result<()> {
9379 if self.tunnel.provider.trim() == "openvpn" {
9381 let openvpn = self.tunnel.openvpn.as_ref().ok_or_else(|| {
9382 anyhow::anyhow!("tunnel.provider='openvpn' requires [tunnel.openvpn]")
9383 })?;
9384
9385 if openvpn.config_file.trim().is_empty() {
9386 anyhow::bail!("tunnel.openvpn.config_file must not be empty");
9387 }
9388 if openvpn.connect_timeout_secs == 0 {
9389 anyhow::bail!("tunnel.openvpn.connect_timeout_secs must be greater than 0");
9390 }
9391 }
9392
9393 if self.gateway.host.trim().is_empty() {
9395 anyhow::bail!("gateway.host must not be empty");
9396 }
9397 if let Some(ref prefix) = self.gateway.path_prefix {
9398 if !prefix.is_empty() {
9401 if !prefix.starts_with('/') {
9402 anyhow::bail!("gateway.path_prefix must start with '/'");
9403 }
9404 if prefix.ends_with('/') {
9405 anyhow::bail!("gateway.path_prefix must not end with '/' (including bare '/')");
9406 }
9407 if let Some(bad) = prefix.chars().find(|c| {
9410 !matches!(c, '/' | '-' | '_' | '.' | '~'
9411 | 'a'..='z' | 'A'..='Z' | '0'..='9'
9412 | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '='
9413 | ':' | '@')
9414 }) {
9415 anyhow::bail!(
9416 "gateway.path_prefix contains invalid character '{bad}'; \
9417 only unreserved and sub-delim URI characters are allowed"
9418 );
9419 }
9420 }
9421 }
9422
9423 if self.autonomy.max_actions_per_hour == 0 {
9425 anyhow::bail!("autonomy.max_actions_per_hour must be greater than 0");
9426 }
9427 for (i, env_name) in self.autonomy.shell_env_passthrough.iter().enumerate() {
9428 if !is_valid_env_var_name(env_name) {
9429 anyhow::bail!(
9430 "autonomy.shell_env_passthrough[{i}] is invalid ({env_name}); expected [A-Za-z_][A-Za-z0-9_]*"
9431 );
9432 }
9433 }
9434
9435 if self.security.otp.challenge_max_attempts == 0 {
9437 anyhow::bail!("security.otp.challenge_max_attempts must be greater than 0");
9438 }
9439 if self.security.otp.token_ttl_secs == 0 {
9440 anyhow::bail!("security.otp.token_ttl_secs must be greater than 0");
9441 }
9442 if self.security.otp.cache_valid_secs == 0 {
9443 anyhow::bail!("security.otp.cache_valid_secs must be greater than 0");
9444 }
9445 if self.security.otp.cache_valid_secs < self.security.otp.token_ttl_secs {
9446 anyhow::bail!(
9447 "security.otp.cache_valid_secs must be greater than or equal to security.otp.token_ttl_secs"
9448 );
9449 }
9450 if self.security.otp.challenge_max_attempts == 0 {
9451 anyhow::bail!("security.otp.challenge_max_attempts must be greater than 0");
9452 }
9453 for (i, action) in self.security.otp.gated_actions.iter().enumerate() {
9454 let normalized = action.trim();
9455 if normalized.is_empty() {
9456 anyhow::bail!("security.otp.gated_actions[{i}] must not be empty");
9457 }
9458 if !normalized
9459 .chars()
9460 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
9461 {
9462 anyhow::bail!(
9463 "security.otp.gated_actions[{i}] contains invalid characters: {normalized}"
9464 );
9465 }
9466 }
9467 DomainMatcher::new(
9468 &self.security.otp.gated_domains,
9469 &self.security.otp.gated_domain_categories,
9470 )
9471 .with_context(
9472 || "Invalid security.otp.gated_domains or security.otp.gated_domain_categories",
9473 )?;
9474 if self.security.estop.state_file.trim().is_empty() {
9475 anyhow::bail!("security.estop.state_file must not be empty");
9476 }
9477
9478 if self.scheduler.max_concurrent == 0 {
9480 anyhow::bail!("scheduler.max_concurrent must be greater than 0");
9481 }
9482 if self.scheduler.max_tasks == 0 {
9483 anyhow::bail!("scheduler.max_tasks must be greater than 0");
9484 }
9485
9486 for (i, route) in self.model_routes.iter().enumerate() {
9488 if route.hint.trim().is_empty() {
9489 anyhow::bail!("model_routes[{i}].hint must not be empty");
9490 }
9491 if route.provider.trim().is_empty() {
9492 anyhow::bail!("model_routes[{i}].provider must not be empty");
9493 }
9494 if route.model.trim().is_empty() {
9495 anyhow::bail!("model_routes[{i}].model must not be empty");
9496 }
9497 }
9498
9499 for (i, route) in self.embedding_routes.iter().enumerate() {
9501 if route.hint.trim().is_empty() {
9502 anyhow::bail!("embedding_routes[{i}].hint must not be empty");
9503 }
9504 if route.provider.trim().is_empty() {
9505 anyhow::bail!("embedding_routes[{i}].provider must not be empty");
9506 }
9507 if route.model.trim().is_empty() {
9508 anyhow::bail!("embedding_routes[{i}].model must not be empty");
9509 }
9510 }
9511
9512 for (profile_key, profile) in &self.model_providers {
9513 let profile_name = profile_key.trim();
9514 if profile_name.is_empty() {
9515 anyhow::bail!("model_providers contains an empty profile name");
9516 }
9517
9518 let has_name = profile
9519 .name
9520 .as_deref()
9521 .map(str::trim)
9522 .is_some_and(|value| !value.is_empty());
9523 let has_base_url = profile
9524 .base_url
9525 .as_deref()
9526 .map(str::trim)
9527 .is_some_and(|value| !value.is_empty());
9528
9529 if !has_name && !has_base_url {
9530 anyhow::bail!(
9531 "model_providers.{profile_name} must define at least one of `name` or `base_url`"
9532 );
9533 }
9534
9535 if let Some(base_url) = profile.base_url.as_deref().map(str::trim) {
9536 if !base_url.is_empty() {
9537 let parsed = reqwest::Url::parse(base_url).with_context(|| {
9538 format!("model_providers.{profile_name}.base_url is not a valid URL")
9539 })?;
9540 if !matches!(parsed.scheme(), "http" | "https") {
9541 anyhow::bail!(
9542 "model_providers.{profile_name}.base_url must use http/https"
9543 );
9544 }
9545 }
9546 }
9547
9548 if let Some(wire_api) = profile.wire_api.as_deref().map(str::trim) {
9549 if !wire_api.is_empty() && normalize_wire_api(wire_api).is_none() {
9550 anyhow::bail!(
9551 "model_providers.{profile_name}.wire_api must be one of: responses, chat_completions"
9552 );
9553 }
9554 }
9555 }
9556
9557 if self
9559 .default_provider
9560 .as_deref()
9561 .is_some_and(|provider| provider.trim().eq_ignore_ascii_case("ollama"))
9562 && self
9563 .default_model
9564 .as_deref()
9565 .is_some_and(|model| model.trim().ends_with(":cloud"))
9566 {
9567 if is_local_ollama_endpoint(self.api_url.as_deref()) {
9568 anyhow::bail!(
9569 "default_model uses ':cloud' with provider 'ollama', but api_url is local or unset. Set api_url to a remote Ollama endpoint (for example https://ollama.com)."
9570 );
9571 }
9572
9573 if !has_ollama_cloud_credential(self.api_key.as_deref()) {
9574 anyhow::bail!(
9575 "default_model uses ':cloud' with provider 'ollama', but no API key is configured. Set api_key or OLLAMA_API_KEY."
9576 );
9577 }
9578 }
9579
9580 if self.microsoft365.enabled {
9582 let tenant = self
9583 .microsoft365
9584 .tenant_id
9585 .as_deref()
9586 .map(str::trim)
9587 .filter(|s| !s.is_empty());
9588 if tenant.is_none() {
9589 anyhow::bail!(
9590 "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
9591 );
9592 }
9593 let client = self
9594 .microsoft365
9595 .client_id
9596 .as_deref()
9597 .map(str::trim)
9598 .filter(|s| !s.is_empty());
9599 if client.is_none() {
9600 anyhow::bail!(
9601 "microsoft365.client_id must not be empty when microsoft365 is enabled"
9602 );
9603 }
9604 let flow = self.microsoft365.auth_flow.trim();
9605 if flow != "client_credentials" && flow != "device_code" {
9606 anyhow::bail!(
9607 "microsoft365.auth_flow must be 'client_credentials' or 'device_code'"
9608 );
9609 }
9610 if flow == "client_credentials"
9611 && self
9612 .microsoft365
9613 .client_secret
9614 .as_deref()
9615 .map_or(true, |s| s.trim().is_empty())
9616 {
9617 anyhow::bail!(
9618 "microsoft365.client_secret must not be empty when auth_flow is 'client_credentials'"
9619 );
9620 }
9621 }
9622
9623 if self.microsoft365.enabled {
9625 let tenant = self
9626 .microsoft365
9627 .tenant_id
9628 .as_deref()
9629 .map(str::trim)
9630 .filter(|s| !s.is_empty());
9631 if tenant.is_none() {
9632 anyhow::bail!(
9633 "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
9634 );
9635 }
9636 let client = self
9637 .microsoft365
9638 .client_id
9639 .as_deref()
9640 .map(str::trim)
9641 .filter(|s| !s.is_empty());
9642 if client.is_none() {
9643 anyhow::bail!(
9644 "microsoft365.client_id must not be empty when microsoft365 is enabled"
9645 );
9646 }
9647 let flow = self.microsoft365.auth_flow.trim();
9648 if flow != "client_credentials" && flow != "device_code" {
9649 anyhow::bail!("microsoft365.auth_flow must be client_credentials or device_code");
9650 }
9651 if flow == "client_credentials"
9652 && self
9653 .microsoft365
9654 .client_secret
9655 .as_deref()
9656 .map_or(true, |s| s.trim().is_empty())
9657 {
9658 anyhow::bail!(
9659 "microsoft365.client_secret must not be empty when auth_flow is client_credentials"
9660 );
9661 }
9662 }
9663
9664 if self.mcp.enabled {
9666 validate_mcp_config(&self.mcp)?;
9667 }
9668
9669 let mut seen_gws_services = std::collections::HashSet::new();
9671 for (i, service) in self.google_workspace.allowed_services.iter().enumerate() {
9672 let normalized = service.trim();
9673 if normalized.is_empty() {
9674 anyhow::bail!("google_workspace.allowed_services[{i}] must not be empty");
9675 }
9676 if !normalized
9677 .chars()
9678 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
9679 {
9680 anyhow::bail!(
9681 "google_workspace.allowed_services[{i}] contains invalid characters: {normalized}"
9682 );
9683 }
9684 if !seen_gws_services.insert(normalized.to_string()) {
9685 anyhow::bail!(
9686 "google_workspace.allowed_services contains duplicate entry: {normalized}"
9687 );
9688 }
9689 }
9690
9691 let effective_services: std::collections::HashSet<&str> =
9696 if self.google_workspace.allowed_services.is_empty() {
9697 DEFAULT_GWS_SERVICES.iter().copied().collect()
9698 } else {
9699 self.google_workspace
9700 .allowed_services
9701 .iter()
9702 .map(|s| s.trim())
9703 .collect()
9704 };
9705
9706 let mut seen_gws_operations = std::collections::HashSet::new();
9707 for (i, operation) in self.google_workspace.allowed_operations.iter().enumerate() {
9708 let service = operation.service.trim();
9709 let resource = operation.resource.trim();
9710
9711 if service.is_empty() {
9712 anyhow::bail!("google_workspace.allowed_operations[{i}].service must not be empty");
9713 }
9714 if resource.is_empty() {
9715 anyhow::bail!(
9716 "google_workspace.allowed_operations[{i}].resource must not be empty"
9717 );
9718 }
9719
9720 if !effective_services.contains(service) {
9721 anyhow::bail!(
9722 "google_workspace.allowed_operations[{i}].service '{service}' is not in the \
9723 effective allowed_services; this entry can never match at runtime"
9724 );
9725 }
9726 if !service
9727 .chars()
9728 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
9729 {
9730 anyhow::bail!(
9731 "google_workspace.allowed_operations[{i}].service contains invalid characters: {service}"
9732 );
9733 }
9734 if !resource
9735 .chars()
9736 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
9737 {
9738 anyhow::bail!(
9739 "google_workspace.allowed_operations[{i}].resource contains invalid characters: {resource}"
9740 );
9741 }
9742
9743 if let Some(ref sub_resource) = operation.sub_resource {
9744 let sub = sub_resource.trim();
9745 if sub.is_empty() {
9746 anyhow::bail!(
9747 "google_workspace.allowed_operations[{i}].sub_resource must not be empty when present"
9748 );
9749 }
9750 if !sub
9751 .chars()
9752 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
9753 {
9754 anyhow::bail!(
9755 "google_workspace.allowed_operations[{i}].sub_resource contains invalid characters: {sub}"
9756 );
9757 }
9758 }
9759
9760 if operation.methods.is_empty() {
9761 anyhow::bail!("google_workspace.allowed_operations[{i}].methods must not be empty");
9762 }
9763
9764 let mut seen_methods = std::collections::HashSet::new();
9765 for (j, method) in operation.methods.iter().enumerate() {
9766 let normalized = method.trim();
9767 if normalized.is_empty() {
9768 anyhow::bail!(
9769 "google_workspace.allowed_operations[{i}].methods[{j}] must not be empty"
9770 );
9771 }
9772 if !normalized
9773 .chars()
9774 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
9775 {
9776 anyhow::bail!(
9777 "google_workspace.allowed_operations[{i}].methods[{j}] contains invalid characters: {normalized}"
9778 );
9779 }
9780 if !seen_methods.insert(normalized.to_string()) {
9781 anyhow::bail!(
9782 "google_workspace.allowed_operations[{i}].methods contains duplicate entry: {normalized}"
9783 );
9784 }
9785 }
9786
9787 let sub_key = operation
9788 .sub_resource
9789 .as_deref()
9790 .map(str::trim)
9791 .unwrap_or("");
9792 let operation_key = format!("{service}:{resource}:{sub_key}");
9793 if !seen_gws_operations.insert(operation_key.clone()) {
9794 anyhow::bail!(
9795 "google_workspace.allowed_operations contains duplicate service/resource/sub_resource entry: {operation_key}"
9796 );
9797 }
9798 }
9799
9800 if self.project_intel.enabled {
9802 let lang = &self.project_intel.default_language;
9803 if !["en", "de", "fr", "it"].contains(&lang.as_str()) {
9804 anyhow::bail!(
9805 "project_intel.default_language must be one of: en, de, fr, it (got '{lang}')"
9806 );
9807 }
9808 let sens = &self.project_intel.risk_sensitivity;
9809 if !["low", "medium", "high"].contains(&sens.as_str()) {
9810 anyhow::bail!(
9811 "project_intel.risk_sensitivity must be one of: low, medium, high (got '{sens}')"
9812 );
9813 }
9814 if let Some(ref tpl_dir) = self.project_intel.templates_dir {
9815 let path = std::path::Path::new(tpl_dir);
9816 if !path.exists() {
9817 anyhow::bail!("project_intel.templates_dir path does not exist: {tpl_dir}");
9818 }
9819 }
9820 }
9821
9822 self.proxy.validate()?;
9824 self.cloud_ops.validate()?;
9825
9826 if self.notion.enabled {
9828 if self.notion.database_id.trim().is_empty() {
9829 anyhow::bail!("notion.database_id must not be empty when notion.enabled = true");
9830 }
9831 if self.notion.poll_interval_secs == 0 {
9832 anyhow::bail!("notion.poll_interval_secs must be greater than 0");
9833 }
9834 if self.notion.max_concurrent == 0 {
9835 anyhow::bail!("notion.max_concurrent must be greater than 0");
9836 }
9837 if self.notion.status_property.trim().is_empty() {
9838 anyhow::bail!("notion.status_property must not be empty");
9839 }
9840 if self.notion.input_property.trim().is_empty() {
9841 anyhow::bail!("notion.input_property must not be empty");
9842 }
9843 if self.notion.result_property.trim().is_empty() {
9844 anyhow::bail!("notion.result_property must not be empty");
9845 }
9846 }
9847
9848 if let Some(ref pinggy) = self.tunnel.pinggy {
9850 if let Some(ref region) = pinggy.region {
9851 let r = region.trim().to_ascii_lowercase();
9852 if !r.is_empty() && !matches!(r.as_str(), "us" | "eu" | "ap" | "br" | "au") {
9853 anyhow::bail!(
9854 "tunnel.pinggy.region must be one of: us, eu, ap, br, au (or omitted for auto)"
9855 );
9856 }
9857 }
9858 }
9859
9860 if self.jira.enabled {
9862 if self.jira.base_url.trim().is_empty() {
9863 anyhow::bail!("jira.base_url must not be empty when jira.enabled = true");
9864 }
9865 if self.jira.email.trim().is_empty() {
9866 anyhow::bail!("jira.email must not be empty when jira.enabled = true");
9867 }
9868 if self.jira.api_token.trim().is_empty()
9869 && std::env::var("JIRA_API_TOKEN")
9870 .unwrap_or_default()
9871 .trim()
9872 .is_empty()
9873 {
9874 anyhow::bail!(
9875 "jira.api_token must be set (or JIRA_API_TOKEN env var) when jira.enabled = true"
9876 );
9877 }
9878 let valid_actions = ["get_ticket", "search_tickets", "comment_ticket"];
9879 for action in &self.jira.allowed_actions {
9880 if !valid_actions.contains(&action.as_str()) {
9881 anyhow::bail!(
9882 "jira.allowed_actions contains unknown action: '{}'. \
9883 Valid: get_ticket, search_tickets, comment_ticket",
9884 action
9885 );
9886 }
9887 }
9888 }
9889
9890 if let Err(msg) = self.security.nevis.validate() {
9892 anyhow::bail!("security.nevis: {msg}");
9893 }
9894
9895 const MAX_DELEGATE_TIMEOUT_SECS: u64 = 3600;
9897 for (name, agent) in &self.agents {
9898 if let Some(timeout) = agent.timeout_secs {
9899 if timeout == 0 {
9900 anyhow::bail!("agents.{name}.timeout_secs must be greater than 0");
9901 }
9902 if timeout > MAX_DELEGATE_TIMEOUT_SECS {
9903 anyhow::bail!(
9904 "agents.{name}.timeout_secs exceeds max {MAX_DELEGATE_TIMEOUT_SECS}"
9905 );
9906 }
9907 }
9908 if let Some(timeout) = agent.agentic_timeout_secs {
9909 if timeout == 0 {
9910 anyhow::bail!("agents.{name}.agentic_timeout_secs must be greater than 0");
9911 }
9912 if timeout > MAX_DELEGATE_TIMEOUT_SECS {
9913 anyhow::bail!(
9914 "agents.{name}.agentic_timeout_secs exceeds max {MAX_DELEGATE_TIMEOUT_SECS}"
9915 );
9916 }
9917 }
9918 }
9919
9920 {
9922 let dp = self.transcription.default_provider.trim();
9923 match dp {
9924 "groq" | "openai" | "deepgram" | "assemblyai" | "google" | "local_whisper" => {}
9925 other => {
9926 anyhow::bail!(
9927 "transcription.default_provider must be one of: groq, openai, deepgram, assemblyai, google, local_whisper (got '{other}')"
9928 );
9929 }
9930 }
9931 }
9932
9933 if self.delegate.timeout_secs == 0 {
9935 anyhow::bail!("delegate.timeout_secs must be greater than 0");
9936 }
9937 if self.delegate.agentic_timeout_secs == 0 {
9938 anyhow::bail!("delegate.agentic_timeout_secs must be greater than 0");
9939 }
9940
9941 for (name, agent) in &self.agents {
9943 if let Some(t) = agent.timeout_secs {
9944 if t == 0 {
9945 anyhow::bail!("agents.{name}.timeout_secs must be greater than 0");
9946 }
9947 }
9948 if let Some(t) = agent.agentic_timeout_secs {
9949 if t == 0 {
9950 anyhow::bail!("agents.{name}.agentic_timeout_secs must be greater than 0");
9951 }
9952 }
9953 }
9954
9955 Ok(())
9956 }
9957
9958 pub fn apply_env_overrides(&mut self) {
9960 if let Ok(key) = std::env::var("CONSTRUCT_API_KEY").or_else(|_| std::env::var("API_KEY")) {
9962 if !key.is_empty() {
9963 self.api_key = Some(key);
9964 }
9965 }
9966 if self.default_provider.as_deref().is_some_and(is_glm_alias) {
9968 if let Ok(key) = std::env::var("GLM_API_KEY") {
9969 if !key.is_empty() {
9970 self.api_key = Some(key);
9971 }
9972 }
9973 }
9974
9975 if self.default_provider.as_deref().is_some_and(is_zai_alias) {
9977 if let Ok(key) = std::env::var("ZAI_API_KEY") {
9978 if !key.is_empty() {
9979 self.api_key = Some(key);
9980 }
9981 }
9982 }
9983
9984 if let Ok(provider) = std::env::var("CONSTRUCT_PROVIDER") {
9989 if !provider.is_empty() {
9990 self.default_provider = Some(provider);
9991 }
9992 } else if let Ok(provider) =
9993 std::env::var("CONSTRUCT_MODEL_PROVIDER").or_else(|_| std::env::var("MODEL_PROVIDER"))
9994 {
9995 if !provider.is_empty() {
9996 self.default_provider = Some(provider);
9997 }
9998 } else if let Ok(provider) = std::env::var("PROVIDER") {
9999 let should_apply_legacy_provider =
10000 self.default_provider.as_deref().map_or(true, |configured| {
10001 configured.trim().eq_ignore_ascii_case("openrouter")
10002 });
10003 if should_apply_legacy_provider && !provider.is_empty() {
10004 self.default_provider = Some(provider);
10005 }
10006 }
10007
10008 if let Ok(model) = std::env::var("CONSTRUCT_MODEL").or_else(|_| std::env::var("MODEL")) {
10010 if !model.is_empty() {
10011 self.default_model = Some(model);
10012 }
10013 }
10014
10015 if let Ok(timeout_secs) = std::env::var("CONSTRUCT_PROVIDER_TIMEOUT_SECS") {
10017 if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
10018 if timeout_secs > 0 {
10019 self.provider_timeout_secs = timeout_secs;
10020 }
10021 }
10022 }
10023
10024 if let Ok(raw) = std::env::var("CONSTRUCT_EXTRA_HEADERS") {
10028 for header in parse_extra_headers_env(&raw) {
10029 self.extra_headers.insert(header.0, header.1);
10030 }
10031 }
10032
10033 self.apply_named_model_provider_profile();
10035
10036 if let Ok(workspace) = std::env::var("CONSTRUCT_WORKSPACE") {
10038 if !workspace.is_empty() {
10039 let expanded = expand_tilde_path(&workspace);
10040 let (_, workspace_dir) = resolve_config_dir_for_workspace(&expanded);
10041 self.workspace_dir = workspace_dir;
10042 }
10043 }
10044
10045 if let Ok(flag) = std::env::var("CONSTRUCT_OPEN_SKILLS_ENABLED") {
10047 if !flag.trim().is_empty() {
10048 match flag.trim().to_ascii_lowercase().as_str() {
10049 "1" | "true" | "yes" | "on" => self.skills.open_skills_enabled = true,
10050 "0" | "false" | "no" | "off" => self.skills.open_skills_enabled = false,
10051 _ => tracing::warn!(
10052 "Ignoring invalid CONSTRUCT_OPEN_SKILLS_ENABLED (valid: 1|0|true|false|yes|no|on|off)"
10053 ),
10054 }
10055 }
10056 }
10057
10058 if let Ok(path) = std::env::var("CONSTRUCT_OPEN_SKILLS_DIR") {
10060 let trimmed = path.trim();
10061 if !trimmed.is_empty() {
10062 self.skills.open_skills_dir = Some(trimmed.to_string());
10063 }
10064 }
10065
10066 if let Ok(flag) = std::env::var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS") {
10068 if !flag.trim().is_empty() {
10069 match flag.trim().to_ascii_lowercase().as_str() {
10070 "1" | "true" | "yes" | "on" => self.skills.allow_scripts = true,
10071 "0" | "false" | "no" | "off" => self.skills.allow_scripts = false,
10072 _ => tracing::warn!(
10073 "Ignoring invalid CONSTRUCT_SKILLS_ALLOW_SCRIPTS (valid: 1|0|true|false|yes|no|on|off)"
10074 ),
10075 }
10076 }
10077 }
10078
10079 if let Ok(mode) = std::env::var("CONSTRUCT_SKILLS_PROMPT_MODE") {
10081 if !mode.trim().is_empty() {
10082 if let Some(parsed) = parse_skills_prompt_injection_mode(&mode) {
10083 self.skills.prompt_injection_mode = parsed;
10084 } else {
10085 tracing::warn!(
10086 "Ignoring invalid CONSTRUCT_SKILLS_PROMPT_MODE (valid: full|compact)"
10087 );
10088 }
10089 }
10090 }
10091
10092 if let Ok(port_str) =
10094 std::env::var("CONSTRUCT_GATEWAY_PORT").or_else(|_| std::env::var("PORT"))
10095 {
10096 if let Ok(port) = port_str.parse::<u16>() {
10097 self.gateway.port = port;
10098 }
10099 }
10100
10101 if let Ok(host) = std::env::var("CONSTRUCT_GATEWAY_HOST").or_else(|_| std::env::var("HOST"))
10103 {
10104 if !host.is_empty() {
10105 self.gateway.host = host;
10106 }
10107 }
10108
10109 if let Ok(val) = std::env::var("CONSTRUCT_ALLOW_PUBLIC_BIND") {
10111 self.gateway.allow_public_bind = val == "1" || val.eq_ignore_ascii_case("true");
10112 }
10113
10114 if let Ok(val) = std::env::var("CONSTRUCT_REQUIRE_PAIRING") {
10116 self.gateway.require_pairing = val == "1" || val.eq_ignore_ascii_case("true");
10117 }
10118
10119 if let Ok(temp_str) = std::env::var("CONSTRUCT_TEMPERATURE") {
10121 match temp_str.parse::<f64>() {
10122 Ok(temp) if TEMPERATURE_RANGE.contains(&temp) => {
10123 self.default_temperature = temp;
10124 }
10125 Ok(temp) => {
10126 tracing::warn!(
10127 "Ignoring CONSTRUCT_TEMPERATURE={temp}: \
10128 value out of range (expected {}..={})",
10129 TEMPERATURE_RANGE.start(),
10130 TEMPERATURE_RANGE.end()
10131 );
10132 }
10133 Err(_) => {
10134 tracing::warn!(
10135 "Ignoring CONSTRUCT_TEMPERATURE={temp_str:?}: not a valid number"
10136 );
10137 }
10138 }
10139 }
10140
10141 if let Ok(flag) = std::env::var("CONSTRUCT_REASONING_ENABLED")
10143 .or_else(|_| std::env::var("REASONING_ENABLED"))
10144 {
10145 let normalized = flag.trim().to_ascii_lowercase();
10146 match normalized.as_str() {
10147 "1" | "true" | "yes" | "on" => self.runtime.reasoning_enabled = Some(true),
10148 "0" | "false" | "no" | "off" => self.runtime.reasoning_enabled = Some(false),
10149 _ => {}
10150 }
10151 }
10152
10153 if let Ok(raw) = std::env::var("CONSTRUCT_REASONING_EFFORT")
10154 .or_else(|_| std::env::var("REASONING_EFFORT"))
10155 .or_else(|_| std::env::var("CONSTRUCT_CODEX_REASONING_EFFORT"))
10156 {
10157 match normalize_reasoning_effort(&raw) {
10158 Ok(effort) => self.runtime.reasoning_effort = Some(effort),
10159 Err(message) => tracing::warn!("Ignoring reasoning effort env override: {message}"),
10160 }
10161 }
10162
10163 if let Ok(enabled) = std::env::var("CONSTRUCT_WEB_SEARCH_ENABLED")
10165 .or_else(|_| std::env::var("WEB_SEARCH_ENABLED"))
10166 {
10167 self.web_search.enabled = enabled == "1" || enabled.eq_ignore_ascii_case("true");
10168 }
10169
10170 if let Ok(provider) = std::env::var("CONSTRUCT_WEB_SEARCH_PROVIDER")
10172 .or_else(|_| std::env::var("WEB_SEARCH_PROVIDER"))
10173 {
10174 let provider = provider.trim();
10175 if !provider.is_empty() {
10176 self.web_search.provider = provider.to_string();
10177 }
10178 }
10179
10180 if let Ok(api_key) =
10182 std::env::var("CONSTRUCT_BRAVE_API_KEY").or_else(|_| std::env::var("BRAVE_API_KEY"))
10183 {
10184 let api_key = api_key.trim();
10185 if !api_key.is_empty() {
10186 self.web_search.brave_api_key = Some(api_key.to_string());
10187 }
10188 }
10189
10190 if let Ok(instance_url) = std::env::var("CONSTRUCT_SEARXNG_INSTANCE_URL")
10192 .or_else(|_| std::env::var("SEARXNG_INSTANCE_URL"))
10193 {
10194 let instance_url = instance_url.trim();
10195 if !instance_url.is_empty() {
10196 self.web_search.searxng_instance_url = Some(instance_url.to_string());
10197 }
10198 }
10199
10200 if let Ok(max_results) = std::env::var("CONSTRUCT_WEB_SEARCH_MAX_RESULTS")
10202 .or_else(|_| std::env::var("WEB_SEARCH_MAX_RESULTS"))
10203 {
10204 if let Ok(max_results) = max_results.parse::<usize>() {
10205 if (1..=10).contains(&max_results) {
10206 self.web_search.max_results = max_results;
10207 }
10208 }
10209 }
10210
10211 if let Ok(timeout_secs) = std::env::var("CONSTRUCT_WEB_SEARCH_TIMEOUT_SECS")
10213 .or_else(|_| std::env::var("WEB_SEARCH_TIMEOUT_SECS"))
10214 {
10215 if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
10216 if timeout_secs > 0 {
10217 self.web_search.timeout_secs = timeout_secs;
10218 }
10219 }
10220 }
10221
10222 if let Ok(provider) = std::env::var("CONSTRUCT_STORAGE_PROVIDER") {
10224 let provider = provider.trim();
10225 if !provider.is_empty() {
10226 self.storage.provider.config.provider = provider.to_string();
10227 }
10228 }
10229
10230 if let Ok(db_url) = std::env::var("CONSTRUCT_STORAGE_DB_URL") {
10232 let db_url = db_url.trim();
10233 if !db_url.is_empty() {
10234 self.storage.provider.config.db_url = Some(db_url.to_string());
10235 }
10236 }
10237
10238 if let Ok(timeout_secs) = std::env::var("CONSTRUCT_STORAGE_CONNECT_TIMEOUT_SECS") {
10240 if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
10241 if timeout_secs > 0 {
10242 self.storage.provider.config.connect_timeout_secs = Some(timeout_secs);
10243 }
10244 }
10245 }
10246 let explicit_proxy_enabled = std::env::var("CONSTRUCT_PROXY_ENABLED")
10248 .ok()
10249 .as_deref()
10250 .and_then(parse_proxy_enabled);
10251 if let Some(enabled) = explicit_proxy_enabled {
10252 self.proxy.enabled = enabled;
10253 }
10254
10255 let mut proxy_url_overridden = false;
10257 if let Ok(proxy_url) =
10258 std::env::var("CONSTRUCT_HTTP_PROXY").or_else(|_| std::env::var("HTTP_PROXY"))
10259 {
10260 self.proxy.http_proxy = normalize_proxy_url_option(Some(&proxy_url));
10261 proxy_url_overridden = true;
10262 }
10263 if let Ok(proxy_url) =
10264 std::env::var("CONSTRUCT_HTTPS_PROXY").or_else(|_| std::env::var("HTTPS_PROXY"))
10265 {
10266 self.proxy.https_proxy = normalize_proxy_url_option(Some(&proxy_url));
10267 proxy_url_overridden = true;
10268 }
10269 if let Ok(proxy_url) =
10270 std::env::var("CONSTRUCT_ALL_PROXY").or_else(|_| std::env::var("ALL_PROXY"))
10271 {
10272 self.proxy.all_proxy = normalize_proxy_url_option(Some(&proxy_url));
10273 proxy_url_overridden = true;
10274 }
10275 if let Ok(no_proxy) =
10276 std::env::var("CONSTRUCT_NO_PROXY").or_else(|_| std::env::var("NO_PROXY"))
10277 {
10278 self.proxy.no_proxy = normalize_no_proxy_list(vec![no_proxy]);
10279 }
10280
10281 if explicit_proxy_enabled.is_none()
10282 && proxy_url_overridden
10283 && self.proxy.has_any_proxy_url()
10284 {
10285 self.proxy.enabled = true;
10286 }
10287
10288 if let Ok(scope_raw) = std::env::var("CONSTRUCT_PROXY_SCOPE") {
10290 if let Some(scope) = parse_proxy_scope(&scope_raw) {
10291 self.proxy.scope = scope;
10292 } else {
10293 tracing::warn!(
10294 scope = %scope_raw,
10295 "Ignoring invalid CONSTRUCT_PROXY_SCOPE (valid: environment|construct|services)"
10296 );
10297 }
10298 }
10299
10300 if let Ok(services_raw) = std::env::var("CONSTRUCT_PROXY_SERVICES") {
10301 self.proxy.services = normalize_service_list(vec![services_raw]);
10302 }
10303
10304 if let Err(error) = self.proxy.validate() {
10305 tracing::warn!("Invalid proxy configuration ignored: {error}");
10306 self.proxy.enabled = false;
10307 }
10308
10309 if self.proxy.enabled && self.proxy.scope == ProxyScope::Environment {
10310 self.proxy.apply_to_process_env();
10311 }
10312
10313 set_runtime_proxy_config(self.proxy.clone());
10314
10315 if self.conversational_ai.enabled {
10316 tracing::warn!(
10317 "conversational_ai.enabled = true but conversational AI features are not yet \
10318 implemented; this section is reserved for future use and will be ignored"
10319 );
10320 }
10321 }
10322
10323 async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
10324 if self
10325 .config_path
10326 .parent()
10327 .is_some_and(|parent| !parent.as_os_str().is_empty())
10328 {
10329 return Ok(self.config_path.clone());
10330 }
10331
10332 let (default_construct_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
10333 let (construct_dir, _workspace_dir, source) =
10334 resolve_runtime_config_dirs(&default_construct_dir, &default_workspace_dir).await?;
10335 let file_name = self
10336 .config_path
10337 .file_name()
10338 .filter(|name| !name.is_empty())
10339 .unwrap_or_else(|| std::ffi::OsStr::new("config.toml"));
10340 let resolved = construct_dir.join(file_name);
10341 tracing::warn!(
10342 path = %self.config_path.display(),
10343 resolved = %resolved.display(),
10344 source = source.as_str(),
10345 "Config path missing parent directory; resolving from runtime environment"
10346 );
10347 Ok(resolved)
10348 }
10349
10350 pub async fn save(&self) -> Result<()> {
10351 let mut config_to_save = self.clone();
10353 let config_path = self.resolve_config_path_for_save().await?;
10354 let construct_dir = config_path
10355 .parent()
10356 .context("Config path must have a parent directory")?;
10357 let store = crate::security::SecretStore::new(construct_dir, self.secrets.encrypt);
10358
10359 encrypt_optional_secret(&store, &mut config_to_save.api_key, "config.api_key")?;
10360 encrypt_optional_secret(
10361 &store,
10362 &mut config_to_save.composio.api_key,
10363 "config.composio.api_key",
10364 )?;
10365 if let Some(ref mut pinggy) = config_to_save.tunnel.pinggy {
10366 encrypt_optional_secret(&store, &mut pinggy.token, "config.tunnel.pinggy.token")?;
10367 }
10368 encrypt_optional_secret(
10369 &store,
10370 &mut config_to_save.microsoft365.client_secret,
10371 "config.microsoft365.client_secret",
10372 )?;
10373
10374 encrypt_optional_secret(
10375 &store,
10376 &mut config_to_save.browser.computer_use.api_key,
10377 "config.browser.computer_use.api_key",
10378 )?;
10379
10380 encrypt_optional_secret(
10381 &store,
10382 &mut config_to_save.web_search.brave_api_key,
10383 "config.web_search.brave_api_key",
10384 )?;
10385
10386 encrypt_optional_secret(
10387 &store,
10388 &mut config_to_save.storage.provider.config.db_url,
10389 "config.storage.provider.config.db_url",
10390 )?;
10391
10392 for agent in config_to_save.agents.values_mut() {
10393 encrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
10394 }
10395
10396 if let Some(ref mut openai) = config_to_save.tts.openai {
10398 encrypt_optional_secret(&store, &mut openai.api_key, "config.tts.openai.api_key")?;
10399 }
10400 if let Some(ref mut elevenlabs) = config_to_save.tts.elevenlabs {
10401 encrypt_optional_secret(
10402 &store,
10403 &mut elevenlabs.api_key,
10404 "config.tts.elevenlabs.api_key",
10405 )?;
10406 }
10407 if let Some(ref mut google) = config_to_save.tts.google {
10408 encrypt_optional_secret(&store, &mut google.api_key, "config.tts.google.api_key")?;
10409 }
10410
10411 encrypt_optional_secret(
10413 &store,
10414 &mut config_to_save.transcription.api_key,
10415 "config.transcription.api_key",
10416 )?;
10417 if let Some(ref mut openai) = config_to_save.transcription.openai {
10418 encrypt_optional_secret(
10419 &store,
10420 &mut openai.api_key,
10421 "config.transcription.openai.api_key",
10422 )?;
10423 }
10424 if let Some(ref mut deepgram) = config_to_save.transcription.deepgram {
10425 encrypt_optional_secret(
10426 &store,
10427 &mut deepgram.api_key,
10428 "config.transcription.deepgram.api_key",
10429 )?;
10430 }
10431 if let Some(ref mut assemblyai) = config_to_save.transcription.assemblyai {
10432 encrypt_optional_secret(
10433 &store,
10434 &mut assemblyai.api_key,
10435 "config.transcription.assemblyai.api_key",
10436 )?;
10437 }
10438 if let Some(ref mut google) = config_to_save.transcription.google {
10439 encrypt_optional_secret(
10440 &store,
10441 &mut google.api_key,
10442 "config.transcription.google.api_key",
10443 )?;
10444 }
10445 if let Some(ref mut local) = config_to_save.transcription.local_whisper {
10446 encrypt_optional_secret(
10447 &store,
10448 &mut local.bearer_token,
10449 "config.transcription.local_whisper.bearer_token",
10450 )?;
10451 }
10452
10453 #[cfg(feature = "channel-nostr")]
10454 if let Some(ref mut ns) = config_to_save.channels_config.nostr {
10455 encrypt_secret(
10456 &store,
10457 &mut ns.private_key,
10458 "config.channels_config.nostr.private_key",
10459 )?;
10460 }
10461 if let Some(ref mut fs) = config_to_save.channels_config.feishu {
10462 encrypt_secret(
10463 &store,
10464 &mut fs.app_secret,
10465 "config.channels_config.feishu.app_secret",
10466 )?;
10467 encrypt_optional_secret(
10468 &store,
10469 &mut fs.encrypt_key,
10470 "config.channels_config.feishu.encrypt_key",
10471 )?;
10472 encrypt_optional_secret(
10473 &store,
10474 &mut fs.verification_token,
10475 "config.channels_config.feishu.verification_token",
10476 )?;
10477 }
10478
10479 if let Some(ref mut tg) = config_to_save.channels_config.telegram {
10481 encrypt_secret(
10482 &store,
10483 &mut tg.bot_token,
10484 "config.channels_config.telegram.bot_token",
10485 )?;
10486 }
10487 if let Some(ref mut dc) = config_to_save.channels_config.discord {
10488 encrypt_secret(
10489 &store,
10490 &mut dc.bot_token,
10491 "config.channels_config.discord.bot_token",
10492 )?;
10493 }
10494 if let Some(ref mut sl) = config_to_save.channels_config.slack {
10495 encrypt_secret(
10496 &store,
10497 &mut sl.bot_token,
10498 "config.channels_config.slack.bot_token",
10499 )?;
10500 encrypt_optional_secret(
10501 &store,
10502 &mut sl.app_token,
10503 "config.channels_config.slack.app_token",
10504 )?;
10505 }
10506 if let Some(ref mut mm) = config_to_save.channels_config.mattermost {
10507 encrypt_secret(
10508 &store,
10509 &mut mm.bot_token,
10510 "config.channels_config.mattermost.bot_token",
10511 )?;
10512 }
10513 if let Some(ref mut mx) = config_to_save.channels_config.matrix {
10514 encrypt_secret(
10515 &store,
10516 &mut mx.access_token,
10517 "config.channels_config.matrix.access_token",
10518 )?;
10519 encrypt_optional_secret(
10520 &store,
10521 &mut mx.recovery_key,
10522 "config.channels_config.matrix.recovery_key",
10523 )?;
10524 }
10525 if let Some(ref mut wa) = config_to_save.channels_config.whatsapp {
10526 encrypt_optional_secret(
10527 &store,
10528 &mut wa.access_token,
10529 "config.channels_config.whatsapp.access_token",
10530 )?;
10531 encrypt_optional_secret(
10532 &store,
10533 &mut wa.app_secret,
10534 "config.channels_config.whatsapp.app_secret",
10535 )?;
10536 encrypt_optional_secret(
10537 &store,
10538 &mut wa.verify_token,
10539 "config.channels_config.whatsapp.verify_token",
10540 )?;
10541 }
10542 if let Some(ref mut lq) = config_to_save.channels_config.linq {
10543 encrypt_secret(
10544 &store,
10545 &mut lq.api_token,
10546 "config.channels_config.linq.api_token",
10547 )?;
10548 encrypt_optional_secret(
10549 &store,
10550 &mut lq.signing_secret,
10551 "config.channels_config.linq.signing_secret",
10552 )?;
10553 }
10554 if let Some(ref mut wt) = config_to_save.channels_config.wati {
10555 encrypt_secret(
10556 &store,
10557 &mut wt.api_token,
10558 "config.channels_config.wati.api_token",
10559 )?;
10560 }
10561 if let Some(ref mut nc) = config_to_save.channels_config.nextcloud_talk {
10562 encrypt_secret(
10563 &store,
10564 &mut nc.app_token,
10565 "config.channels_config.nextcloud_talk.app_token",
10566 )?;
10567 encrypt_optional_secret(
10568 &store,
10569 &mut nc.webhook_secret,
10570 "config.channels_config.nextcloud_talk.webhook_secret",
10571 )?;
10572 }
10573 if let Some(ref mut em) = config_to_save.channels_config.email {
10574 encrypt_secret(
10575 &store,
10576 &mut em.password,
10577 "config.channels_config.email.password",
10578 )?;
10579 }
10580 if let Some(ref mut gp) = config_to_save.channels_config.gmail_push {
10581 encrypt_secret(
10582 &store,
10583 &mut gp.oauth_token,
10584 "config.channels_config.gmail_push.oauth_token",
10585 )?;
10586 }
10587 if let Some(ref mut irc) = config_to_save.channels_config.irc {
10588 encrypt_optional_secret(
10589 &store,
10590 &mut irc.server_password,
10591 "config.channels_config.irc.server_password",
10592 )?;
10593 encrypt_optional_secret(
10594 &store,
10595 &mut irc.nickserv_password,
10596 "config.channels_config.irc.nickserv_password",
10597 )?;
10598 encrypt_optional_secret(
10599 &store,
10600 &mut irc.sasl_password,
10601 "config.channels_config.irc.sasl_password",
10602 )?;
10603 }
10604 if let Some(ref mut lk) = config_to_save.channels_config.lark {
10605 encrypt_secret(
10606 &store,
10607 &mut lk.app_secret,
10608 "config.channels_config.lark.app_secret",
10609 )?;
10610 encrypt_optional_secret(
10611 &store,
10612 &mut lk.encrypt_key,
10613 "config.channels_config.lark.encrypt_key",
10614 )?;
10615 encrypt_optional_secret(
10616 &store,
10617 &mut lk.verification_token,
10618 "config.channels_config.lark.verification_token",
10619 )?;
10620 }
10621 if let Some(ref mut fs) = config_to_save.channels_config.feishu {
10622 encrypt_secret(
10623 &store,
10624 &mut fs.app_secret,
10625 "config.channels_config.feishu.app_secret",
10626 )?;
10627 encrypt_optional_secret(
10628 &store,
10629 &mut fs.encrypt_key,
10630 "config.channels_config.feishu.encrypt_key",
10631 )?;
10632 encrypt_optional_secret(
10633 &store,
10634 &mut fs.verification_token,
10635 "config.channels_config.feishu.verification_token",
10636 )?;
10637 }
10638 if let Some(ref mut dt) = config_to_save.channels_config.dingtalk {
10639 encrypt_secret(
10640 &store,
10641 &mut dt.client_secret,
10642 "config.channels_config.dingtalk.client_secret",
10643 )?;
10644 }
10645 if let Some(ref mut wc) = config_to_save.channels_config.wecom {
10646 encrypt_secret(
10647 &store,
10648 &mut wc.webhook_key,
10649 "config.channels_config.wecom.webhook_key",
10650 )?;
10651 }
10652 if let Some(ref mut qq) = config_to_save.channels_config.qq {
10653 encrypt_secret(
10654 &store,
10655 &mut qq.app_secret,
10656 "config.channels_config.qq.app_secret",
10657 )?;
10658 }
10659 if let Some(ref mut wh) = config_to_save.channels_config.webhook {
10660 encrypt_optional_secret(
10661 &store,
10662 &mut wh.secret,
10663 "config.channels_config.webhook.secret",
10664 )?;
10665 }
10666 if let Some(ref mut ct) = config_to_save.channels_config.clawdtalk {
10667 encrypt_secret(
10668 &store,
10669 &mut ct.api_key,
10670 "config.channels_config.clawdtalk.api_key",
10671 )?;
10672 encrypt_optional_secret(
10673 &store,
10674 &mut ct.webhook_secret,
10675 "config.channels_config.clawdtalk.webhook_secret",
10676 )?;
10677 }
10678
10679 for token in &mut config_to_save.gateway.paired_tokens {
10681 encrypt_secret(&store, token, "config.gateway.paired_tokens[]")?;
10682 }
10683
10684 encrypt_optional_secret(
10686 &store,
10687 &mut config_to_save.security.nevis.client_secret,
10688 "config.security.nevis.client_secret",
10689 )?;
10690
10691 if !config_to_save.notion.api_key.is_empty() {
10693 encrypt_secret(
10694 &store,
10695 &mut config_to_save.notion.api_key,
10696 "config.notion.api_key",
10697 )?;
10698 }
10699
10700 if !config_to_save.jira.api_token.is_empty() {
10702 encrypt_secret(
10703 &store,
10704 &mut config_to_save.jira.api_token,
10705 "config.jira.api_token",
10706 )?;
10707 }
10708
10709 let toml_str =
10710 toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
10711
10712 let parent_dir = config_path
10713 .parent()
10714 .context("Config path must have a parent directory")?;
10715
10716 fs::create_dir_all(parent_dir).await.with_context(|| {
10717 format!(
10718 "Failed to create config directory: {}",
10719 parent_dir.display()
10720 )
10721 })?;
10722
10723 let file_name = config_path
10724 .file_name()
10725 .and_then(|v| v.to_str())
10726 .unwrap_or("config.toml");
10727 let temp_path = parent_dir.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
10728 let backup_path = parent_dir.join(format!("{file_name}.bak"));
10729
10730 let mut temp_file = OpenOptions::new()
10731 .create_new(true)
10732 .write(true)
10733 .open(&temp_path)
10734 .await
10735 .with_context(|| {
10736 format!(
10737 "Failed to create temporary config file: {}",
10738 temp_path.display()
10739 )
10740 })?;
10741 temp_file
10742 .write_all(toml_str.as_bytes())
10743 .await
10744 .context("Failed to write temporary config contents")?;
10745 temp_file
10746 .sync_all()
10747 .await
10748 .context("Failed to fsync temporary config file")?;
10749 drop(temp_file);
10750
10751 let had_existing_config = config_path.exists();
10752 if had_existing_config {
10753 fs::copy(&config_path, &backup_path)
10754 .await
10755 .with_context(|| {
10756 format!(
10757 "Failed to create config backup before atomic replace: {}",
10758 backup_path.display()
10759 )
10760 })?;
10761 }
10762
10763 if let Err(e) = fs::rename(&temp_path, &config_path).await {
10764 let _ = fs::remove_file(&temp_path).await;
10765 if had_existing_config && backup_path.exists() {
10766 fs::copy(&backup_path, &config_path)
10767 .await
10768 .context("Failed to restore config backup")?;
10769 }
10770 anyhow::bail!("Failed to atomically replace config file: {e}");
10771 }
10772
10773 #[cfg(unix)]
10774 {
10775 use std::{fs::Permissions, os::unix::fs::PermissionsExt};
10776 if let Err(err) = fs::set_permissions(&config_path, Permissions::from_mode(0o600)).await
10777 {
10778 tracing::warn!(
10779 "Failed to harden config permissions to 0600 at {}: {}",
10780 config_path.display(),
10781 err
10782 );
10783 }
10784 }
10785
10786 sync_directory(parent_dir).await?;
10787
10788 if had_existing_config {
10789 let _ = fs::remove_file(&backup_path).await;
10790 }
10791
10792 Ok(())
10793 }
10794}
10795
10796#[allow(clippy::unused_async)] async fn sync_directory(path: &Path) -> Result<()> {
10798 #[cfg(unix)]
10799 {
10800 let dir = File::open(path)
10801 .await
10802 .with_context(|| format!("Failed to open directory for fsync: {}", path.display()))?;
10803 dir.sync_all()
10804 .await
10805 .with_context(|| format!("Failed to fsync directory metadata: {}", path.display()))?;
10806 Ok(())
10807 }
10808
10809 #[cfg(windows)]
10810 {
10811 use std::os::windows::fs::OpenOptionsExt;
10812 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x02000000;
10813 let dir = std::fs::OpenOptions::new()
10814 .read(true)
10815 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
10816 .open(path)
10817 .with_context(|| format!("Failed to open directory for fsync: {}", path.display()))?;
10818 dir.sync_all()
10819 .with_context(|| format!("Failed to fsync directory metadata: {}", path.display()))?;
10820 Ok(())
10821 }
10822
10823 #[cfg(not(any(unix, windows)))]
10824 {
10825 let _ = path;
10826 Ok(())
10827 }
10828}
10829
10830#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
10838pub struct SopConfig {
10839 #[serde(default)]
10842 pub sops_dir: Option<String>,
10843
10844 #[serde(default = "default_sop_execution_mode")]
10848 pub default_execution_mode: String,
10849
10850 #[serde(default = "default_sop_max_concurrent_total")]
10852 pub max_concurrent_total: usize,
10853
10854 #[serde(default = "default_sop_approval_timeout_secs")]
10858 pub approval_timeout_secs: u64,
10859
10860 #[serde(default = "default_sop_max_finished_runs")]
10863 pub max_finished_runs: usize,
10864}
10865
10866fn default_sop_execution_mode() -> String {
10867 "supervised".to_string()
10868}
10869
10870fn default_sop_max_concurrent_total() -> usize {
10871 4
10872}
10873
10874fn default_sop_approval_timeout_secs() -> u64 {
10875 300
10876}
10877
10878fn default_sop_max_finished_runs() -> usize {
10879 100
10880}
10881
10882impl Default for SopConfig {
10883 fn default() -> Self {
10884 Self {
10885 sops_dir: None,
10886 default_execution_mode: default_sop_execution_mode(),
10887 max_concurrent_total: default_sop_max_concurrent_total(),
10888 approval_timeout_secs: default_sop_approval_timeout_secs(),
10889 max_finished_runs: default_sop_max_finished_runs(),
10890 }
10891 }
10892}
10893
10894#[cfg(test)]
10895mod tests {
10896 use super::*;
10897 use std::io;
10898 #[cfg(unix)]
10899 use std::os::unix::fs::PermissionsExt;
10900 use std::path::PathBuf;
10901 use std::sync::{Arc, Mutex as StdMutex};
10902 use tempfile::TempDir;
10903 use tokio::sync::{Mutex, MutexGuard};
10904 use tokio::test;
10905 use tokio_stream::StreamExt;
10906 use tokio_stream::wrappers::ReadDirStream;
10907
10908 #[test]
10911 async fn expand_tilde_path_handles_absolute_path() {
10912 let path = expand_tilde_path("/absolute/path");
10913 assert_eq!(path, PathBuf::from("/absolute/path"));
10914 }
10915
10916 #[test]
10917 async fn expand_tilde_path_handles_relative_path() {
10918 let path = expand_tilde_path("relative/path");
10919 assert_eq!(path, PathBuf::from("relative/path"));
10920 }
10921
10922 #[test]
10923 async fn expand_tilde_path_expands_tilde_when_home_set() {
10924 let path = expand_tilde_path("~/.construct");
10927 if std::env::var("HOME").is_ok() {
10930 assert!(
10931 !path.to_string_lossy().starts_with('~'),
10932 "Tilde should be expanded when HOME is set"
10933 );
10934 }
10935 }
10936
10937 fn has_test_table(raw: &str, table: &str) -> bool {
10940 let exact = format!("[{table}]");
10941 let nested = format!("[{table}.");
10942 raw.lines()
10943 .map(str::trim)
10944 .any(|line| line == exact || line.starts_with(&nested))
10945 }
10946
10947 fn parse_test_config(raw: &str) -> Config {
10948 let mut merged = raw.trim().to_string();
10949 for table in [
10950 "data_retention",
10951 "cloud_ops",
10952 "conversational_ai",
10953 "security",
10954 "security_ops",
10955 ] {
10956 if has_test_table(&merged, table) {
10957 continue;
10958 }
10959 if !merged.is_empty() {
10960 merged.push_str("\n\n");
10961 }
10962 merged.push('[');
10963 merged.push_str(table);
10964 merged.push(']');
10965 }
10966 merged.push('\n');
10967 let mut config: Config = toml::from_str(&merged).unwrap();
10968 config.autonomy.ensure_default_auto_approve();
10969 config
10970 }
10971
10972 #[test]
10973 async fn http_request_config_default_has_correct_values() {
10974 let cfg = HttpRequestConfig::default();
10975 assert_eq!(cfg.timeout_secs, 30);
10976 assert_eq!(cfg.max_response_size, 1_000_000);
10977 assert!(cfg.enabled);
10978 assert_eq!(cfg.allowed_domains, vec!["*".to_string()]);
10979 }
10980
10981 #[test]
10982 async fn config_default_has_sane_values() {
10983 let c = Config::default();
10984 assert_eq!(c.default_provider.as_deref(), Some("openrouter"));
10985 assert!(c.default_model.as_deref().unwrap().contains("claude"));
10986 assert!((c.default_temperature - 0.7).abs() < f64::EPSILON);
10987 assert!(c.api_key.is_none());
10988 assert!(!c.skills.open_skills_enabled);
10989 assert!(!c.skills.allow_scripts);
10990 assert_eq!(
10991 c.skills.prompt_injection_mode,
10992 SkillsPromptInjectionMode::Full
10993 );
10994 assert_eq!(c.provider_timeout_secs, 120);
10995 assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
10996 assert!(c.config_path.to_string_lossy().contains("config.toml"));
10997 }
10998
10999 #[derive(Clone, Default)]
11000 struct SharedLogBuffer(Arc<StdMutex<Vec<u8>>>);
11001
11002 struct SharedLogWriter(Arc<StdMutex<Vec<u8>>>);
11003
11004 impl SharedLogBuffer {
11005 fn captured(&self) -> String {
11006 String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
11007 }
11008 }
11009
11010 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedLogBuffer {
11011 type Writer = SharedLogWriter;
11012
11013 fn make_writer(&'a self) -> Self::Writer {
11014 SharedLogWriter(self.0.clone())
11015 }
11016 }
11017
11018 impl io::Write for SharedLogWriter {
11019 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
11020 self.0.lock().unwrap().extend_from_slice(buf);
11021 Ok(buf.len())
11022 }
11023
11024 fn flush(&mut self) -> io::Result<()> {
11025 Ok(())
11026 }
11027 }
11028
11029 #[test]
11030 async fn config_dir_creation_error_mentions_openrc_and_path() {
11031 let msg = config_dir_creation_error(Path::new("/etc/construct"));
11032 assert!(msg.contains("/etc/construct"));
11033 assert!(msg.contains("OpenRC"));
11034 assert!(msg.contains("construct"));
11035 }
11036
11037 #[test]
11038 async fn config_schema_export_contains_expected_contract_shape() {
11039 let schema = schemars::schema_for!(Config);
11040 let schema_json = serde_json::to_value(&schema).expect("schema should serialize to json");
11041
11042 assert_eq!(
11043 schema_json
11044 .get("$schema")
11045 .and_then(serde_json::Value::as_str),
11046 Some("https://json-schema.org/draft/2020-12/schema")
11047 );
11048
11049 let properties = schema_json
11050 .get("properties")
11051 .and_then(serde_json::Value::as_object)
11052 .expect("schema should expose top-level properties");
11053
11054 assert!(properties.contains_key("default_provider"));
11055 assert!(properties.contains_key("skills"));
11056 assert!(properties.contains_key("gateway"));
11057 assert!(properties.contains_key("channels_config"));
11058 assert!(!properties.contains_key("workspace_dir"));
11059 assert!(!properties.contains_key("config_path"));
11060
11061 assert!(
11062 schema_json
11063 .get("$defs")
11064 .and_then(serde_json::Value::as_object)
11065 .is_some(),
11066 "schema should include reusable type definitions"
11067 );
11068 }
11069
11070 #[cfg(unix)]
11071 #[test]
11072 async fn save_sets_config_permissions_on_new_file() {
11073 let temp = TempDir::new().expect("temp dir");
11074 let config_path = temp.path().join("config.toml");
11075 let workspace_dir = temp.path().join("workspace");
11076
11077 let mut config = Config::default();
11078 config.config_path = config_path.clone();
11079 config.workspace_dir = workspace_dir;
11080
11081 config.save().await.expect("save config");
11082
11083 let mode = std::fs::metadata(&config_path)
11084 .expect("config metadata")
11085 .permissions()
11086 .mode()
11087 & 0o777;
11088 assert_eq!(mode, 0o600);
11089 }
11090
11091 #[test]
11092 async fn observability_config_default() {
11093 let o = ObservabilityConfig::default();
11094 assert_eq!(o.backend, "none");
11095 assert_eq!(o.runtime_trace_mode, "none");
11096 assert_eq!(o.runtime_trace_path, "state/runtime-trace.jsonl");
11097 assert_eq!(o.runtime_trace_max_entries, 200);
11098 }
11099
11100 #[test]
11101 async fn autonomy_config_default() {
11102 let a = AutonomyConfig::default();
11103 assert_eq!(a.level, AutonomyLevel::Supervised);
11104 assert!(a.workspace_only);
11105 assert!(a.allowed_commands.contains(&"git".to_string()));
11106 assert!(a.allowed_commands.contains(&"cargo".to_string()));
11107 assert!(a.forbidden_paths.contains(&"/etc".to_string()));
11108 assert_eq!(a.max_actions_per_hour, 20);
11109 assert_eq!(a.max_cost_per_day_cents, 500);
11110 assert!(a.require_approval_for_medium_risk);
11111 assert!(a.block_high_risk_commands);
11112 assert!(a.shell_env_passthrough.is_empty());
11113 }
11114
11115 #[test]
11116 async fn runtime_config_default() {
11117 let r = RuntimeConfig::default();
11118 assert_eq!(r.kind, "native");
11119 assert_eq!(r.docker.image, "alpine:3.20");
11120 assert_eq!(r.docker.network, "none");
11121 assert_eq!(r.docker.memory_limit_mb, Some(512));
11122 assert_eq!(r.docker.cpu_limit, Some(1.0));
11123 assert!(r.docker.read_only_rootfs);
11124 assert!(r.docker.mount_workspace);
11125 }
11126
11127 #[test]
11128 async fn heartbeat_config_default() {
11129 let h = HeartbeatConfig::default();
11130 assert!(!h.enabled);
11131 assert_eq!(h.interval_minutes, 30);
11132 assert!(h.message.is_none());
11133 assert!(h.target.is_none());
11134 assert!(h.to.is_none());
11135 }
11136
11137 #[test]
11138 async fn heartbeat_config_parses_delivery_aliases() {
11139 let raw = r#"
11140enabled = true
11141interval_minutes = 10
11142message = "Ping"
11143channel = "telegram"
11144recipient = "42"
11145"#;
11146 let parsed: HeartbeatConfig = toml::from_str(raw).unwrap();
11147 assert!(parsed.enabled);
11148 assert_eq!(parsed.interval_minutes, 10);
11149 assert_eq!(parsed.message.as_deref(), Some("Ping"));
11150 assert_eq!(parsed.target.as_deref(), Some("telegram"));
11151 assert_eq!(parsed.to.as_deref(), Some("42"));
11152 }
11153
11154 #[test]
11155 async fn cron_config_default() {
11156 let c = CronConfig::default();
11157 assert!(c.enabled);
11158 assert_eq!(c.max_run_history, 50);
11159 }
11160
11161 #[test]
11162 async fn cron_config_serde_roundtrip() {
11163 let c = CronConfig {
11164 enabled: false,
11165 catch_up_on_startup: false,
11166 max_run_history: 100,
11167 jobs: Vec::new(),
11168 };
11169 let json = serde_json::to_string(&c).unwrap();
11170 let parsed: CronConfig = serde_json::from_str(&json).unwrap();
11171 assert!(!parsed.enabled);
11172 assert!(!parsed.catch_up_on_startup);
11173 assert_eq!(parsed.max_run_history, 100);
11174 }
11175
11176 #[test]
11177 async fn config_defaults_cron_when_section_missing() {
11178 let toml_str = r#"
11179workspace_dir = "/tmp/workspace"
11180config_path = "/tmp/config.toml"
11181default_temperature = 0.7
11182"#;
11183
11184 let parsed = parse_test_config(toml_str);
11185 assert!(parsed.cron.enabled);
11186 assert!(parsed.cron.catch_up_on_startup);
11187 assert_eq!(parsed.cron.max_run_history, 50);
11188 }
11189
11190 #[test]
11191 async fn memory_config_default_hygiene_settings() {
11192 let m = MemoryConfig::default();
11193 assert_eq!(m.backend, "none");
11194 assert!(m.auto_save);
11195 assert!(m.hygiene_enabled);
11196 assert_eq!(m.archive_after_days, 7);
11197 assert_eq!(m.purge_after_days, 30);
11198 assert_eq!(m.conversation_retention_days, 30);
11199 }
11200
11201 #[test]
11202 async fn storage_provider_config_defaults() {
11203 let storage = StorageConfig::default();
11204 assert!(storage.provider.config.provider.is_empty());
11205 assert!(storage.provider.config.db_url.is_none());
11206 assert_eq!(storage.provider.config.schema, "public");
11207 assert_eq!(storage.provider.config.table, "memories");
11208 assert!(storage.provider.config.connect_timeout_secs.is_none());
11209 }
11210
11211 #[test]
11212 async fn channels_config_default() {
11213 let c = ChannelsConfig::default();
11214 assert!(c.cli);
11215 assert!(c.telegram.is_none());
11216 assert!(c.discord.is_none());
11217 assert!(!c.show_tool_calls);
11218 }
11219
11220 #[test]
11223 async fn config_toml_roundtrip() {
11224 let config = Config {
11225 workspace_dir: PathBuf::from("/tmp/test/workspace"),
11226 config_path: PathBuf::from("/tmp/test/config.toml"),
11227 api_key: Some("sk-test-key".into()),
11228 api_url: None,
11229 api_path: None,
11230 default_provider: Some("openrouter".into()),
11231 default_model: Some("gpt-4o".into()),
11232 model_providers: HashMap::new(),
11233 default_temperature: 0.5,
11234 provider_timeout_secs: 120,
11235 provider_max_tokens: None,
11236 extra_headers: HashMap::new(),
11237 observability: ObservabilityConfig {
11238 backend: "log".into(),
11239 ..ObservabilityConfig::default()
11240 },
11241 autonomy: AutonomyConfig {
11242 level: AutonomyLevel::Full,
11243 workspace_only: false,
11244 allowed_commands: vec!["docker".into()],
11245 forbidden_paths: vec!["/secret".into()],
11246 max_actions_per_hour: 50,
11247 max_cost_per_day_cents: 1000,
11248 require_approval_for_medium_risk: false,
11249 block_high_risk_commands: true,
11250 shell_env_passthrough: vec!["DATABASE_URL".into()],
11251 auto_approve: vec!["file_read".into()],
11252 always_ask: vec![],
11253 allowed_roots: vec![],
11254 non_cli_excluded_tools: vec![],
11255 },
11256 trust: crate::trust::TrustConfig::default(),
11257 backup: BackupConfig::default(),
11258 data_retention: DataRetentionConfig::default(),
11259 cloud_ops: CloudOpsConfig::default(),
11260 conversational_ai: ConversationalAiConfig::default(),
11261 security: SecurityConfig::default(),
11262 security_ops: SecurityOpsConfig::default(),
11263 runtime: RuntimeConfig {
11264 kind: "docker".into(),
11265 ..RuntimeConfig::default()
11266 },
11267 reliability: ReliabilityConfig::default(),
11268 scheduler: SchedulerConfig::default(),
11269 skills: SkillsConfig::default(),
11270 pipeline: PipelineConfig::default(),
11271 model_routes: Vec::new(),
11272 embedding_routes: Vec::new(),
11273 query_classification: QueryClassificationConfig::default(),
11274 heartbeat: HeartbeatConfig {
11275 enabled: true,
11276 interval_minutes: 15,
11277 two_phase: true,
11278 message: Some("Check London time".into()),
11279 target: Some("telegram".into()),
11280 to: Some("123456".into()),
11281 ..HeartbeatConfig::default()
11282 },
11283 cron: CronConfig::default(),
11284 channels_config: ChannelsConfig {
11285 cli: true,
11286 telegram: Some(TelegramConfig {
11287 bot_token: "123:ABC".into(),
11288 allowed_users: vec!["user1".into()],
11289 stream_mode: StreamMode::default(),
11290 draft_update_interval_ms: default_draft_update_interval_ms(),
11291 interrupt_on_new_message: false,
11292 mention_only: false,
11293 ack_reactions: None,
11294 proxy_url: None,
11295 notification_chat_id: None,
11296 }),
11297 discord: None,
11298 discord_history: None,
11299 slack: None,
11300 mattermost: None,
11301 webhook: None,
11302 imessage: None,
11303 matrix: None,
11304 signal: None,
11305 whatsapp: None,
11306 linq: None,
11307 wati: None,
11308 nextcloud_talk: None,
11309 email: None,
11310 gmail_push: None,
11311 irc: None,
11312 lark: None,
11313 feishu: None,
11314 dingtalk: None,
11315 wecom: None,
11316 qq: None,
11317 twitter: None,
11318 mochat: None,
11319 #[cfg(feature = "channel-nostr")]
11320 nostr: None,
11321 clawdtalk: None,
11322 reddit: None,
11323 bluesky: None,
11324 voice_call: None,
11325 #[cfg(feature = "voice-wake")]
11326 voice_wake: None,
11327 message_timeout_secs: 300,
11328 ack_reactions: true,
11329 show_tool_calls: true,
11330 session_persistence: true,
11331 session_backend: default_session_backend(),
11332 session_ttl_hours: 0,
11333 debounce_ms: 0,
11334 },
11335 memory: MemoryConfig::default(),
11336 storage: StorageConfig::default(),
11337 tunnel: TunnelConfig::default(),
11338 gateway: GatewayConfig::default(),
11339 composio: ComposioConfig::default(),
11340 microsoft365: Microsoft365Config::default(),
11341 secrets: SecretsConfig::default(),
11342 browser: BrowserConfig::default(),
11343 browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig::default(),
11344 http_request: HttpRequestConfig::default(),
11345 multimodal: MultimodalConfig::default(),
11346 media_pipeline: MediaPipelineConfig::default(),
11347 web_fetch: WebFetchConfig::default(),
11348 link_enricher: LinkEnricherConfig::default(),
11349 text_browser: TextBrowserConfig::default(),
11350 web_search: WebSearchConfig::default(),
11351 project_intel: ProjectIntelConfig::default(),
11352 google_workspace: GoogleWorkspaceConfig::default(),
11353 proxy: ProxyConfig::default(),
11354 agent: AgentConfig::default(),
11355 pacing: PacingConfig::default(),
11356 identity: IdentityConfig::default(),
11357 cost: CostConfig::default(),
11358 peripherals: PeripheralsConfig::default(),
11359 delegate: DelegateToolConfig::default(),
11360 agents: HashMap::new(),
11361 swarms: HashMap::new(),
11362 hooks: HooksConfig::default(),
11363 hardware: HardwareConfig::default(),
11364 transcription: TranscriptionConfig::default(),
11365 tts: TtsConfig::default(),
11366 mcp: McpConfig::default(),
11367 kumiho: KumihoConfig::default(),
11368 operator: OperatorConfig::default(),
11369 nodes: NodesConfig::default(),
11370 clawhub: ClawHubConfig::default(),
11371 workspace: WorkspaceConfig::default(),
11372 notion: NotionConfig::default(),
11373 jira: JiraConfig::default(),
11374 node_transport: NodeTransportConfig::default(),
11375 linkedin: LinkedInConfig::default(),
11376 image_gen: ImageGenConfig::default(),
11377 plugins: PluginsConfig::default(),
11378 locale: None,
11379 verifiable_intent: VerifiableIntentConfig::default(),
11380 claude_code: ClaudeCodeConfig::default(),
11381 claude_code_runner: ClaudeCodeRunnerConfig::default(),
11382 codex_cli: CodexCliConfig::default(),
11383 gemini_cli: GeminiCliConfig::default(),
11384 opencode_cli: OpenCodeCliConfig::default(),
11385 sop: SopConfig::default(),
11386 shell_tool: ShellToolConfig::default(),
11387 };
11388
11389 let toml_str = toml::to_string_pretty(&config).unwrap();
11390 let parsed = parse_test_config(&toml_str);
11391
11392 assert_eq!(parsed.api_key, config.api_key);
11393 assert_eq!(parsed.default_provider, config.default_provider);
11394 assert_eq!(parsed.default_model, config.default_model);
11395 assert!((parsed.default_temperature - config.default_temperature).abs() < f64::EPSILON);
11396 assert_eq!(parsed.observability.backend, "log");
11397 assert_eq!(parsed.observability.runtime_trace_mode, "none");
11398 assert_eq!(parsed.autonomy.level, AutonomyLevel::Full);
11399 assert!(!parsed.autonomy.workspace_only);
11400 assert_eq!(parsed.runtime.kind, "docker");
11401 assert!(parsed.heartbeat.enabled);
11402 assert_eq!(parsed.heartbeat.interval_minutes, 15);
11403 assert_eq!(
11404 parsed.heartbeat.message.as_deref(),
11405 Some("Check London time")
11406 );
11407 assert_eq!(parsed.heartbeat.target.as_deref(), Some("telegram"));
11408 assert_eq!(parsed.heartbeat.to.as_deref(), Some("123456"));
11409 assert!(parsed.channels_config.telegram.is_some());
11410 assert_eq!(
11411 parsed.channels_config.telegram.unwrap().bot_token,
11412 "123:ABC"
11413 );
11414 }
11415
11416 #[test]
11417 async fn config_minimal_toml_uses_defaults() {
11418 let minimal = r#"
11419workspace_dir = "/tmp/ws"
11420config_path = "/tmp/config.toml"
11421default_temperature = 0.7
11422"#;
11423 let parsed = parse_test_config(minimal);
11424 assert!(parsed.api_key.is_none());
11425 assert!(parsed.default_provider.is_none());
11426 assert_eq!(parsed.observability.backend, "none");
11427 assert_eq!(parsed.observability.runtime_trace_mode, "none");
11428 assert_eq!(parsed.autonomy.level, AutonomyLevel::Supervised);
11429 assert_eq!(parsed.runtime.kind, "native");
11430 assert!(!parsed.heartbeat.enabled);
11431 assert!(parsed.channels_config.cli);
11432 assert!(parsed.memory.hygiene_enabled);
11433 assert_eq!(parsed.memory.archive_after_days, 7);
11434 assert_eq!(parsed.memory.purge_after_days, 30);
11435 assert_eq!(parsed.memory.conversation_retention_days, 30);
11436 assert_eq!(parsed.provider_timeout_secs, 120);
11438 }
11439
11440 #[test]
11443 async fn autonomy_section_is_not_silently_ignored() {
11444 let raw = r#"
11445default_temperature = 0.7
11446
11447[autonomy]
11448level = "full"
11449max_actions_per_hour = 99
11450auto_approve = ["file_read", "memory_recall", "http_request"]
11451"#;
11452 let parsed = parse_test_config(raw);
11453 assert_eq!(
11454 parsed.autonomy.level,
11455 AutonomyLevel::Full,
11456 "autonomy.level must be parsed from config (was silently defaulting to Supervised)"
11457 );
11458 assert_eq!(
11459 parsed.autonomy.max_actions_per_hour, 99,
11460 "autonomy.max_actions_per_hour must be parsed from config"
11461 );
11462 assert!(
11463 parsed
11464 .autonomy
11465 .auto_approve
11466 .contains(&"http_request".to_string()),
11467 "autonomy.auto_approve must include http_request from config"
11468 );
11469 }
11470
11471 #[test]
11474 async fn auto_approve_merges_user_entries_with_defaults() {
11475 let raw = r#"
11476default_temperature = 0.7
11477
11478[autonomy]
11479auto_approve = ["my_custom_tool", "another_tool"]
11480"#;
11481 let parsed = parse_test_config(raw);
11482 assert!(
11484 parsed
11485 .autonomy
11486 .auto_approve
11487 .contains(&"my_custom_tool".to_string()),
11488 "user-supplied tool must remain in auto_approve"
11489 );
11490 assert!(
11491 parsed
11492 .autonomy
11493 .auto_approve
11494 .contains(&"another_tool".to_string()),
11495 "user-supplied tool must remain in auto_approve"
11496 );
11497 for default_tool in &[
11499 "file_read",
11500 "memory_recall",
11501 "weather",
11502 "calculator",
11503 "web_fetch",
11504 ] {
11505 assert!(
11506 parsed
11507 .autonomy
11508 .auto_approve
11509 .contains(&String::from(*default_tool)),
11510 "default tool '{default_tool}' must be present in auto_approve even when user provides custom list"
11511 );
11512 }
11513 }
11514
11515 #[test]
11517 async fn auto_approve_empty_list_gets_defaults() {
11518 let raw = r#"
11519default_temperature = 0.7
11520
11521[autonomy]
11522auto_approve = []
11523"#;
11524 let parsed = parse_test_config(raw);
11525 let defaults = default_auto_approve();
11526 for tool in &defaults {
11527 assert!(
11528 parsed.autonomy.auto_approve.contains(tool),
11529 "default tool '{tool}' must be present even when user sets auto_approve = []"
11530 );
11531 }
11532 }
11533
11534 #[test]
11536 async fn auto_approve_defaults_when_no_autonomy_section() {
11537 let raw = r#"
11538default_temperature = 0.7
11539"#;
11540 let parsed = parse_test_config(raw);
11541 let defaults = default_auto_approve();
11542 for tool in &defaults {
11543 assert!(
11544 parsed.autonomy.auto_approve.contains(tool),
11545 "default tool '{tool}' must be present when no [autonomy] section"
11546 );
11547 }
11548 }
11549
11550 #[test]
11553 async fn auto_approve_no_duplicates() {
11554 let raw = r#"
11555default_temperature = 0.7
11556
11557[autonomy]
11558auto_approve = ["weather", "file_read"]
11559"#;
11560 let parsed = parse_test_config(raw);
11561 let weather_count = parsed
11562 .autonomy
11563 .auto_approve
11564 .iter()
11565 .filter(|t| *t == "weather")
11566 .count();
11567 assert_eq!(weather_count, 1, "weather must not be duplicated");
11568 let file_read_count = parsed
11569 .autonomy
11570 .auto_approve
11571 .iter()
11572 .filter(|t| *t == "file_read")
11573 .count();
11574 assert_eq!(file_read_count, 1, "file_read must not be duplicated");
11575 }
11576
11577 #[test]
11578 async fn provider_timeout_secs_parses_from_toml() {
11579 let raw = r#"
11580default_temperature = 0.7
11581provider_timeout_secs = 300
11582"#;
11583 let parsed = parse_test_config(raw);
11584 assert_eq!(parsed.provider_timeout_secs, 300);
11585 }
11586
11587 #[test]
11588 async fn parse_extra_headers_env_basic() {
11589 let headers = parse_extra_headers_env("User-Agent:MyApp/1.0,X-Title:construct");
11590 assert_eq!(headers.len(), 2);
11591 assert_eq!(
11592 headers[0],
11593 ("User-Agent".to_string(), "MyApp/1.0".to_string())
11594 );
11595 assert_eq!(headers[1], ("X-Title".to_string(), "construct".to_string()));
11596 }
11597
11598 #[test]
11599 async fn parse_extra_headers_env_with_url_value() {
11600 let headers = parse_extra_headers_env("HTTP-Referer:https://github.com/KumihoIO/construct");
11601 assert_eq!(headers.len(), 1);
11602 assert_eq!(headers[0].0, "HTTP-Referer");
11604 assert_eq!(headers[0].1, "https://github.com/KumihoIO/construct");
11605 }
11606
11607 #[test]
11608 async fn parse_extra_headers_env_empty_string() {
11609 let headers = parse_extra_headers_env("");
11610 assert!(headers.is_empty());
11611 }
11612
11613 #[test]
11614 async fn parse_extra_headers_env_whitespace_trimming() {
11615 let headers = parse_extra_headers_env(" X-Title : construct , User-Agent : cli/1.0 ");
11616 assert_eq!(headers.len(), 2);
11617 assert_eq!(headers[0], ("X-Title".to_string(), "construct".to_string()));
11618 assert_eq!(
11619 headers[1],
11620 ("User-Agent".to_string(), "cli/1.0".to_string())
11621 );
11622 }
11623
11624 #[test]
11625 async fn parse_extra_headers_env_skips_malformed() {
11626 let headers = parse_extra_headers_env("X-Valid:value,no-colon-here,Another:ok");
11627 assert_eq!(headers.len(), 2);
11628 assert_eq!(headers[0], ("X-Valid".to_string(), "value".to_string()));
11629 assert_eq!(headers[1], ("Another".to_string(), "ok".to_string()));
11630 }
11631
11632 #[test]
11633 async fn parse_extra_headers_env_skips_empty_key() {
11634 let headers = parse_extra_headers_env(":value,X-Valid:ok");
11635 assert_eq!(headers.len(), 1);
11636 assert_eq!(headers[0], ("X-Valid".to_string(), "ok".to_string()));
11637 }
11638
11639 #[test]
11640 async fn parse_extra_headers_env_allows_empty_value() {
11641 let headers = parse_extra_headers_env("X-Empty:");
11642 assert_eq!(headers.len(), 1);
11643 assert_eq!(headers[0], ("X-Empty".to_string(), String::new()));
11644 }
11645
11646 #[test]
11647 async fn parse_extra_headers_env_trailing_comma() {
11648 let headers = parse_extra_headers_env("X-Title:construct,");
11649 assert_eq!(headers.len(), 1);
11650 assert_eq!(headers[0], ("X-Title".to_string(), "construct".to_string()));
11651 }
11652
11653 #[test]
11654 async fn extra_headers_parses_from_toml() {
11655 let raw = r#"
11656default_temperature = 0.7
11657
11658[extra_headers]
11659User-Agent = "MyApp/1.0"
11660X-Title = "construct"
11661"#;
11662 let parsed = parse_test_config(raw);
11663 assert_eq!(parsed.extra_headers.len(), 2);
11664 assert_eq!(parsed.extra_headers.get("User-Agent").unwrap(), "MyApp/1.0");
11665 assert_eq!(parsed.extra_headers.get("X-Title").unwrap(), "construct");
11666 }
11667
11668 #[test]
11669 async fn extra_headers_defaults_to_empty() {
11670 let raw = r#"
11671default_temperature = 0.7
11672"#;
11673 let parsed = parse_test_config(raw);
11674 assert!(parsed.extra_headers.is_empty());
11675 }
11676
11677 #[test]
11678 async fn storage_provider_dburl_alias_deserializes() {
11679 let raw = r#"
11680default_temperature = 0.7
11681
11682[storage.provider.config]
11683provider = "qdrant"
11684dbURL = "http://localhost:6333"
11685schema = "public"
11686table = "memories"
11687connect_timeout_secs = 12
11688"#;
11689
11690 let parsed = parse_test_config(raw);
11691 assert_eq!(parsed.storage.provider.config.provider, "qdrant");
11692 assert_eq!(
11693 parsed.storage.provider.config.db_url.as_deref(),
11694 Some("http://localhost:6333")
11695 );
11696 assert_eq!(parsed.storage.provider.config.schema, "public");
11697 assert_eq!(parsed.storage.provider.config.table, "memories");
11698 assert_eq!(
11699 parsed.storage.provider.config.connect_timeout_secs,
11700 Some(12)
11701 );
11702 }
11703
11704 #[test]
11705 async fn runtime_reasoning_enabled_deserializes() {
11706 let raw = r#"
11707default_temperature = 0.7
11708
11709[runtime]
11710reasoning_enabled = false
11711"#;
11712
11713 let parsed = parse_test_config(raw);
11714 assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
11715 }
11716
11717 #[test]
11718 async fn runtime_reasoning_effort_deserializes() {
11719 let raw = r#"
11720default_temperature = 0.7
11721
11722[runtime]
11723reasoning_effort = "HIGH"
11724"#;
11725
11726 let parsed: Config = toml::from_str(raw).unwrap();
11727 assert_eq!(parsed.runtime.reasoning_effort.as_deref(), Some("high"));
11728 }
11729
11730 #[test]
11731 async fn runtime_reasoning_effort_rejects_invalid_values() {
11732 let raw = r#"
11733default_temperature = 0.7
11734
11735[runtime]
11736reasoning_effort = "turbo"
11737"#;
11738
11739 let error = toml::from_str::<Config>(raw).expect_err("invalid value should fail");
11740 assert!(error.to_string().contains("reasoning_effort"));
11741 }
11742
11743 #[test]
11744 async fn agent_config_defaults() {
11745 let cfg = AgentConfig::default();
11746 assert!(cfg.compact_context);
11747 assert_eq!(cfg.max_tool_iterations, 10);
11748 assert_eq!(cfg.max_history_messages, 50);
11749 assert!(!cfg.parallel_tools);
11750 assert_eq!(cfg.tool_dispatcher, "auto");
11751 }
11752
11753 #[test]
11754 async fn agent_config_deserializes() {
11755 let raw = r#"
11756default_temperature = 0.7
11757[agent]
11758compact_context = true
11759max_tool_iterations = 20
11760max_history_messages = 80
11761parallel_tools = true
11762tool_dispatcher = "xml"
11763"#;
11764 let parsed = parse_test_config(raw);
11765 assert!(parsed.agent.compact_context);
11766 assert_eq!(parsed.agent.max_tool_iterations, 20);
11767 assert_eq!(parsed.agent.max_history_messages, 80);
11768 assert!(parsed.agent.parallel_tools);
11769 assert_eq!(parsed.agent.tool_dispatcher, "xml");
11770 }
11771
11772 #[test]
11773 async fn pacing_config_defaults_are_all_none_or_empty() {
11774 let cfg = PacingConfig::default();
11775 assert!(cfg.step_timeout_secs.is_none());
11776 assert!(cfg.loop_detection_min_elapsed_secs.is_none());
11777 assert!(cfg.loop_ignore_tools.is_empty());
11778 assert!(cfg.message_timeout_scale_max.is_none());
11779 }
11780
11781 #[test]
11782 async fn pacing_config_deserializes_from_toml() {
11783 let raw = r#"
11784default_temperature = 0.7
11785[pacing]
11786step_timeout_secs = 120
11787loop_detection_min_elapsed_secs = 60
11788loop_ignore_tools = ["browser_screenshot", "browser_navigate"]
11789message_timeout_scale_max = 8
11790"#;
11791 let parsed: Config = toml::from_str(raw).unwrap();
11792 assert_eq!(parsed.pacing.step_timeout_secs, Some(120));
11793 assert_eq!(parsed.pacing.loop_detection_min_elapsed_secs, Some(60));
11794 assert_eq!(
11795 parsed.pacing.loop_ignore_tools,
11796 vec!["browser_screenshot", "browser_navigate"]
11797 );
11798 assert_eq!(parsed.pacing.message_timeout_scale_max, Some(8));
11799 }
11800
11801 #[test]
11802 async fn pacing_config_absent_preserves_defaults() {
11803 let raw = r#"
11804default_temperature = 0.7
11805"#;
11806 let parsed: Config = toml::from_str(raw).unwrap();
11807 assert!(parsed.pacing.step_timeout_secs.is_none());
11808 assert!(parsed.pacing.loop_detection_min_elapsed_secs.is_none());
11809 assert!(parsed.pacing.loop_ignore_tools.is_empty());
11810 assert!(parsed.pacing.message_timeout_scale_max.is_none());
11811 }
11812
11813 #[tokio::test]
11814 async fn sync_directory_handles_existing_directory() {
11815 let dir = std::env::temp_dir().join(format!(
11816 "construct_test_sync_directory_{}",
11817 uuid::Uuid::new_v4()
11818 ));
11819 fs::create_dir_all(&dir).await.unwrap();
11820
11821 sync_directory(&dir).await.unwrap();
11822
11823 let _ = fs::remove_dir_all(&dir).await;
11824 }
11825
11826 #[tokio::test]
11827 async fn config_save_and_load_tmpdir() {
11828 let dir = std::env::temp_dir().join("construct_test_config");
11829 let _ = fs::remove_dir_all(&dir).await;
11830 fs::create_dir_all(&dir).await.unwrap();
11831
11832 let config_path = dir.join("config.toml");
11833 let config = Config {
11834 workspace_dir: dir.join("workspace"),
11835 config_path: config_path.clone(),
11836 api_key: Some("sk-roundtrip".into()),
11837 api_url: None,
11838 api_path: None,
11839 default_provider: Some("openrouter".into()),
11840 default_model: Some("test-model".into()),
11841 model_providers: HashMap::new(),
11842 default_temperature: 0.9,
11843 provider_timeout_secs: 120,
11844 provider_max_tokens: None,
11845 extra_headers: HashMap::new(),
11846 observability: ObservabilityConfig::default(),
11847 autonomy: AutonomyConfig::default(),
11848 trust: crate::trust::TrustConfig::default(),
11849 backup: BackupConfig::default(),
11850 data_retention: DataRetentionConfig::default(),
11851 cloud_ops: CloudOpsConfig::default(),
11852 conversational_ai: ConversationalAiConfig::default(),
11853 security: SecurityConfig::default(),
11854 security_ops: SecurityOpsConfig::default(),
11855 runtime: RuntimeConfig::default(),
11856 reliability: ReliabilityConfig::default(),
11857 scheduler: SchedulerConfig::default(),
11858 skills: SkillsConfig::default(),
11859 pipeline: PipelineConfig::default(),
11860 model_routes: Vec::new(),
11861 embedding_routes: Vec::new(),
11862 query_classification: QueryClassificationConfig::default(),
11863 heartbeat: HeartbeatConfig::default(),
11864 cron: CronConfig::default(),
11865 channels_config: ChannelsConfig::default(),
11866 memory: MemoryConfig::default(),
11867 storage: StorageConfig::default(),
11868 tunnel: TunnelConfig::default(),
11869 gateway: GatewayConfig::default(),
11870 composio: ComposioConfig::default(),
11871 microsoft365: Microsoft365Config::default(),
11872 secrets: SecretsConfig::default(),
11873 browser: BrowserConfig::default(),
11874 browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig::default(),
11875 http_request: HttpRequestConfig::default(),
11876 multimodal: MultimodalConfig::default(),
11877 media_pipeline: MediaPipelineConfig::default(),
11878 web_fetch: WebFetchConfig::default(),
11879 link_enricher: LinkEnricherConfig::default(),
11880 text_browser: TextBrowserConfig::default(),
11881 web_search: WebSearchConfig::default(),
11882 project_intel: ProjectIntelConfig::default(),
11883 google_workspace: GoogleWorkspaceConfig::default(),
11884 proxy: ProxyConfig::default(),
11885 agent: AgentConfig::default(),
11886 pacing: PacingConfig::default(),
11887 identity: IdentityConfig::default(),
11888 cost: CostConfig::default(),
11889 peripherals: PeripheralsConfig::default(),
11890 delegate: DelegateToolConfig::default(),
11891 agents: HashMap::new(),
11892 swarms: HashMap::new(),
11893 hooks: HooksConfig::default(),
11894 hardware: HardwareConfig::default(),
11895 transcription: TranscriptionConfig::default(),
11896 tts: TtsConfig::default(),
11897 mcp: McpConfig::default(),
11898 kumiho: KumihoConfig::default(),
11899 operator: OperatorConfig::default(),
11900 nodes: NodesConfig::default(),
11901 clawhub: ClawHubConfig::default(),
11902 workspace: WorkspaceConfig::default(),
11903 notion: NotionConfig::default(),
11904 jira: JiraConfig::default(),
11905 node_transport: NodeTransportConfig::default(),
11906 linkedin: LinkedInConfig::default(),
11907 image_gen: ImageGenConfig::default(),
11908 plugins: PluginsConfig::default(),
11909 locale: None,
11910 verifiable_intent: VerifiableIntentConfig::default(),
11911 claude_code: ClaudeCodeConfig::default(),
11912 claude_code_runner: ClaudeCodeRunnerConfig::default(),
11913 codex_cli: CodexCliConfig::default(),
11914 gemini_cli: GeminiCliConfig::default(),
11915 opencode_cli: OpenCodeCliConfig::default(),
11916 sop: SopConfig::default(),
11917 shell_tool: ShellToolConfig::default(),
11918 };
11919
11920 config.save().await.unwrap();
11921 assert!(config_path.exists());
11922
11923 let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
11924 let loaded: Config = toml::from_str(&contents).unwrap();
11925 assert!(
11926 loaded
11927 .api_key
11928 .as_deref()
11929 .is_some_and(crate::security::SecretStore::is_encrypted)
11930 );
11931 let store = crate::security::SecretStore::new(&dir, true);
11932 let decrypted = store.decrypt(loaded.api_key.as_deref().unwrap()).unwrap();
11933 assert_eq!(decrypted, "sk-roundtrip");
11934 assert_eq!(loaded.default_model.as_deref(), Some("test-model"));
11935 assert!((loaded.default_temperature - 0.9).abs() < f64::EPSILON);
11936
11937 let _ = fs::remove_dir_all(&dir).await;
11938 }
11939
11940 #[tokio::test]
11941 async fn config_save_encrypts_nested_credentials() {
11942 let dir = std::env::temp_dir().join(format!(
11943 "construct_test_nested_credentials_{}",
11944 uuid::Uuid::new_v4()
11945 ));
11946 fs::create_dir_all(&dir).await.unwrap();
11947
11948 let mut config = Config::default();
11949 config.workspace_dir = dir.join("workspace");
11950 config.config_path = dir.join("config.toml");
11951 config.api_key = Some("root-credential".into());
11952 config.composio.api_key = Some("composio-credential".into());
11953 config.browser.computer_use.api_key = Some("browser-credential".into());
11954 config.web_search.brave_api_key = Some("brave-credential".into());
11955 config.storage.provider.config.db_url = Some("postgres://user:pw@host/db".into());
11956 config.channels_config.feishu = Some(FeishuConfig {
11957 app_id: "cli_feishu_123".into(),
11958 app_secret: "feishu-secret".into(),
11959 encrypt_key: Some("feishu-encrypt".into()),
11960 verification_token: Some("feishu-verify".into()),
11961 allowed_users: vec!["*".into()],
11962 receive_mode: LarkReceiveMode::Websocket,
11963 port: None,
11964 proxy_url: None,
11965 });
11966
11967 config.agents.insert(
11968 "worker".into(),
11969 DelegateAgentConfig {
11970 provider: "openrouter".into(),
11971 model: "model-test".into(),
11972 system_prompt: None,
11973 api_key: Some("agent-credential".into()),
11974 temperature: None,
11975 max_depth: 3,
11976 agentic: false,
11977 allowed_tools: Vec::new(),
11978 max_iterations: 10,
11979 timeout_secs: None,
11980 agentic_timeout_secs: None,
11981 skills_directory: None,
11982 },
11983 );
11984
11985 config.save().await.unwrap();
11986
11987 let contents = tokio::fs::read_to_string(config.config_path.clone())
11988 .await
11989 .unwrap();
11990 let stored: Config = toml::from_str(&contents).unwrap();
11991 let store = crate::security::SecretStore::new(&dir, true);
11992
11993 let root_encrypted = stored.api_key.as_deref().unwrap();
11994 assert!(crate::security::SecretStore::is_encrypted(root_encrypted));
11995 assert_eq!(store.decrypt(root_encrypted).unwrap(), "root-credential");
11996
11997 let composio_encrypted = stored.composio.api_key.as_deref().unwrap();
11998 assert!(crate::security::SecretStore::is_encrypted(
11999 composio_encrypted
12000 ));
12001 assert_eq!(
12002 store.decrypt(composio_encrypted).unwrap(),
12003 "composio-credential"
12004 );
12005
12006 let browser_encrypted = stored.browser.computer_use.api_key.as_deref().unwrap();
12007 assert!(crate::security::SecretStore::is_encrypted(
12008 browser_encrypted
12009 ));
12010 assert_eq!(
12011 store.decrypt(browser_encrypted).unwrap(),
12012 "browser-credential"
12013 );
12014
12015 let web_search_encrypted = stored.web_search.brave_api_key.as_deref().unwrap();
12016 assert!(crate::security::SecretStore::is_encrypted(
12017 web_search_encrypted
12018 ));
12019 assert_eq!(
12020 store.decrypt(web_search_encrypted).unwrap(),
12021 "brave-credential"
12022 );
12023
12024 let worker = stored.agents.get("worker").unwrap();
12025 let worker_encrypted = worker.api_key.as_deref().unwrap();
12026 assert!(crate::security::SecretStore::is_encrypted(worker_encrypted));
12027 assert_eq!(store.decrypt(worker_encrypted).unwrap(), "agent-credential");
12028
12029 let storage_db_url = stored.storage.provider.config.db_url.as_deref().unwrap();
12030 assert!(crate::security::SecretStore::is_encrypted(storage_db_url));
12031 assert_eq!(
12032 store.decrypt(storage_db_url).unwrap(),
12033 "postgres://user:pw@host/db"
12034 );
12035
12036 let feishu = stored.channels_config.feishu.as_ref().unwrap();
12037 assert!(crate::security::SecretStore::is_encrypted(
12038 &feishu.app_secret
12039 ));
12040 assert_eq!(store.decrypt(&feishu.app_secret).unwrap(), "feishu-secret");
12041 assert!(
12042 feishu
12043 .encrypt_key
12044 .as_deref()
12045 .is_some_and(crate::security::SecretStore::is_encrypted)
12046 );
12047 assert_eq!(
12048 store
12049 .decrypt(feishu.encrypt_key.as_deref().unwrap())
12050 .unwrap(),
12051 "feishu-encrypt"
12052 );
12053 assert!(
12054 feishu
12055 .verification_token
12056 .as_deref()
12057 .is_some_and(crate::security::SecretStore::is_encrypted)
12058 );
12059 assert_eq!(
12060 store
12061 .decrypt(feishu.verification_token.as_deref().unwrap())
12062 .unwrap(),
12063 "feishu-verify"
12064 );
12065
12066 let _ = fs::remove_dir_all(&dir).await;
12067 }
12068
12069 #[tokio::test]
12070 async fn config_save_atomic_cleanup() {
12071 let dir =
12072 std::env::temp_dir().join(format!("construct_test_config_{}", uuid::Uuid::new_v4()));
12073 fs::create_dir_all(&dir).await.unwrap();
12074
12075 let config_path = dir.join("config.toml");
12076 let mut config = Config::default();
12077 config.workspace_dir = dir.join("workspace");
12078 config.config_path = config_path.clone();
12079 config.default_model = Some("model-a".into());
12080 config.save().await.unwrap();
12081 assert!(config_path.exists());
12082
12083 config.default_model = Some("model-b".into());
12084 config.save().await.unwrap();
12085
12086 let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
12087 assert!(contents.contains("model-b"));
12088
12089 let names: Vec<String> = ReadDirStream::new(fs::read_dir(&dir).await.unwrap())
12090 .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
12091 .collect()
12092 .await;
12093 assert!(!names.iter().any(|name| name.contains(".tmp-")));
12094 assert!(!names.iter().any(|name| name.ends_with(".bak")));
12095
12096 let _ = fs::remove_dir_all(&dir).await;
12097 }
12098
12099 #[test]
12102 async fn telegram_config_serde() {
12103 let tc = TelegramConfig {
12104 bot_token: "123:XYZ".into(),
12105 allowed_users: vec!["alice".into(), "bob".into()],
12106 stream_mode: StreamMode::Partial,
12107 draft_update_interval_ms: 500,
12108 interrupt_on_new_message: true,
12109 mention_only: false,
12110 ack_reactions: None,
12111 proxy_url: None,
12112 notification_chat_id: None,
12113 };
12114 let json = serde_json::to_string(&tc).unwrap();
12115 let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
12116 assert_eq!(parsed.bot_token, "123:XYZ");
12117 assert_eq!(parsed.allowed_users.len(), 2);
12118 assert_eq!(parsed.stream_mode, StreamMode::Partial);
12119 assert_eq!(parsed.draft_update_interval_ms, 500);
12120 assert!(parsed.interrupt_on_new_message);
12121 }
12122
12123 #[test]
12124 async fn telegram_config_defaults_stream_off() {
12125 let json = r#"{"bot_token":"tok","allowed_users":[]}"#;
12126 let parsed: TelegramConfig = serde_json::from_str(json).unwrap();
12127 assert_eq!(parsed.stream_mode, StreamMode::Off);
12128 assert_eq!(parsed.draft_update_interval_ms, 1000);
12129 assert!(!parsed.interrupt_on_new_message);
12130 }
12131
12132 #[test]
12133 async fn discord_config_serde() {
12134 let dc = DiscordConfig {
12135 bot_token: "discord-token".into(),
12136 guild_id: Some("12345".into()),
12137 allowed_users: vec![],
12138 listen_to_bots: false,
12139 interrupt_on_new_message: false,
12140 mention_only: false,
12141 proxy_url: None,
12142 stream_mode: StreamMode::default(),
12143 draft_update_interval_ms: 1000,
12144 multi_message_delay_ms: 800,
12145 notification_channel_id: None,
12146 };
12147 let json = serde_json::to_string(&dc).unwrap();
12148 let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
12149 assert_eq!(parsed.bot_token, "discord-token");
12150 assert_eq!(parsed.guild_id.as_deref(), Some("12345"));
12151 }
12152
12153 #[test]
12154 async fn discord_config_optional_guild() {
12155 let dc = DiscordConfig {
12156 bot_token: "tok".into(),
12157 guild_id: None,
12158 allowed_users: vec![],
12159 listen_to_bots: false,
12160 interrupt_on_new_message: false,
12161 mention_only: false,
12162 proxy_url: None,
12163 stream_mode: StreamMode::default(),
12164 draft_update_interval_ms: 1000,
12165 multi_message_delay_ms: 800,
12166 notification_channel_id: None,
12167 };
12168 let json = serde_json::to_string(&dc).unwrap();
12169 let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
12170 assert!(parsed.guild_id.is_none());
12171 }
12172
12173 #[test]
12176 async fn imessage_config_serde() {
12177 let ic = IMessageConfig {
12178 allowed_contacts: vec!["+1234567890".into(), "user@icloud.com".into()],
12179 };
12180 let json = serde_json::to_string(&ic).unwrap();
12181 let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
12182 assert_eq!(parsed.allowed_contacts.len(), 2);
12183 assert_eq!(parsed.allowed_contacts[0], "+1234567890");
12184 }
12185
12186 #[test]
12187 async fn imessage_config_empty_contacts() {
12188 let ic = IMessageConfig {
12189 allowed_contacts: vec![],
12190 };
12191 let json = serde_json::to_string(&ic).unwrap();
12192 let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
12193 assert!(parsed.allowed_contacts.is_empty());
12194 }
12195
12196 #[test]
12197 async fn imessage_config_wildcard() {
12198 let ic = IMessageConfig {
12199 allowed_contacts: vec!["*".into()],
12200 };
12201 let toml_str = toml::to_string(&ic).unwrap();
12202 let parsed: IMessageConfig = toml::from_str(&toml_str).unwrap();
12203 assert_eq!(parsed.allowed_contacts, vec!["*"]);
12204 }
12205
12206 #[test]
12207 async fn matrix_config_serde() {
12208 let mc = MatrixConfig {
12209 homeserver: "https://matrix.org".into(),
12210 access_token: "syt_token_abc".into(),
12211 user_id: Some("@bot:matrix.org".into()),
12212 device_id: Some("DEVICE123".into()),
12213 room_id: "!room123:matrix.org".into(),
12214 allowed_users: vec!["@user:matrix.org".into()],
12215 allowed_rooms: vec![],
12216 interrupt_on_new_message: false,
12217 stream_mode: StreamMode::default(),
12218 draft_update_interval_ms: 1500,
12219 multi_message_delay_ms: 800,
12220 recovery_key: None,
12221 };
12222 let json = serde_json::to_string(&mc).unwrap();
12223 let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
12224 assert_eq!(parsed.homeserver, "https://matrix.org");
12225 assert_eq!(parsed.access_token, "syt_token_abc");
12226 assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org"));
12227 assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123"));
12228 assert_eq!(parsed.room_id, "!room123:matrix.org");
12229 assert_eq!(parsed.allowed_users.len(), 1);
12230 }
12231
12232 #[test]
12233 async fn matrix_config_toml_roundtrip() {
12234 let mc = MatrixConfig {
12235 homeserver: "https://synapse.local:8448".into(),
12236 access_token: "tok".into(),
12237 user_id: None,
12238 device_id: None,
12239 room_id: "!abc:synapse.local".into(),
12240 allowed_users: vec!["@admin:synapse.local".into(), "*".into()],
12241 allowed_rooms: vec![],
12242 interrupt_on_new_message: false,
12243 stream_mode: StreamMode::default(),
12244 draft_update_interval_ms: 1500,
12245 multi_message_delay_ms: 800,
12246 recovery_key: None,
12247 };
12248 let toml_str = toml::to_string(&mc).unwrap();
12249 let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
12250 assert_eq!(parsed.homeserver, "https://synapse.local:8448");
12251 assert_eq!(parsed.allowed_users.len(), 2);
12252 }
12253
12254 #[test]
12255 async fn matrix_config_backward_compatible_without_session_hints() {
12256 let toml = r#"
12257homeserver = "https://matrix.org"
12258access_token = "tok"
12259room_id = "!ops:matrix.org"
12260allowed_users = ["@ops:matrix.org"]
12261"#;
12262
12263 let parsed: MatrixConfig = toml::from_str(toml).unwrap();
12264 assert_eq!(parsed.homeserver, "https://matrix.org");
12265 assert!(parsed.user_id.is_none());
12266 assert!(parsed.device_id.is_none());
12267 }
12268
12269 #[test]
12270 async fn signal_config_serde() {
12271 let sc = SignalConfig {
12272 http_url: "http://127.0.0.1:8686".into(),
12273 account: "+1234567890".into(),
12274 group_id: Some("group123".into()),
12275 allowed_from: vec!["+1111111111".into()],
12276 ignore_attachments: true,
12277 ignore_stories: false,
12278 proxy_url: None,
12279 };
12280 let json = serde_json::to_string(&sc).unwrap();
12281 let parsed: SignalConfig = serde_json::from_str(&json).unwrap();
12282 assert_eq!(parsed.http_url, "http://127.0.0.1:8686");
12283 assert_eq!(parsed.account, "+1234567890");
12284 assert_eq!(parsed.group_id.as_deref(), Some("group123"));
12285 assert_eq!(parsed.allowed_from.len(), 1);
12286 assert!(parsed.ignore_attachments);
12287 assert!(!parsed.ignore_stories);
12288 }
12289
12290 #[test]
12291 async fn signal_config_toml_roundtrip() {
12292 let sc = SignalConfig {
12293 http_url: "http://localhost:8080".into(),
12294 account: "+9876543210".into(),
12295 group_id: None,
12296 allowed_from: vec!["*".into()],
12297 ignore_attachments: false,
12298 ignore_stories: true,
12299 proxy_url: None,
12300 };
12301 let toml_str = toml::to_string(&sc).unwrap();
12302 let parsed: SignalConfig = toml::from_str(&toml_str).unwrap();
12303 assert_eq!(parsed.http_url, "http://localhost:8080");
12304 assert_eq!(parsed.account, "+9876543210");
12305 assert!(parsed.group_id.is_none());
12306 assert!(parsed.ignore_stories);
12307 }
12308
12309 #[test]
12310 async fn signal_config_defaults() {
12311 let json = r#"{"http_url":"http://127.0.0.1:8686","account":"+1234567890"}"#;
12312 let parsed: SignalConfig = serde_json::from_str(json).unwrap();
12313 assert!(parsed.group_id.is_none());
12314 assert!(parsed.allowed_from.is_empty());
12315 assert!(!parsed.ignore_attachments);
12316 assert!(!parsed.ignore_stories);
12317 }
12318
12319 #[test]
12320 async fn channels_config_with_imessage_and_matrix() {
12321 let c = ChannelsConfig {
12322 cli: true,
12323 telegram: None,
12324 discord: None,
12325 discord_history: None,
12326 slack: None,
12327 mattermost: None,
12328 webhook: None,
12329 imessage: Some(IMessageConfig {
12330 allowed_contacts: vec!["+1".into()],
12331 }),
12332 matrix: Some(MatrixConfig {
12333 homeserver: "https://m.org".into(),
12334 access_token: "tok".into(),
12335 user_id: None,
12336 device_id: None,
12337 room_id: "!r:m".into(),
12338 allowed_users: vec!["@u:m".into()],
12339 allowed_rooms: vec![],
12340 interrupt_on_new_message: false,
12341 stream_mode: StreamMode::default(),
12342 draft_update_interval_ms: 1500,
12343 multi_message_delay_ms: 800,
12344 recovery_key: None,
12345 }),
12346 signal: None,
12347 whatsapp: None,
12348 linq: None,
12349 wati: None,
12350 nextcloud_talk: None,
12351 email: None,
12352 gmail_push: None,
12353 irc: None,
12354 lark: None,
12355 feishu: None,
12356 dingtalk: None,
12357 wecom: None,
12358 qq: None,
12359 twitter: None,
12360 mochat: None,
12361 #[cfg(feature = "channel-nostr")]
12362 nostr: None,
12363 clawdtalk: None,
12364 reddit: None,
12365 bluesky: None,
12366 voice_call: None,
12367 #[cfg(feature = "voice-wake")]
12368 voice_wake: None,
12369 message_timeout_secs: 300,
12370 ack_reactions: true,
12371 show_tool_calls: true,
12372 session_persistence: true,
12373 session_backend: default_session_backend(),
12374 session_ttl_hours: 0,
12375 debounce_ms: 0,
12376 };
12377 let toml_str = toml::to_string_pretty(&c).unwrap();
12378 let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
12379 assert!(parsed.imessage.is_some());
12380 assert!(parsed.matrix.is_some());
12381 assert_eq!(parsed.imessage.unwrap().allowed_contacts, vec!["+1"]);
12382 assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org");
12383 }
12384
12385 #[test]
12386 async fn channels_config_default_has_no_imessage_matrix() {
12387 let c = ChannelsConfig::default();
12388 assert!(c.imessage.is_none());
12389 assert!(c.matrix.is_none());
12390 }
12391
12392 #[test]
12395 async fn discord_config_deserializes_without_allowed_users() {
12396 let json = r#"{"bot_token":"tok","guild_id":"123"}"#;
12398 let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12399 assert!(parsed.allowed_users.is_empty());
12400 }
12401
12402 #[test]
12403 async fn discord_config_deserializes_with_allowed_users() {
12404 let json = r#"{"bot_token":"tok","guild_id":"123","allowed_users":["111","222"]}"#;
12405 let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12406 assert_eq!(parsed.allowed_users, vec!["111", "222"]);
12407 }
12408
12409 #[test]
12410 async fn slack_config_deserializes_without_allowed_users() {
12411 let json = r#"{"bot_token":"xoxb-tok"}"#;
12412 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12413 assert!(parsed.channel_ids.is_empty());
12414 assert!(parsed.allowed_users.is_empty());
12415 assert!(!parsed.interrupt_on_new_message);
12416 assert_eq!(parsed.thread_replies, None);
12417 assert!(!parsed.mention_only);
12418 }
12419
12420 #[test]
12421 async fn slack_config_deserializes_with_allowed_users() {
12422 let json = r#"{"bot_token":"xoxb-tok","allowed_users":["U111"]}"#;
12423 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12424 assert!(parsed.channel_ids.is_empty());
12425 assert_eq!(parsed.allowed_users, vec!["U111"]);
12426 assert!(!parsed.interrupt_on_new_message);
12427 assert_eq!(parsed.thread_replies, None);
12428 assert!(!parsed.mention_only);
12429 }
12430
12431 #[test]
12432 async fn slack_config_deserializes_with_channel_ids() {
12433 let json = r#"{"bot_token":"xoxb-tok","channel_ids":["C111","D222"]}"#;
12434 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12435 assert_eq!(parsed.channel_ids, vec!["C111", "D222"]);
12436 assert!(parsed.allowed_users.is_empty());
12437 assert!(!parsed.interrupt_on_new_message);
12438 assert_eq!(parsed.thread_replies, None);
12439 assert!(!parsed.mention_only);
12440 }
12441
12442 #[test]
12443 async fn slack_config_deserializes_with_mention_only() {
12444 let json = r#"{"bot_token":"xoxb-tok","mention_only":true}"#;
12445 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12446 assert!(parsed.mention_only);
12447 assert!(!parsed.interrupt_on_new_message);
12448 assert_eq!(parsed.thread_replies, None);
12449 }
12450
12451 #[test]
12452 async fn slack_config_deserializes_interrupt_on_new_message() {
12453 let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
12454 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12455 assert!(parsed.interrupt_on_new_message);
12456 assert_eq!(parsed.thread_replies, None);
12457 assert!(!parsed.mention_only);
12458 }
12459
12460 #[test]
12461 async fn slack_config_deserializes_thread_replies() {
12462 let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#;
12463 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12464 assert_eq!(parsed.thread_replies, Some(false));
12465 assert!(!parsed.interrupt_on_new_message);
12466 assert!(!parsed.mention_only);
12467 }
12468
12469 #[test]
12470 async fn discord_config_default_interrupt_on_new_message_is_false() {
12471 let json = r#"{"bot_token":"tok"}"#;
12472 let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12473 assert!(!parsed.interrupt_on_new_message);
12474 }
12475
12476 #[test]
12477 async fn discord_config_deserializes_interrupt_on_new_message_true() {
12478 let json = r#"{"bot_token":"tok","interrupt_on_new_message":true}"#;
12479 let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12480 assert!(parsed.interrupt_on_new_message);
12481 }
12482
12483 #[test]
12484 async fn discord_config_toml_backward_compat() {
12485 let toml_str = r#"
12486bot_token = "tok"
12487guild_id = "123"
12488"#;
12489 let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
12490 assert!(parsed.allowed_users.is_empty());
12491 assert_eq!(parsed.bot_token, "tok");
12492 }
12493
12494 #[test]
12495 async fn slack_config_toml_backward_compat() {
12496 let toml_str = r#"
12497bot_token = "xoxb-tok"
12498channel_id = "C123"
12499"#;
12500 let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
12501 assert!(parsed.channel_ids.is_empty());
12502 assert!(parsed.allowed_users.is_empty());
12503 assert!(!parsed.interrupt_on_new_message);
12504 assert_eq!(parsed.thread_replies, None);
12505 assert!(!parsed.mention_only);
12506 assert_eq!(parsed.channel_id.as_deref(), Some("C123"));
12507 }
12508
12509 #[test]
12510 async fn slack_config_toml_accepts_channel_ids() {
12511 let toml_str = r#"
12512bot_token = "xoxb-tok"
12513channel_ids = ["C123", "D456"]
12514"#;
12515 let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
12516 assert_eq!(parsed.channel_ids, vec!["C123", "D456"]);
12517 assert!(parsed.allowed_users.is_empty());
12518 assert!(!parsed.interrupt_on_new_message);
12519 assert_eq!(parsed.thread_replies, None);
12520 assert!(!parsed.mention_only);
12521 assert!(parsed.channel_id.is_none());
12522 }
12523
12524 #[test]
12525 async fn mattermost_config_default_interrupt_on_new_message_is_false() {
12526 let json = r#"{"url":"https://mm.example.com","bot_token":"tok"}"#;
12527 let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
12528 assert!(!parsed.interrupt_on_new_message);
12529 }
12530
12531 #[test]
12532 async fn mattermost_config_deserializes_interrupt_on_new_message_true() {
12533 let json =
12534 r#"{"url":"https://mm.example.com","bot_token":"tok","interrupt_on_new_message":true}"#;
12535 let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
12536 assert!(parsed.interrupt_on_new_message);
12537 }
12538
12539 #[test]
12540 async fn webhook_config_with_secret() {
12541 let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
12542 let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
12543 assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
12544 }
12545
12546 #[test]
12547 async fn webhook_config_without_secret() {
12548 let json = r#"{"port":8080}"#;
12549 let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
12550 assert!(parsed.secret.is_none());
12551 assert_eq!(parsed.port, 8080);
12552 }
12553
12554 #[test]
12557 async fn whatsapp_config_serde() {
12558 let wc = WhatsAppConfig {
12559 access_token: Some("EAABx...".into()),
12560 phone_number_id: Some("123456789".into()),
12561 verify_token: Some("my-verify-token".into()),
12562 app_secret: None,
12563 session_path: None,
12564 pair_phone: None,
12565 pair_code: None,
12566 allowed_numbers: vec!["+1234567890".into(), "+9876543210".into()],
12567 mode: WhatsAppWebMode::default(),
12568 dm_policy: WhatsAppChatPolicy::default(),
12569 group_policy: WhatsAppChatPolicy::default(),
12570 self_chat_mode: false,
12571 dm_mention_patterns: vec![],
12572 group_mention_patterns: vec![],
12573 proxy_url: None,
12574 };
12575 let json = serde_json::to_string(&wc).unwrap();
12576 let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
12577 assert_eq!(parsed.access_token, Some("EAABx...".into()));
12578 assert_eq!(parsed.phone_number_id, Some("123456789".into()));
12579 assert_eq!(parsed.verify_token, Some("my-verify-token".into()));
12580 assert_eq!(parsed.allowed_numbers.len(), 2);
12581 }
12582
12583 #[test]
12584 async fn whatsapp_config_toml_roundtrip() {
12585 let wc = WhatsAppConfig {
12586 access_token: Some("tok".into()),
12587 phone_number_id: Some("12345".into()),
12588 verify_token: Some("verify".into()),
12589 app_secret: Some("secret123".into()),
12590 session_path: None,
12591 pair_phone: None,
12592 pair_code: None,
12593 allowed_numbers: vec!["+1".into()],
12594 mode: WhatsAppWebMode::default(),
12595 dm_policy: WhatsAppChatPolicy::default(),
12596 group_policy: WhatsAppChatPolicy::default(),
12597 self_chat_mode: false,
12598 dm_mention_patterns: vec![],
12599 group_mention_patterns: vec![],
12600 proxy_url: None,
12601 };
12602 let toml_str = toml::to_string(&wc).unwrap();
12603 let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
12604 assert_eq!(parsed.phone_number_id, Some("12345".into()));
12605 assert_eq!(parsed.allowed_numbers, vec!["+1"]);
12606 }
12607
12608 #[test]
12609 async fn whatsapp_config_deserializes_without_allowed_numbers() {
12610 let json = r#"{"access_token":"tok","phone_number_id":"123","verify_token":"ver"}"#;
12611 let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
12612 assert!(parsed.allowed_numbers.is_empty());
12613 }
12614
12615 #[test]
12616 async fn whatsapp_config_wildcard_allowed() {
12617 let wc = WhatsAppConfig {
12618 access_token: Some("tok".into()),
12619 phone_number_id: Some("123".into()),
12620 verify_token: Some("ver".into()),
12621 app_secret: None,
12622 session_path: None,
12623 pair_phone: None,
12624 pair_code: None,
12625 allowed_numbers: vec!["*".into()],
12626 mode: WhatsAppWebMode::default(),
12627 dm_policy: WhatsAppChatPolicy::default(),
12628 group_policy: WhatsAppChatPolicy::default(),
12629 self_chat_mode: false,
12630 dm_mention_patterns: vec![],
12631 group_mention_patterns: vec![],
12632 proxy_url: None,
12633 };
12634 let toml_str = toml::to_string(&wc).unwrap();
12635 let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
12636 assert_eq!(parsed.allowed_numbers, vec!["*"]);
12637 }
12638
12639 #[test]
12640 async fn whatsapp_config_backend_type_cloud_precedence_when_ambiguous() {
12641 let wc = WhatsAppConfig {
12642 access_token: Some("tok".into()),
12643 phone_number_id: Some("123".into()),
12644 verify_token: Some("ver".into()),
12645 app_secret: None,
12646 session_path: Some("~/.construct/state/whatsapp-web/session.db".into()),
12647 pair_phone: None,
12648 pair_code: None,
12649 allowed_numbers: vec!["+1".into()],
12650 mode: WhatsAppWebMode::default(),
12651 dm_policy: WhatsAppChatPolicy::default(),
12652 group_policy: WhatsAppChatPolicy::default(),
12653 self_chat_mode: false,
12654 dm_mention_patterns: vec![],
12655 group_mention_patterns: vec![],
12656 proxy_url: None,
12657 };
12658 assert!(wc.is_ambiguous_config());
12659 assert_eq!(wc.backend_type(), "cloud");
12660 }
12661
12662 #[test]
12663 async fn whatsapp_config_backend_type_web() {
12664 let wc = WhatsAppConfig {
12665 access_token: None,
12666 phone_number_id: None,
12667 verify_token: None,
12668 app_secret: None,
12669 session_path: Some("~/.construct/state/whatsapp-web/session.db".into()),
12670 pair_phone: None,
12671 pair_code: None,
12672 allowed_numbers: vec![],
12673 mode: WhatsAppWebMode::default(),
12674 dm_policy: WhatsAppChatPolicy::default(),
12675 group_policy: WhatsAppChatPolicy::default(),
12676 self_chat_mode: false,
12677 dm_mention_patterns: vec![],
12678 group_mention_patterns: vec![],
12679 proxy_url: None,
12680 };
12681 assert!(!wc.is_ambiguous_config());
12682 assert_eq!(wc.backend_type(), "web");
12683 }
12684
12685 #[test]
12686 async fn channels_config_with_whatsapp() {
12687 let c = ChannelsConfig {
12688 cli: true,
12689 telegram: None,
12690 discord: None,
12691 discord_history: None,
12692 slack: None,
12693 mattermost: None,
12694 webhook: None,
12695 imessage: None,
12696 matrix: None,
12697 signal: None,
12698 whatsapp: Some(WhatsAppConfig {
12699 access_token: Some("tok".into()),
12700 phone_number_id: Some("123".into()),
12701 verify_token: Some("ver".into()),
12702 app_secret: None,
12703 session_path: None,
12704 pair_phone: None,
12705 pair_code: None,
12706 allowed_numbers: vec!["+1".into()],
12707 mode: WhatsAppWebMode::default(),
12708 dm_policy: WhatsAppChatPolicy::default(),
12709 group_policy: WhatsAppChatPolicy::default(),
12710 self_chat_mode: false,
12711 dm_mention_patterns: vec![],
12712 group_mention_patterns: vec![],
12713 proxy_url: None,
12714 }),
12715 linq: None,
12716 wati: None,
12717 nextcloud_talk: None,
12718 email: None,
12719 gmail_push: None,
12720 irc: None,
12721 lark: None,
12722 feishu: None,
12723 dingtalk: None,
12724 wecom: None,
12725 qq: None,
12726 twitter: None,
12727 mochat: None,
12728 #[cfg(feature = "channel-nostr")]
12729 nostr: None,
12730 clawdtalk: None,
12731 reddit: None,
12732 bluesky: None,
12733 voice_call: None,
12734 #[cfg(feature = "voice-wake")]
12735 voice_wake: None,
12736 message_timeout_secs: 300,
12737 ack_reactions: true,
12738 show_tool_calls: true,
12739 session_persistence: true,
12740 session_backend: default_session_backend(),
12741 session_ttl_hours: 0,
12742 debounce_ms: 0,
12743 };
12744 let toml_str = toml::to_string_pretty(&c).unwrap();
12745 let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
12746 assert!(parsed.whatsapp.is_some());
12747 let wa = parsed.whatsapp.unwrap();
12748 assert_eq!(wa.phone_number_id, Some("123".into()));
12749 assert_eq!(wa.allowed_numbers, vec!["+1"]);
12750 }
12751
12752 #[test]
12753 async fn channels_config_default_has_no_whatsapp() {
12754 let c = ChannelsConfig::default();
12755 assert!(c.whatsapp.is_none());
12756 }
12757
12758 #[test]
12759 async fn channels_config_default_has_no_nextcloud_talk() {
12760 let c = ChannelsConfig::default();
12761 assert!(c.nextcloud_talk.is_none());
12762 }
12763
12764 #[test]
12769 async fn checklist_gateway_default_requires_pairing() {
12770 let g = GatewayConfig::default();
12771 assert!(g.require_pairing, "Pairing must be required by default");
12772 }
12773
12774 #[test]
12775 async fn checklist_gateway_default_blocks_public_bind() {
12776 let g = GatewayConfig::default();
12777 assert!(
12778 !g.allow_public_bind,
12779 "Public bind must be blocked by default"
12780 );
12781 }
12782
12783 #[test]
12784 async fn checklist_gateway_default_no_tokens() {
12785 let g = GatewayConfig::default();
12786 assert!(
12787 g.paired_tokens.is_empty(),
12788 "No pre-paired tokens by default"
12789 );
12790 assert_eq!(g.pair_rate_limit_per_minute, 10);
12791 assert_eq!(g.webhook_rate_limit_per_minute, 60);
12792 assert!(!g.trust_forwarded_headers);
12793 assert_eq!(g.rate_limit_max_keys, 10_000);
12794 assert_eq!(g.idempotency_ttl_secs, 300);
12795 assert_eq!(g.idempotency_max_keys, 10_000);
12796 }
12797
12798 #[test]
12799 async fn checklist_gateway_cli_default_host_is_localhost() {
12800 let c = Config::default();
12803 assert!(
12804 c.gateway.require_pairing,
12805 "Config default must require pairing"
12806 );
12807 assert!(
12808 !c.gateway.allow_public_bind,
12809 "Config default must block public bind"
12810 );
12811 }
12812
12813 #[test]
12814 async fn checklist_gateway_serde_roundtrip() {
12815 let g = GatewayConfig {
12816 port: 42617,
12817 host: "127.0.0.1".into(),
12818 require_pairing: true,
12819 allow_public_bind: false,
12820 paired_tokens: vec!["zc_test_token".into()],
12821 pair_rate_limit_per_minute: 12,
12822 webhook_rate_limit_per_minute: 80,
12823 trust_forwarded_headers: true,
12824 path_prefix: Some("/construct".into()),
12825 rate_limit_max_keys: 2048,
12826 idempotency_ttl_secs: 600,
12827 idempotency_max_keys: 4096,
12828 session_persistence: true,
12829 session_ttl_hours: 0,
12830 pairing_dashboard: PairingDashboardConfig::default(),
12831 tls: None,
12832 };
12833 let toml_str = toml::to_string(&g).unwrap();
12834 let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
12835 assert!(parsed.require_pairing);
12836 assert!(parsed.session_persistence);
12837 assert_eq!(parsed.session_ttl_hours, 0);
12838 assert!(!parsed.allow_public_bind);
12839 assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
12840 assert_eq!(parsed.pair_rate_limit_per_minute, 12);
12841 assert_eq!(parsed.webhook_rate_limit_per_minute, 80);
12842 assert!(parsed.trust_forwarded_headers);
12843 assert_eq!(parsed.path_prefix.as_deref(), Some("/construct"));
12844 assert_eq!(parsed.rate_limit_max_keys, 2048);
12845 assert_eq!(parsed.idempotency_ttl_secs, 600);
12846 assert_eq!(parsed.idempotency_max_keys, 4096);
12847 }
12848
12849 #[test]
12850 async fn checklist_gateway_backward_compat_no_gateway_section() {
12851 let minimal = r#"
12853workspace_dir = "/tmp/ws"
12854config_path = "/tmp/config.toml"
12855default_temperature = 0.7
12856"#;
12857 let parsed = parse_test_config(minimal);
12858 assert!(
12859 parsed.gateway.require_pairing,
12860 "Missing [gateway] must default to require_pairing=true"
12861 );
12862 assert!(
12863 !parsed.gateway.allow_public_bind,
12864 "Missing [gateway] must default to allow_public_bind=false"
12865 );
12866 }
12867
12868 #[test]
12869 async fn checklist_autonomy_default_is_workspace_scoped() {
12870 let a = AutonomyConfig::default();
12871 assert!(a.workspace_only, "Default autonomy must be workspace_only");
12872 assert!(
12873 a.forbidden_paths.contains(&"/etc".to_string()),
12874 "Must block /etc"
12875 );
12876 assert!(
12877 a.forbidden_paths.contains(&"/proc".to_string()),
12878 "Must block /proc"
12879 );
12880 assert!(
12881 a.forbidden_paths.contains(&"~/.ssh".to_string()),
12882 "Must block ~/.ssh"
12883 );
12884 }
12885
12886 #[test]
12891 async fn composio_config_default_disabled() {
12892 let c = ComposioConfig::default();
12893 assert!(!c.enabled, "Composio must be disabled by default");
12894 assert!(c.api_key.is_none(), "No API key by default");
12895 assert_eq!(c.entity_id, "default");
12896 }
12897
12898 #[test]
12899 async fn composio_config_serde_roundtrip() {
12900 let c = ComposioConfig {
12901 enabled: true,
12902 api_key: Some("comp-key-123".into()),
12903 entity_id: "user42".into(),
12904 };
12905 let toml_str = toml::to_string(&c).unwrap();
12906 let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
12907 assert!(parsed.enabled);
12908 assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
12909 assert_eq!(parsed.entity_id, "user42");
12910 }
12911
12912 #[test]
12913 async fn composio_config_backward_compat_missing_section() {
12914 let minimal = r#"
12915workspace_dir = "/tmp/ws"
12916config_path = "/tmp/config.toml"
12917default_temperature = 0.7
12918"#;
12919 let parsed = parse_test_config(minimal);
12920 assert!(
12921 !parsed.composio.enabled,
12922 "Missing [composio] must default to disabled"
12923 );
12924 assert!(parsed.composio.api_key.is_none());
12925 }
12926
12927 #[test]
12928 async fn composio_config_partial_toml() {
12929 let toml_str = r"
12930enabled = true
12931";
12932 let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
12933 assert!(parsed.enabled);
12934 assert!(parsed.api_key.is_none());
12935 assert_eq!(parsed.entity_id, "default");
12936 }
12937
12938 #[test]
12939 async fn composio_config_enable_alias_supported() {
12940 let toml_str = r"
12941enable = true
12942";
12943 let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
12944 assert!(parsed.enabled);
12945 assert!(parsed.api_key.is_none());
12946 assert_eq!(parsed.entity_id, "default");
12947 }
12948
12949 #[test]
12954 async fn secrets_config_default_encrypts() {
12955 let s = SecretsConfig::default();
12956 assert!(s.encrypt, "Encryption must be enabled by default");
12957 }
12958
12959 #[test]
12960 async fn secrets_config_serde_roundtrip() {
12961 let s = SecretsConfig { encrypt: false };
12962 let toml_str = toml::to_string(&s).unwrap();
12963 let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
12964 assert!(!parsed.encrypt);
12965 }
12966
12967 #[test]
12968 async fn secrets_config_backward_compat_missing_section() {
12969 let minimal = r#"
12970workspace_dir = "/tmp/ws"
12971config_path = "/tmp/config.toml"
12972default_temperature = 0.7
12973"#;
12974 let parsed = parse_test_config(minimal);
12975 assert!(
12976 parsed.secrets.encrypt,
12977 "Missing [secrets] must default to encrypt=true"
12978 );
12979 }
12980
12981 #[test]
12982 async fn config_default_has_composio_and_secrets() {
12983 let c = Config::default();
12984 assert!(!c.composio.enabled);
12985 assert!(c.composio.api_key.is_none());
12986 assert!(c.secrets.encrypt);
12987 assert!(c.browser.enabled);
12988 assert_eq!(c.browser.allowed_domains, vec!["*".to_string()]);
12989 }
12990
12991 #[test]
12992 async fn browser_config_default_enabled() {
12993 let b = BrowserConfig::default();
12994 assert!(b.enabled);
12995 assert_eq!(b.allowed_domains, vec!["*".to_string()]);
12996 assert_eq!(b.backend, "agent_browser");
12997 assert!(b.native_headless);
12998 assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
12999 assert!(b.native_chrome_path.is_none());
13000 assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions");
13001 assert_eq!(b.computer_use.timeout_ms, 15_000);
13002 assert!(!b.computer_use.allow_remote_endpoint);
13003 assert!(b.computer_use.window_allowlist.is_empty());
13004 assert!(b.computer_use.max_coordinate_x.is_none());
13005 assert!(b.computer_use.max_coordinate_y.is_none());
13006 }
13007
13008 #[test]
13009 async fn browser_config_serde_roundtrip() {
13010 let b = BrowserConfig {
13011 enabled: true,
13012 allowed_domains: vec!["example.com".into(), "docs.example.com".into()],
13013 session_name: None,
13014 backend: "auto".into(),
13015 native_headless: false,
13016 native_webdriver_url: "http://localhost:4444".into(),
13017 native_chrome_path: Some("/usr/bin/chromium".into()),
13018 computer_use: BrowserComputerUseConfig {
13019 endpoint: "https://computer-use.example.com/v1/actions".into(),
13020 api_key: Some("test-token".into()),
13021 timeout_ms: 8_000,
13022 allow_remote_endpoint: true,
13023 window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()],
13024 max_coordinate_x: Some(3840),
13025 max_coordinate_y: Some(2160),
13026 },
13027 };
13028 let toml_str = toml::to_string(&b).unwrap();
13029 let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap();
13030 assert!(parsed.enabled);
13031 assert_eq!(parsed.allowed_domains.len(), 2);
13032 assert_eq!(parsed.allowed_domains[0], "example.com");
13033 assert_eq!(parsed.backend, "auto");
13034 assert!(!parsed.native_headless);
13035 assert_eq!(parsed.native_webdriver_url, "http://localhost:4444");
13036 assert_eq!(
13037 parsed.native_chrome_path.as_deref(),
13038 Some("/usr/bin/chromium")
13039 );
13040 assert_eq!(
13041 parsed.computer_use.endpoint,
13042 "https://computer-use.example.com/v1/actions"
13043 );
13044 assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token"));
13045 assert_eq!(parsed.computer_use.timeout_ms, 8_000);
13046 assert!(parsed.computer_use.allow_remote_endpoint);
13047 assert_eq!(parsed.computer_use.window_allowlist.len(), 2);
13048 assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840));
13049 assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160));
13050 }
13051
13052 #[test]
13053 async fn browser_config_backward_compat_missing_section() {
13054 let minimal = r#"
13055workspace_dir = "/tmp/ws"
13056config_path = "/tmp/config.toml"
13057default_temperature = 0.7
13058"#;
13059 let parsed = parse_test_config(minimal);
13060 assert!(parsed.browser.enabled);
13061 assert_eq!(parsed.browser.allowed_domains, vec!["*".to_string()]);
13062 }
13063
13064 async fn env_override_lock() -> MutexGuard<'static, ()> {
13067 static ENV_OVERRIDE_TEST_LOCK: Mutex<()> = Mutex::const_new(());
13068 ENV_OVERRIDE_TEST_LOCK.lock().await
13069 }
13070
13071 fn clear_proxy_env_test_vars() {
13072 for key in [
13073 "CONSTRUCT_PROXY_ENABLED",
13074 "CONSTRUCT_HTTP_PROXY",
13075 "CONSTRUCT_HTTPS_PROXY",
13076 "CONSTRUCT_ALL_PROXY",
13077 "CONSTRUCT_NO_PROXY",
13078 "CONSTRUCT_PROXY_SCOPE",
13079 "CONSTRUCT_PROXY_SERVICES",
13080 "HTTP_PROXY",
13081 "HTTPS_PROXY",
13082 "ALL_PROXY",
13083 "NO_PROXY",
13084 "http_proxy",
13085 "https_proxy",
13086 "all_proxy",
13087 "no_proxy",
13088 ] {
13089 unsafe { std::env::remove_var(key) };
13091 }
13092 }
13093
13094 #[test]
13095 async fn env_override_api_key() {
13096 let _env_guard = env_override_lock().await;
13097 let mut config = Config::default();
13098 assert!(config.api_key.is_none());
13099
13100 unsafe { std::env::set_var("CONSTRUCT_API_KEY", "sk-test-env-key") };
13102 config.apply_env_overrides();
13103 assert_eq!(config.api_key.as_deref(), Some("sk-test-env-key"));
13104
13105 unsafe { std::env::remove_var("CONSTRUCT_API_KEY") };
13107 }
13108
13109 #[test]
13110 async fn env_override_api_key_fallback() {
13111 let _env_guard = env_override_lock().await;
13112 let mut config = Config::default();
13113
13114 unsafe { std::env::remove_var("CONSTRUCT_API_KEY") };
13116 unsafe { std::env::set_var("API_KEY", "sk-fallback-key") };
13118 config.apply_env_overrides();
13119 assert_eq!(config.api_key.as_deref(), Some("sk-fallback-key"));
13120
13121 unsafe { std::env::remove_var("API_KEY") };
13123 }
13124
13125 #[test]
13126 async fn env_override_provider() {
13127 let _env_guard = env_override_lock().await;
13128 let mut config = Config::default();
13129
13130 unsafe { std::env::set_var("CONSTRUCT_PROVIDER", "anthropic") };
13132 config.apply_env_overrides();
13133 assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
13134
13135 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13137 }
13138
13139 #[test]
13140 async fn env_override_model_provider_alias() {
13141 let _env_guard = env_override_lock().await;
13142 let mut config = Config::default();
13143
13144 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13146 unsafe { std::env::set_var("CONSTRUCT_MODEL_PROVIDER", "openai-codex") };
13148 config.apply_env_overrides();
13149 assert_eq!(config.default_provider.as_deref(), Some("openai-codex"));
13150
13151 unsafe { std::env::remove_var("CONSTRUCT_MODEL_PROVIDER") };
13153 }
13154
13155 #[test]
13156 async fn toml_supports_model_provider_and_model_alias_fields() {
13157 let raw = r#"
13158default_temperature = 0.7
13159model_provider = "sub2api"
13160model = "gpt-5.3-codex"
13161
13162[model_providers.sub2api]
13163name = "sub2api"
13164base_url = "https://api.tonsof.blue/v1"
13165wire_api = "responses"
13166requires_openai_auth = true
13167"#;
13168
13169 let parsed = parse_test_config(raw);
13170 assert_eq!(parsed.default_provider.as_deref(), Some("sub2api"));
13171 assert_eq!(parsed.default_model.as_deref(), Some("gpt-5.3-codex"));
13172 let profile = parsed
13173 .model_providers
13174 .get("sub2api")
13175 .expect("profile should exist");
13176 assert_eq!(profile.wire_api.as_deref(), Some("responses"));
13177 assert!(profile.requires_openai_auth);
13178 }
13179
13180 #[test]
13181 async fn env_override_open_skills_enabled_and_dir() {
13182 let _env_guard = env_override_lock().await;
13183 let mut config = Config::default();
13184 assert!(!config.skills.open_skills_enabled);
13185 assert!(config.skills.open_skills_dir.is_none());
13186 assert_eq!(
13187 config.skills.prompt_injection_mode,
13188 SkillsPromptInjectionMode::Full
13189 );
13190
13191 unsafe { std::env::set_var("CONSTRUCT_OPEN_SKILLS_ENABLED", "true") };
13193 unsafe { std::env::set_var("CONSTRUCT_OPEN_SKILLS_DIR", "/tmp/open-skills") };
13195 unsafe { std::env::set_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS", "yes") };
13197 unsafe { std::env::set_var("CONSTRUCT_SKILLS_PROMPT_MODE", "compact") };
13199 config.apply_env_overrides();
13200
13201 assert!(config.skills.open_skills_enabled);
13202 assert!(config.skills.allow_scripts);
13203 assert_eq!(
13204 config.skills.open_skills_dir.as_deref(),
13205 Some("/tmp/open-skills")
13206 );
13207 assert_eq!(
13208 config.skills.prompt_injection_mode,
13209 SkillsPromptInjectionMode::Compact
13210 );
13211
13212 unsafe { std::env::remove_var("CONSTRUCT_OPEN_SKILLS_ENABLED") };
13214 unsafe { std::env::remove_var("CONSTRUCT_OPEN_SKILLS_DIR") };
13216 unsafe { std::env::remove_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS") };
13218 unsafe { std::env::remove_var("CONSTRUCT_SKILLS_PROMPT_MODE") };
13220 }
13221
13222 #[test]
13223 async fn env_override_open_skills_enabled_invalid_value_keeps_existing_value() {
13224 let _env_guard = env_override_lock().await;
13225 let mut config = Config::default();
13226 config.skills.open_skills_enabled = true;
13227 config.skills.allow_scripts = true;
13228 config.skills.prompt_injection_mode = SkillsPromptInjectionMode::Compact;
13229
13230 unsafe { std::env::set_var("CONSTRUCT_OPEN_SKILLS_ENABLED", "maybe") };
13232 unsafe { std::env::set_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS", "maybe") };
13234 unsafe { std::env::set_var("CONSTRUCT_SKILLS_PROMPT_MODE", "invalid") };
13236 config.apply_env_overrides();
13237
13238 assert!(config.skills.open_skills_enabled);
13239 assert!(config.skills.allow_scripts);
13240 assert_eq!(
13241 config.skills.prompt_injection_mode,
13242 SkillsPromptInjectionMode::Compact
13243 );
13244 unsafe { std::env::remove_var("CONSTRUCT_OPEN_SKILLS_ENABLED") };
13246 unsafe { std::env::remove_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS") };
13248 unsafe { std::env::remove_var("CONSTRUCT_SKILLS_PROMPT_MODE") };
13250 }
13251
13252 #[test]
13253 async fn env_override_provider_fallback() {
13254 let _env_guard = env_override_lock().await;
13255 let mut config = Config::default();
13256
13257 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13259 unsafe { std::env::set_var("PROVIDER", "openai") };
13261 config.apply_env_overrides();
13262 assert_eq!(config.default_provider.as_deref(), Some("openai"));
13263
13264 unsafe { std::env::remove_var("PROVIDER") };
13266 }
13267
13268 #[test]
13269 async fn env_override_provider_fallback_does_not_replace_non_default_provider() {
13270 let _env_guard = env_override_lock().await;
13271 let mut config = Config {
13272 default_provider: Some("custom:https://proxy.example.com/v1".to_string()),
13273 ..Config::default()
13274 };
13275
13276 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13278 unsafe { std::env::set_var("PROVIDER", "openrouter") };
13280 config.apply_env_overrides();
13281 assert_eq!(
13282 config.default_provider.as_deref(),
13283 Some("custom:https://proxy.example.com/v1")
13284 );
13285
13286 unsafe { std::env::remove_var("PROVIDER") };
13288 }
13289
13290 #[test]
13291 async fn env_override_zero_claw_provider_overrides_non_default_provider() {
13292 let _env_guard = env_override_lock().await;
13293 let mut config = Config {
13294 default_provider: Some("custom:https://proxy.example.com/v1".to_string()),
13295 ..Config::default()
13296 };
13297
13298 unsafe { std::env::set_var("CONSTRUCT_PROVIDER", "openrouter") };
13300 unsafe { std::env::set_var("PROVIDER", "anthropic") };
13302 config.apply_env_overrides();
13303 assert_eq!(config.default_provider.as_deref(), Some("openrouter"));
13304
13305 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13307 unsafe { std::env::remove_var("PROVIDER") };
13309 }
13310
13311 #[test]
13312 async fn env_override_glm_api_key_for_regional_aliases() {
13313 let _env_guard = env_override_lock().await;
13314 let mut config = Config {
13315 default_provider: Some("glm-cn".to_string()),
13316 ..Config::default()
13317 };
13318
13319 unsafe { std::env::set_var("GLM_API_KEY", "glm-regional-key") };
13321 config.apply_env_overrides();
13322 assert_eq!(config.api_key.as_deref(), Some("glm-regional-key"));
13323
13324 unsafe { std::env::remove_var("GLM_API_KEY") };
13326 }
13327
13328 #[test]
13329 async fn env_override_zai_api_key_for_regional_aliases() {
13330 let _env_guard = env_override_lock().await;
13331 let mut config = Config {
13332 default_provider: Some("zai-cn".to_string()),
13333 ..Config::default()
13334 };
13335
13336 unsafe { std::env::set_var("ZAI_API_KEY", "zai-regional-key") };
13338 config.apply_env_overrides();
13339 assert_eq!(config.api_key.as_deref(), Some("zai-regional-key"));
13340
13341 unsafe { std::env::remove_var("ZAI_API_KEY") };
13343 }
13344
13345 #[test]
13346 async fn env_override_model() {
13347 let _env_guard = env_override_lock().await;
13348 let mut config = Config::default();
13349
13350 unsafe { std::env::set_var("CONSTRUCT_MODEL", "gpt-4o") };
13352 config.apply_env_overrides();
13353 assert_eq!(config.default_model.as_deref(), Some("gpt-4o"));
13354
13355 unsafe { std::env::remove_var("CONSTRUCT_MODEL") };
13357 }
13358
13359 #[test]
13360 async fn model_provider_profile_maps_to_custom_endpoint() {
13361 let _env_guard = env_override_lock().await;
13362 let mut config = Config {
13363 default_provider: Some("sub2api".to_string()),
13364 model_providers: HashMap::from([(
13365 "sub2api".to_string(),
13366 ModelProviderConfig {
13367 name: Some("sub2api".to_string()),
13368 base_url: Some("https://api.tonsof.blue/v1".to_string()),
13369 wire_api: None,
13370 requires_openai_auth: false,
13371 azure_openai_resource: None,
13372 azure_openai_deployment: None,
13373 azure_openai_api_version: None,
13374 api_path: None,
13375 max_tokens: None,
13376 },
13377 )]),
13378 ..Config::default()
13379 };
13380
13381 config.apply_env_overrides();
13382 assert_eq!(
13383 config.default_provider.as_deref(),
13384 Some("custom:https://api.tonsof.blue/v1")
13385 );
13386 assert_eq!(
13387 config.api_url.as_deref(),
13388 Some("https://api.tonsof.blue/v1")
13389 );
13390 }
13391
13392 #[test]
13393 async fn model_provider_profile_responses_uses_openai_codex_and_openai_key() {
13394 let _env_guard = env_override_lock().await;
13395 let mut config = Config {
13396 default_provider: Some("sub2api".to_string()),
13397 model_providers: HashMap::from([(
13398 "sub2api".to_string(),
13399 ModelProviderConfig {
13400 name: Some("sub2api".to_string()),
13401 base_url: Some("https://api.tonsof.blue".to_string()),
13402 wire_api: Some("responses".to_string()),
13403 requires_openai_auth: true,
13404 azure_openai_resource: None,
13405 azure_openai_deployment: None,
13406 azure_openai_api_version: None,
13407 api_path: None,
13408 max_tokens: None,
13409 },
13410 )]),
13411 api_key: None,
13412 ..Config::default()
13413 };
13414
13415 unsafe { std::env::set_var("OPENAI_API_KEY", "sk-test-codex-key") };
13417 config.apply_env_overrides();
13418 unsafe { std::env::remove_var("OPENAI_API_KEY") };
13420
13421 assert_eq!(config.default_provider.as_deref(), Some("openai-codex"));
13422 assert_eq!(config.api_url.as_deref(), Some("https://api.tonsof.blue"));
13423 assert_eq!(config.api_key.as_deref(), Some("sk-test-codex-key"));
13424 }
13425
13426 #[test]
13427 async fn save_repairs_bare_config_filename_using_runtime_resolution() {
13428 let _env_guard = env_override_lock().await;
13429 let temp_home =
13430 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13431 let workspace_dir = temp_home.join("workspace");
13432 let resolved_config_path = temp_home.join(".construct").join("config.toml");
13433
13434 let original_home = std::env::var("HOME").ok();
13435 unsafe { std::env::set_var("HOME", &temp_home) };
13437 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13439
13440 let mut config = Config::default();
13441 config.workspace_dir = workspace_dir;
13442 config.config_path = PathBuf::from("config.toml");
13443 config.default_temperature = 0.5;
13444 config.save().await.unwrap();
13445
13446 assert!(resolved_config_path.exists());
13447 let saved = tokio::fs::read_to_string(&resolved_config_path)
13448 .await
13449 .unwrap();
13450 let parsed = parse_test_config(&saved);
13451 assert_eq!(parsed.default_temperature, 0.5);
13452
13453 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13455 if let Some(home) = original_home {
13456 unsafe { std::env::set_var("HOME", home) };
13458 } else {
13459 unsafe { std::env::remove_var("HOME") };
13461 }
13462 let _ = tokio::fs::remove_dir_all(temp_home).await;
13463 }
13464
13465 #[test]
13466 async fn validate_ollama_cloud_model_requires_remote_api_url() {
13467 let _env_guard = env_override_lock().await;
13468 let config = Config {
13469 default_provider: Some("ollama".to_string()),
13470 default_model: Some("glm-5:cloud".to_string()),
13471 api_url: None,
13472 api_key: Some("ollama-key".to_string()),
13473 ..Config::default()
13474 };
13475
13476 let error = config.validate().expect_err("expected validation to fail");
13477 assert!(error.to_string().contains(
13478 "default_model uses ':cloud' with provider 'ollama', but api_url is local or unset"
13479 ));
13480 }
13481
13482 #[test]
13483 async fn validate_ollama_cloud_model_accepts_remote_endpoint_and_env_key() {
13484 let _env_guard = env_override_lock().await;
13485 let config = Config {
13486 default_provider: Some("ollama".to_string()),
13487 default_model: Some("glm-5:cloud".to_string()),
13488 api_url: Some("https://ollama.com/api".to_string()),
13489 api_key: None,
13490 ..Config::default()
13491 };
13492
13493 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-env-key") };
13495 let result = config.validate();
13496 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
13498
13499 assert!(result.is_ok(), "expected validation to pass: {result:?}");
13500 }
13501
13502 #[test]
13503 async fn validate_rejects_unknown_model_provider_wire_api() {
13504 let _env_guard = env_override_lock().await;
13505 let config = Config {
13506 default_provider: Some("sub2api".to_string()),
13507 model_providers: HashMap::from([(
13508 "sub2api".to_string(),
13509 ModelProviderConfig {
13510 name: Some("sub2api".to_string()),
13511 base_url: Some("https://api.tonsof.blue/v1".to_string()),
13512 wire_api: Some("ws".to_string()),
13513 requires_openai_auth: false,
13514 azure_openai_resource: None,
13515 azure_openai_deployment: None,
13516 azure_openai_api_version: None,
13517 api_path: None,
13518 max_tokens: None,
13519 },
13520 )]),
13521 ..Config::default()
13522 };
13523
13524 let error = config.validate().expect_err("expected validation failure");
13525 assert!(
13526 error
13527 .to_string()
13528 .contains("wire_api must be one of: responses, chat_completions")
13529 );
13530 }
13531
13532 #[test]
13533 async fn env_override_model_fallback() {
13534 let _env_guard = env_override_lock().await;
13535 let mut config = Config::default();
13536
13537 unsafe { std::env::remove_var("CONSTRUCT_MODEL") };
13539 unsafe { std::env::set_var("MODEL", "anthropic/claude-3.5-sonnet") };
13541 config.apply_env_overrides();
13542 assert_eq!(
13543 config.default_model.as_deref(),
13544 Some("anthropic/claude-3.5-sonnet")
13545 );
13546
13547 unsafe { std::env::remove_var("MODEL") };
13549 }
13550
13551 #[test]
13552 async fn env_override_workspace() {
13553 let _env_guard = env_override_lock().await;
13554 let mut config = Config::default();
13555
13556 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", "/custom/workspace") };
13558 config.apply_env_overrides();
13559 assert_eq!(config.workspace_dir, PathBuf::from("/custom/workspace"));
13560
13561 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13563 }
13564
13565 #[test]
13566 async fn resolve_runtime_config_dirs_uses_env_workspace_first() {
13567 let _env_guard = env_override_lock().await;
13568 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13569 let default_workspace_dir = default_config_dir.join("workspace");
13570 let workspace_dir = default_config_dir.join("profile-a");
13571
13572 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13574 let (config_dir, resolved_workspace_dir, source) =
13575 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13576 .await
13577 .unwrap();
13578
13579 assert_eq!(source, ConfigResolutionSource::EnvWorkspace);
13580 assert_eq!(config_dir, workspace_dir);
13581 assert_eq!(resolved_workspace_dir, workspace_dir.join("workspace"));
13582
13583 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13585 let _ = fs::remove_dir_all(default_config_dir).await;
13586 }
13587
13588 #[test]
13589 async fn resolve_runtime_config_dirs_uses_env_config_dir_first() {
13590 let _env_guard = env_override_lock().await;
13591 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13592 let default_workspace_dir = default_config_dir.join("workspace");
13593 let explicit_config_dir = default_config_dir.join("explicit-config");
13594 let marker_config_dir = default_config_dir.join("profiles").join("alpha");
13595 let state_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
13596
13597 fs::create_dir_all(&default_config_dir).await.unwrap();
13598 let state = ActiveWorkspaceState {
13599 config_dir: marker_config_dir.to_string_lossy().into_owned(),
13600 };
13601 fs::write(&state_path, toml::to_string(&state).unwrap())
13602 .await
13603 .unwrap();
13604
13605 unsafe { std::env::set_var("CONSTRUCT_CONFIG_DIR", &explicit_config_dir) };
13607 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13609
13610 let (config_dir, resolved_workspace_dir, source) =
13611 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13612 .await
13613 .unwrap();
13614
13615 assert_eq!(source, ConfigResolutionSource::EnvConfigDir);
13616 assert_eq!(config_dir, explicit_config_dir);
13617 assert_eq!(
13618 resolved_workspace_dir,
13619 explicit_config_dir.join("workspace")
13620 );
13621
13622 unsafe { std::env::remove_var("CONSTRUCT_CONFIG_DIR") };
13624 let _ = fs::remove_dir_all(default_config_dir).await;
13625 }
13626
13627 #[test]
13628 async fn resolve_runtime_config_dirs_uses_active_workspace_marker() {
13629 let _env_guard = env_override_lock().await;
13630 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13631 let default_workspace_dir = default_config_dir.join("workspace");
13632 let marker_config_dir = default_config_dir.join("profiles").join("alpha");
13633 let state_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
13634
13635 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13637 fs::create_dir_all(&default_config_dir).await.unwrap();
13638 let state = ActiveWorkspaceState {
13639 config_dir: marker_config_dir.to_string_lossy().into_owned(),
13640 };
13641 fs::write(&state_path, toml::to_string(&state).unwrap())
13642 .await
13643 .unwrap();
13644
13645 let (config_dir, resolved_workspace_dir, source) =
13646 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13647 .await
13648 .unwrap();
13649
13650 assert_eq!(source, ConfigResolutionSource::ActiveWorkspaceMarker);
13651 assert_eq!(config_dir, marker_config_dir);
13652 assert_eq!(resolved_workspace_dir, marker_config_dir.join("workspace"));
13653
13654 let _ = fs::remove_dir_all(default_config_dir).await;
13655 }
13656
13657 #[test]
13658 async fn resolve_runtime_config_dirs_falls_back_to_default_layout() {
13659 let _env_guard = env_override_lock().await;
13660 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13661 let default_workspace_dir = default_config_dir.join("workspace");
13662
13663 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13665 let (config_dir, resolved_workspace_dir, source) =
13666 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13667 .await
13668 .unwrap();
13669
13670 assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
13671 assert_eq!(config_dir, default_config_dir);
13672 assert_eq!(resolved_workspace_dir, default_workspace_dir);
13673
13674 let _ = fs::remove_dir_all(default_config_dir).await;
13675 }
13676
13677 #[test]
13678 async fn load_or_init_workspace_override_uses_workspace_root_for_config() {
13679 let _env_guard = env_override_lock().await;
13680 let temp_home =
13681 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13682 let workspace_dir = temp_home.join("profile-a");
13683
13684 let original_home = std::env::var("HOME").ok();
13685 unsafe { std::env::set_var("HOME", &temp_home) };
13687 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13689
13690 let config = Box::pin(Config::load_or_init()).await.unwrap();
13691
13692 assert_eq!(config.workspace_dir, workspace_dir.join("workspace"));
13693 assert_eq!(config.config_path, workspace_dir.join("config.toml"));
13694 assert!(workspace_dir.join("config.toml").exists());
13695
13696 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13698 if let Some(home) = original_home {
13699 unsafe { std::env::set_var("HOME", home) };
13701 } else {
13702 unsafe { std::env::remove_var("HOME") };
13704 }
13705 let _ = fs::remove_dir_all(temp_home).await;
13706 }
13707
13708 #[test]
13709 async fn load_or_init_workspace_suffix_uses_legacy_config_layout() {
13710 let _env_guard = env_override_lock().await;
13711 let temp_home =
13712 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13713 let workspace_dir = temp_home.join("workspace");
13714 let legacy_config_path = temp_home.join(".construct").join("config.toml");
13715
13716 let original_home = std::env::var("HOME").ok();
13717 unsafe { std::env::set_var("HOME", &temp_home) };
13719 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13721
13722 let config = Box::pin(Config::load_or_init()).await.unwrap();
13723
13724 assert_eq!(config.workspace_dir, workspace_dir);
13725 assert_eq!(config.config_path, legacy_config_path);
13726 assert!(config.config_path.exists());
13727
13728 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13730 if let Some(home) = original_home {
13731 unsafe { std::env::set_var("HOME", home) };
13733 } else {
13734 unsafe { std::env::remove_var("HOME") };
13736 }
13737 let _ = fs::remove_dir_all(temp_home).await;
13738 }
13739
13740 #[test]
13741 async fn load_or_init_workspace_override_keeps_existing_legacy_config() {
13742 let _env_guard = env_override_lock().await;
13743 let temp_home =
13744 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13745 let workspace_dir = temp_home.join("custom-workspace");
13746 let legacy_config_dir = temp_home.join(".construct");
13747 let legacy_config_path = legacy_config_dir.join("config.toml");
13748
13749 fs::create_dir_all(&legacy_config_dir).await.unwrap();
13750 fs::write(
13751 &legacy_config_path,
13752 r#"default_temperature = 0.7
13753default_model = "legacy-model"
13754"#,
13755 )
13756 .await
13757 .unwrap();
13758
13759 let original_home = std::env::var("HOME").ok();
13760 unsafe { std::env::set_var("HOME", &temp_home) };
13762 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13764
13765 let config = Box::pin(Config::load_or_init()).await.unwrap();
13766
13767 assert_eq!(config.workspace_dir, workspace_dir);
13768 assert_eq!(config.config_path, legacy_config_path);
13769 assert_eq!(config.default_model.as_deref(), Some("legacy-model"));
13770
13771 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13773 if let Some(home) = original_home {
13774 unsafe { std::env::set_var("HOME", home) };
13776 } else {
13777 unsafe { std::env::remove_var("HOME") };
13779 }
13780 let _ = fs::remove_dir_all(temp_home).await;
13781 }
13782
13783 #[test]
13784 async fn load_or_init_decrypts_feishu_channel_secrets() {
13785 let _env_guard = env_override_lock().await;
13786 let temp_home =
13787 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13788 let config_dir = temp_home.join(".construct");
13789 let config_path = config_dir.join("config.toml");
13790
13791 fs::create_dir_all(&config_dir).await.unwrap();
13792
13793 let original_home = std::env::var("HOME").ok();
13794 unsafe { std::env::set_var("HOME", &temp_home) };
13796 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13798
13799 let mut config = Config::default();
13800 config.config_path = config_path.clone();
13801 config.workspace_dir = config_dir.join("workspace");
13802 config.secrets.encrypt = true;
13803 config.channels_config.feishu = Some(FeishuConfig {
13804 app_id: "cli_feishu_123".into(),
13805 app_secret: "feishu-secret".into(),
13806 encrypt_key: Some("feishu-encrypt".into()),
13807 verification_token: Some("feishu-verify".into()),
13808 allowed_users: vec!["*".into()],
13809 receive_mode: LarkReceiveMode::Websocket,
13810 port: None,
13811 proxy_url: None,
13812 });
13813 config.save().await.unwrap();
13814
13815 let loaded = Box::pin(Config::load_or_init()).await.unwrap();
13816 let feishu = loaded.channels_config.feishu.as_ref().unwrap();
13817 assert_eq!(feishu.app_secret, "feishu-secret");
13818 assert_eq!(feishu.encrypt_key.as_deref(), Some("feishu-encrypt"));
13819 assert_eq!(feishu.verification_token.as_deref(), Some("feishu-verify"));
13820
13821 if let Some(home) = original_home {
13822 unsafe { std::env::set_var("HOME", home) };
13824 } else {
13825 unsafe { std::env::remove_var("HOME") };
13827 }
13828 let _ = fs::remove_dir_all(temp_home).await;
13829 }
13830
13831 #[test]
13832 async fn load_or_init_uses_persisted_active_workspace_marker() {
13833 let _env_guard = env_override_lock().await;
13834 let temp_home =
13835 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13836 let temp_default_dir = temp_home.join(".construct");
13837 let custom_config_dir = temp_home.join("profiles").join("agent-alpha");
13838
13839 fs::create_dir_all(&custom_config_dir).await.unwrap();
13840 fs::create_dir_all(&temp_default_dir).await.unwrap();
13844 fs::write(
13845 custom_config_dir.join("config.toml"),
13846 "default_temperature = 0.7\ndefault_model = \"persisted-profile\"\n",
13847 )
13848 .await
13849 .unwrap();
13850
13851 persist_active_workspace_config_dir_in(&custom_config_dir, &temp_default_dir)
13854 .await
13855 .unwrap();
13856
13857 let original_home = std::env::var("HOME").ok();
13861 unsafe { std::env::set_var("HOME", &temp_home) };
13863 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13865
13866 let config = Box::pin(Config::load_or_init()).await.unwrap();
13867
13868 assert_eq!(config.config_path, custom_config_dir.join("config.toml"));
13869 assert_eq!(config.workspace_dir, custom_config_dir.join("workspace"));
13870 assert_eq!(config.default_model.as_deref(), Some("persisted-profile"));
13871
13872 if let Some(home) = original_home {
13873 unsafe { std::env::set_var("HOME", home) };
13875 } else {
13876 unsafe { std::env::remove_var("HOME") };
13878 }
13879 let _ = fs::remove_dir_all(temp_home).await;
13880 }
13881
13882 #[test]
13883 async fn load_or_init_env_workspace_override_takes_priority_over_marker() {
13884 let _env_guard = env_override_lock().await;
13885 let temp_home =
13886 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13887 let temp_default_dir = temp_home.join(".construct");
13888 let marker_config_dir = temp_home.join("profiles").join("persisted-profile");
13889 let env_workspace_dir = temp_home.join("env-workspace");
13890
13891 fs::create_dir_all(&marker_config_dir).await.unwrap();
13892 fs::write(
13893 marker_config_dir.join("config.toml"),
13894 "default_temperature = 0.7\ndefault_model = \"marker-model\"\n",
13895 )
13896 .await
13897 .unwrap();
13898
13899 persist_active_workspace_config_dir_in(&marker_config_dir, &temp_default_dir)
13901 .await
13902 .unwrap();
13903
13904 let original_home = std::env::var("HOME").ok();
13905 unsafe { std::env::set_var("HOME", &temp_home) };
13907 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &env_workspace_dir) };
13909
13910 let config = Box::pin(Config::load_or_init()).await.unwrap();
13911
13912 assert_eq!(config.workspace_dir, env_workspace_dir.join("workspace"));
13913 assert_eq!(config.config_path, env_workspace_dir.join("config.toml"));
13914
13915 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13917 if let Some(home) = original_home {
13918 unsafe { std::env::set_var("HOME", home) };
13920 } else {
13921 unsafe { std::env::remove_var("HOME") };
13923 }
13924 let _ = fs::remove_dir_all(temp_home).await;
13925 }
13926
13927 #[test]
13928 async fn persist_active_workspace_marker_is_cleared_for_default_config_dir() {
13929 let temp_home =
13930 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13931 let default_config_dir = temp_home.join(".construct");
13932 let custom_config_dir = temp_home.join("profiles").join("custom-profile");
13933 let marker_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
13934
13935 persist_active_workspace_config_dir_in(&custom_config_dir, &default_config_dir)
13938 .await
13939 .unwrap();
13940 assert!(marker_path.exists());
13941
13942 persist_active_workspace_config_dir_in(&default_config_dir, &default_config_dir)
13943 .await
13944 .unwrap();
13945 assert!(!marker_path.exists());
13946
13947 let _ = fs::remove_dir_all(temp_home).await;
13948 }
13949
13950 #[test]
13951 #[allow(clippy::large_futures)]
13952 async fn load_or_init_logs_existing_config_as_initialized() {
13953 let _env_guard = env_override_lock().await;
13954 let temp_home =
13955 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13956 let workspace_dir = temp_home.join("profile-a");
13957 let config_path = workspace_dir.join("config.toml");
13958
13959 fs::create_dir_all(&workspace_dir).await.unwrap();
13960 fs::write(
13961 &config_path,
13962 r#"default_temperature = 0.7
13963default_model = "persisted-profile"
13964"#,
13965 )
13966 .await
13967 .unwrap();
13968
13969 let original_home = std::env::var("HOME").ok();
13970 unsafe { std::env::set_var("HOME", &temp_home) };
13972 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13974
13975 let capture = SharedLogBuffer::default();
13976 let subscriber = tracing_subscriber::fmt()
13977 .with_ansi(false)
13978 .without_time()
13979 .with_target(false)
13980 .with_writer(capture.clone())
13981 .finish();
13982 let dispatch = tracing::Dispatch::new(subscriber);
13983 let guard = tracing::dispatcher::set_default(&dispatch);
13984
13985 let config = Box::pin(Config::load_or_init()).await.unwrap();
13986
13987 drop(guard);
13988 let logs = capture.captured();
13989
13990 assert_eq!(config.workspace_dir, workspace_dir.join("workspace"));
13991 assert_eq!(config.config_path, config_path);
13992 assert_eq!(config.default_model.as_deref(), Some("persisted-profile"));
13993 assert!(logs.contains("Config loaded"), "{logs}");
13994 assert!(logs.contains("initialized=true"), "{logs}");
13995 assert!(!logs.contains("initialized=false"), "{logs}");
13996
13997 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13999 if let Some(home) = original_home {
14000 unsafe { std::env::set_var("HOME", home) };
14002 } else {
14003 unsafe { std::env::remove_var("HOME") };
14005 }
14006 let _ = fs::remove_dir_all(temp_home).await;
14007 }
14008
14009 #[test]
14010 async fn env_override_empty_values_ignored() {
14011 let _env_guard = env_override_lock().await;
14012 let mut config = Config::default();
14013 let original_provider = config.default_provider.clone();
14014
14015 unsafe { std::env::set_var("CONSTRUCT_PROVIDER", "") };
14017 config.apply_env_overrides();
14018 assert_eq!(config.default_provider, original_provider);
14019
14020 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
14022 }
14023
14024 #[test]
14025 async fn env_override_gateway_port() {
14026 let _env_guard = env_override_lock().await;
14027 let mut config = Config::default();
14028 assert_eq!(config.gateway.port, 42617);
14029
14030 unsafe { std::env::set_var("CONSTRUCT_GATEWAY_PORT", "8080") };
14032 config.apply_env_overrides();
14033 assert_eq!(config.gateway.port, 8080);
14034
14035 unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_PORT") };
14037 }
14038
14039 #[test]
14040 async fn env_override_port_fallback() {
14041 let _env_guard = env_override_lock().await;
14042 let mut config = Config::default();
14043
14044 unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_PORT") };
14046 unsafe { std::env::set_var("PORT", "9000") };
14048 config.apply_env_overrides();
14049 assert_eq!(config.gateway.port, 9000);
14050
14051 unsafe { std::env::remove_var("PORT") };
14053 }
14054
14055 #[test]
14056 async fn env_override_gateway_host() {
14057 let _env_guard = env_override_lock().await;
14058 let mut config = Config::default();
14059 assert_eq!(config.gateway.host, "127.0.0.1");
14060
14061 unsafe { std::env::set_var("CONSTRUCT_GATEWAY_HOST", "0.0.0.0") };
14063 config.apply_env_overrides();
14064 assert_eq!(config.gateway.host, "0.0.0.0");
14065
14066 unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_HOST") };
14068 }
14069
14070 #[test]
14071 async fn env_override_host_fallback() {
14072 let _env_guard = env_override_lock().await;
14073 let mut config = Config::default();
14074
14075 unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_HOST") };
14077 unsafe { std::env::set_var("HOST", "0.0.0.0") };
14079 config.apply_env_overrides();
14080 assert_eq!(config.gateway.host, "0.0.0.0");
14081
14082 unsafe { std::env::remove_var("HOST") };
14084 }
14085
14086 #[test]
14087 async fn env_override_require_pairing() {
14088 let _env_guard = env_override_lock().await;
14089 let mut config = Config::default();
14090 assert!(config.gateway.require_pairing);
14091
14092 unsafe { std::env::set_var("CONSTRUCT_REQUIRE_PAIRING", "false") };
14094 config.apply_env_overrides();
14095 assert!(!config.gateway.require_pairing);
14096
14097 unsafe { std::env::set_var("CONSTRUCT_REQUIRE_PAIRING", "true") };
14099 config.apply_env_overrides();
14100 assert!(config.gateway.require_pairing);
14101
14102 unsafe { std::env::remove_var("CONSTRUCT_REQUIRE_PAIRING") };
14104 }
14105
14106 #[test]
14107 async fn env_override_temperature() {
14108 let _env_guard = env_override_lock().await;
14109 let mut config = Config::default();
14110
14111 unsafe { std::env::set_var("CONSTRUCT_TEMPERATURE", "0.5") };
14113 config.apply_env_overrides();
14114 assert!((config.default_temperature - 0.5).abs() < f64::EPSILON);
14115
14116 unsafe { std::env::remove_var("CONSTRUCT_TEMPERATURE") };
14118 }
14119
14120 #[test]
14121 async fn env_override_temperature_out_of_range_ignored() {
14122 let _env_guard = env_override_lock().await;
14123 unsafe { std::env::remove_var("CONSTRUCT_TEMPERATURE") };
14126
14127 let mut config = Config::default();
14128 let original_temp = config.default_temperature;
14129
14130 unsafe { std::env::set_var("CONSTRUCT_TEMPERATURE", "3.0") };
14133 config.apply_env_overrides();
14134 assert!(
14135 (config.default_temperature - original_temp).abs() < f64::EPSILON,
14136 "Temperature 3.0 should be ignored (out of range)"
14137 );
14138
14139 unsafe { std::env::remove_var("CONSTRUCT_TEMPERATURE") };
14141 }
14142
14143 #[test]
14144 async fn env_override_reasoning_enabled() {
14145 let _env_guard = env_override_lock().await;
14146 let mut config = Config::default();
14147 assert_eq!(config.runtime.reasoning_enabled, None);
14148
14149 unsafe { std::env::set_var("CONSTRUCT_REASONING_ENABLED", "false") };
14151 config.apply_env_overrides();
14152 assert_eq!(config.runtime.reasoning_enabled, Some(false));
14153
14154 unsafe { std::env::set_var("CONSTRUCT_REASONING_ENABLED", "true") };
14156 config.apply_env_overrides();
14157 assert_eq!(config.runtime.reasoning_enabled, Some(true));
14158
14159 unsafe { std::env::remove_var("CONSTRUCT_REASONING_ENABLED") };
14161 }
14162
14163 #[test]
14164 async fn env_override_reasoning_invalid_value_ignored() {
14165 let _env_guard = env_override_lock().await;
14166 let mut config = Config::default();
14167 config.runtime.reasoning_enabled = Some(false);
14168
14169 unsafe { std::env::set_var("CONSTRUCT_REASONING_ENABLED", "maybe") };
14171 config.apply_env_overrides();
14172 assert_eq!(config.runtime.reasoning_enabled, Some(false));
14173
14174 unsafe { std::env::remove_var("CONSTRUCT_REASONING_ENABLED") };
14176 }
14177
14178 #[test]
14179 async fn env_override_reasoning_effort() {
14180 let _env_guard = env_override_lock().await;
14181 let mut config = Config::default();
14182 assert_eq!(config.runtime.reasoning_effort, None);
14183
14184 unsafe { std::env::set_var("CONSTRUCT_REASONING_EFFORT", "HIGH") };
14186 config.apply_env_overrides();
14187 assert_eq!(config.runtime.reasoning_effort.as_deref(), Some("high"));
14188
14189 unsafe { std::env::remove_var("CONSTRUCT_REASONING_EFFORT") };
14191 }
14192
14193 #[test]
14194 async fn env_override_reasoning_effort_legacy_codex_env() {
14195 let _env_guard = env_override_lock().await;
14196 let mut config = Config::default();
14197
14198 unsafe { std::env::set_var("CONSTRUCT_CODEX_REASONING_EFFORT", "minimal") };
14200 config.apply_env_overrides();
14201 assert_eq!(config.runtime.reasoning_effort.as_deref(), Some("minimal"));
14202
14203 unsafe { std::env::remove_var("CONSTRUCT_CODEX_REASONING_EFFORT") };
14205 }
14206
14207 #[test]
14208 async fn env_override_invalid_port_ignored() {
14209 let _env_guard = env_override_lock().await;
14210 let mut config = Config::default();
14211 let original_port = config.gateway.port;
14212
14213 unsafe { std::env::set_var("PORT", "not_a_number") };
14215 config.apply_env_overrides();
14216 assert_eq!(config.gateway.port, original_port);
14217
14218 unsafe { std::env::remove_var("PORT") };
14220 }
14221
14222 #[test]
14223 async fn env_override_web_search_config() {
14224 let _env_guard = env_override_lock().await;
14225 let mut config = Config::default();
14226
14227 unsafe { std::env::set_var("WEB_SEARCH_ENABLED", "false") };
14229 unsafe { std::env::set_var("WEB_SEARCH_PROVIDER", "brave") };
14231 unsafe { std::env::set_var("WEB_SEARCH_MAX_RESULTS", "7") };
14233 unsafe { std::env::set_var("WEB_SEARCH_TIMEOUT_SECS", "20") };
14235 unsafe { std::env::set_var("BRAVE_API_KEY", "brave-test-key") };
14237
14238 config.apply_env_overrides();
14239
14240 assert!(!config.web_search.enabled);
14241 assert_eq!(config.web_search.provider, "brave");
14242 assert_eq!(config.web_search.max_results, 7);
14243 assert_eq!(config.web_search.timeout_secs, 20);
14244 assert_eq!(
14245 config.web_search.brave_api_key.as_deref(),
14246 Some("brave-test-key")
14247 );
14248
14249 unsafe { std::env::remove_var("WEB_SEARCH_ENABLED") };
14251 unsafe { std::env::remove_var("WEB_SEARCH_PROVIDER") };
14253 unsafe { std::env::remove_var("WEB_SEARCH_MAX_RESULTS") };
14255 unsafe { std::env::remove_var("WEB_SEARCH_TIMEOUT_SECS") };
14257 unsafe { std::env::remove_var("BRAVE_API_KEY") };
14259 }
14260
14261 #[test]
14262 async fn env_override_web_search_invalid_values_ignored() {
14263 let _env_guard = env_override_lock().await;
14264 let mut config = Config::default();
14265 let original_max_results = config.web_search.max_results;
14266 let original_timeout = config.web_search.timeout_secs;
14267
14268 unsafe { std::env::set_var("WEB_SEARCH_MAX_RESULTS", "99") };
14270 unsafe { std::env::set_var("WEB_SEARCH_TIMEOUT_SECS", "0") };
14272
14273 config.apply_env_overrides();
14274
14275 assert_eq!(config.web_search.max_results, original_max_results);
14276 assert_eq!(config.web_search.timeout_secs, original_timeout);
14277
14278 unsafe { std::env::remove_var("WEB_SEARCH_MAX_RESULTS") };
14280 unsafe { std::env::remove_var("WEB_SEARCH_TIMEOUT_SECS") };
14282 }
14283
14284 #[test]
14285 async fn env_override_storage_provider_config() {
14286 let _env_guard = env_override_lock().await;
14287 let mut config = Config::default();
14288
14289 unsafe { std::env::set_var("CONSTRUCT_STORAGE_PROVIDER", "qdrant") };
14291 unsafe { std::env::set_var("CONSTRUCT_STORAGE_DB_URL", "http://localhost:6333") };
14293 unsafe { std::env::set_var("CONSTRUCT_STORAGE_CONNECT_TIMEOUT_SECS", "15") };
14295
14296 config.apply_env_overrides();
14297
14298 assert_eq!(config.storage.provider.config.provider, "qdrant");
14299 assert_eq!(
14300 config.storage.provider.config.db_url.as_deref(),
14301 Some("http://localhost:6333")
14302 );
14303 assert_eq!(
14304 config.storage.provider.config.connect_timeout_secs,
14305 Some(15)
14306 );
14307
14308 unsafe { std::env::remove_var("CONSTRUCT_STORAGE_PROVIDER") };
14310 unsafe { std::env::remove_var("CONSTRUCT_STORAGE_DB_URL") };
14312 unsafe { std::env::remove_var("CONSTRUCT_STORAGE_CONNECT_TIMEOUT_SECS") };
14314 }
14315
14316 #[test]
14317 async fn proxy_config_scope_services_requires_entries_when_enabled() {
14318 let proxy = ProxyConfig {
14319 enabled: true,
14320 http_proxy: Some("http://127.0.0.1:7890".into()),
14321 https_proxy: None,
14322 all_proxy: None,
14323 no_proxy: Vec::new(),
14324 scope: ProxyScope::Services,
14325 services: Vec::new(),
14326 };
14327
14328 let error = proxy.validate().unwrap_err().to_string();
14329 assert!(error.contains("proxy.scope='services'"));
14330 }
14331
14332 #[test]
14333 async fn env_override_proxy_scope_services() {
14334 let _env_guard = env_override_lock().await;
14335 clear_proxy_env_test_vars();
14336
14337 let mut config = Config::default();
14338 unsafe { std::env::set_var("CONSTRUCT_PROXY_ENABLED", "true") };
14340 unsafe { std::env::set_var("CONSTRUCT_HTTP_PROXY", "http://127.0.0.1:7890") };
14342 unsafe {
14344 std::env::set_var(
14345 "CONSTRUCT_PROXY_SERVICES",
14346 "provider.openai, tool.http_request",
14347 );
14348 }
14349 unsafe { std::env::set_var("CONSTRUCT_PROXY_SCOPE", "services") };
14351
14352 config.apply_env_overrides();
14353
14354 assert!(config.proxy.enabled);
14355 assert_eq!(config.proxy.scope, ProxyScope::Services);
14356 assert_eq!(
14357 config.proxy.http_proxy.as_deref(),
14358 Some("http://127.0.0.1:7890")
14359 );
14360 assert!(config.proxy.should_apply_to_service("provider.openai"));
14361 assert!(config.proxy.should_apply_to_service("tool.http_request"));
14362 assert!(!config.proxy.should_apply_to_service("provider.anthropic"));
14363
14364 clear_proxy_env_test_vars();
14365 }
14366
14367 #[test]
14368 async fn env_override_proxy_scope_environment_applies_process_env() {
14369 let _env_guard = env_override_lock().await;
14370 clear_proxy_env_test_vars();
14371
14372 let mut config = Config::default();
14373 unsafe { std::env::set_var("CONSTRUCT_PROXY_ENABLED", "true") };
14375 unsafe { std::env::set_var("CONSTRUCT_PROXY_SCOPE", "environment") };
14377 unsafe { std::env::set_var("CONSTRUCT_HTTP_PROXY", "http://127.0.0.1:7890") };
14379 unsafe { std::env::set_var("CONSTRUCT_HTTPS_PROXY", "http://127.0.0.1:7891") };
14381 unsafe { std::env::set_var("CONSTRUCT_NO_PROXY", "localhost,127.0.0.1") };
14383
14384 config.apply_env_overrides();
14385
14386 assert_eq!(config.proxy.scope, ProxyScope::Environment);
14387 assert_eq!(
14388 std::env::var("HTTP_PROXY").ok().as_deref(),
14389 Some("http://127.0.0.1:7890")
14390 );
14391 assert_eq!(
14392 std::env::var("HTTPS_PROXY").ok().as_deref(),
14393 Some("http://127.0.0.1:7891")
14394 );
14395 assert!(
14396 std::env::var("NO_PROXY")
14397 .ok()
14398 .is_some_and(|value| value.contains("localhost"))
14399 );
14400
14401 clear_proxy_env_test_vars();
14402 }
14403
14404 #[test]
14405 async fn google_workspace_allowed_operations_require_methods() {
14406 let mut config = Config::default();
14407 config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
14408 service: "gmail".into(),
14409 resource: "users".into(),
14410 sub_resource: Some("drafts".into()),
14411 methods: Vec::new(),
14412 }];
14413
14414 let err = config.validate().unwrap_err().to_string();
14415 assert!(err.contains("google_workspace.allowed_operations[0].methods"));
14416 }
14417
14418 #[test]
14419 async fn google_workspace_allowed_operations_reject_duplicate_service_resource_sub_resource_entries()
14420 {
14421 let mut config = Config::default();
14422 config.google_workspace.allowed_operations = vec![
14423 GoogleWorkspaceAllowedOperation {
14424 service: "gmail".into(),
14425 resource: "users".into(),
14426 sub_resource: Some("drafts".into()),
14427 methods: vec!["create".into()],
14428 },
14429 GoogleWorkspaceAllowedOperation {
14430 service: "gmail".into(),
14431 resource: "users".into(),
14432 sub_resource: Some("drafts".into()),
14433 methods: vec!["update".into()],
14434 },
14435 ];
14436
14437 let err = config.validate().unwrap_err().to_string();
14438 assert!(err.contains("duplicate service/resource/sub_resource entry"));
14439 }
14440
14441 #[test]
14442 async fn google_workspace_allowed_operations_allow_same_resource_different_sub_resource() {
14443 let mut config = Config::default();
14444 config.google_workspace.allowed_operations = vec![
14445 GoogleWorkspaceAllowedOperation {
14446 service: "gmail".into(),
14447 resource: "users".into(),
14448 sub_resource: Some("messages".into()),
14449 methods: vec!["list".into(), "get".into()],
14450 },
14451 GoogleWorkspaceAllowedOperation {
14452 service: "gmail".into(),
14453 resource: "users".into(),
14454 sub_resource: Some("drafts".into()),
14455 methods: vec!["create".into(), "update".into()],
14456 },
14457 ];
14458
14459 assert!(config.validate().is_ok());
14460 }
14461
14462 #[test]
14463 async fn google_workspace_allowed_operations_reject_duplicate_methods_within_entry() {
14464 let mut config = Config::default();
14465 config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
14466 service: "gmail".into(),
14467 resource: "users".into(),
14468 sub_resource: Some("drafts".into()),
14469 methods: vec!["create".into(), "create".into()],
14470 }];
14471
14472 let err = config.validate().unwrap_err().to_string();
14473 assert!(
14474 err.contains("duplicate entry"),
14475 "expected duplicate entry error, got: {err}"
14476 );
14477 }
14478
14479 #[test]
14480 async fn google_workspace_allowed_operations_accept_valid_entries() {
14481 let mut config = Config::default();
14482 config.google_workspace.allowed_operations = vec![
14483 GoogleWorkspaceAllowedOperation {
14484 service: "gmail".into(),
14485 resource: "users".into(),
14486 sub_resource: Some("messages".into()),
14487 methods: vec!["list".into(), "get".into()],
14488 },
14489 GoogleWorkspaceAllowedOperation {
14490 service: "drive".into(),
14491 resource: "files".into(),
14492 sub_resource: None,
14493 methods: vec!["list".into(), "get".into()],
14494 },
14495 ];
14496
14497 assert!(config.validate().is_ok());
14498 }
14499
14500 #[test]
14501 async fn google_workspace_allowed_operations_reject_invalid_sub_resource_characters() {
14502 let mut config = Config::default();
14503 config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
14504 service: "gmail".into(),
14505 resource: "users".into(),
14506 sub_resource: Some("bad resource!".into()),
14507 methods: vec!["list".into()],
14508 }];
14509
14510 let err = config.validate().unwrap_err().to_string();
14511 assert!(err.contains("sub_resource contains invalid characters"));
14512 }
14513
14514 fn runtime_proxy_cache_contains(cache_key: &str) -> bool {
14515 match runtime_proxy_client_cache().read() {
14516 Ok(guard) => guard.contains_key(cache_key),
14517 Err(poisoned) => poisoned.into_inner().contains_key(cache_key),
14518 }
14519 }
14520
14521 #[test]
14522 async fn runtime_proxy_client_cache_reuses_default_profile_key() {
14523 let service_key = format!(
14524 "provider.cache_test.{}",
14525 std::time::SystemTime::now()
14526 .duration_since(std::time::UNIX_EPOCH)
14527 .expect("system clock should be after unix epoch")
14528 .as_nanos()
14529 );
14530 let cache_key = runtime_proxy_cache_key(&service_key, None, None);
14531
14532 clear_runtime_proxy_client_cache();
14533 assert!(!runtime_proxy_cache_contains(&cache_key));
14534
14535 let _ = build_runtime_proxy_client(&service_key);
14536 assert!(runtime_proxy_cache_contains(&cache_key));
14537
14538 let _ = build_runtime_proxy_client(&service_key);
14539 assert!(runtime_proxy_cache_contains(&cache_key));
14540 }
14541
14542 #[test]
14543 async fn set_runtime_proxy_config_clears_runtime_proxy_client_cache() {
14544 let service_key = format!(
14545 "provider.cache_timeout_test.{}",
14546 std::time::SystemTime::now()
14547 .duration_since(std::time::UNIX_EPOCH)
14548 .expect("system clock should be after unix epoch")
14549 .as_nanos()
14550 );
14551 let cache_key = runtime_proxy_cache_key(&service_key, Some(30), Some(5));
14552
14553 clear_runtime_proxy_client_cache();
14554 let _ = build_runtime_proxy_client_with_timeouts(&service_key, 30, 5);
14555 assert!(runtime_proxy_cache_contains(&cache_key));
14556
14557 set_runtime_proxy_config(ProxyConfig::default());
14558 assert!(!runtime_proxy_cache_contains(&cache_key));
14559 }
14560
14561 #[test]
14562 async fn gateway_config_default_values() {
14563 let g = GatewayConfig::default();
14564 assert_eq!(g.port, 42617);
14565 assert_eq!(g.host, "127.0.0.1");
14566 assert!(g.require_pairing);
14567 assert!(!g.allow_public_bind);
14568 assert!(g.paired_tokens.is_empty());
14569 assert!(!g.trust_forwarded_headers);
14570 assert_eq!(g.rate_limit_max_keys, 10_000);
14571 assert_eq!(g.idempotency_max_keys, 10_000);
14572 }
14573
14574 #[test]
14577 async fn peripherals_config_default_disabled() {
14578 let p = PeripheralsConfig::default();
14579 assert!(!p.enabled);
14580 assert!(p.boards.is_empty());
14581 }
14582
14583 #[test]
14584 async fn peripheral_board_config_defaults() {
14585 let b = PeripheralBoardConfig::default();
14586 assert!(b.board.is_empty());
14587 assert_eq!(b.transport, "serial");
14588 assert!(b.path.is_none());
14589 assert_eq!(b.baud, 115_200);
14590 }
14591
14592 #[test]
14593 async fn peripherals_config_toml_roundtrip() {
14594 let p = PeripheralsConfig {
14595 enabled: true,
14596 boards: vec![PeripheralBoardConfig {
14597 board: "nucleo-f401re".into(),
14598 transport: "serial".into(),
14599 path: Some("/dev/ttyACM0".into()),
14600 baud: 115_200,
14601 }],
14602 datasheet_dir: None,
14603 };
14604 let toml_str = toml::to_string(&p).unwrap();
14605 let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
14606 assert!(parsed.enabled);
14607 assert_eq!(parsed.boards.len(), 1);
14608 assert_eq!(parsed.boards[0].board, "nucleo-f401re");
14609 assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
14610 }
14611
14612 #[test]
14613 async fn lark_config_serde() {
14614 let lc = LarkConfig {
14615 app_id: "cli_123456".into(),
14616 app_secret: "secret_abc".into(),
14617 encrypt_key: Some("encrypt_key".into()),
14618 verification_token: Some("verify_token".into()),
14619 allowed_users: vec!["user_123".into(), "user_456".into()],
14620 mention_only: false,
14621 use_feishu: true,
14622 receive_mode: LarkReceiveMode::Websocket,
14623 port: None,
14624 proxy_url: None,
14625 };
14626 let json = serde_json::to_string(&lc).unwrap();
14627 let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
14628 assert_eq!(parsed.app_id, "cli_123456");
14629 assert_eq!(parsed.app_secret, "secret_abc");
14630 assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
14631 assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
14632 assert_eq!(parsed.allowed_users.len(), 2);
14633 assert!(parsed.use_feishu);
14634 }
14635
14636 #[test]
14637 async fn lark_config_toml_roundtrip() {
14638 let lc = LarkConfig {
14639 app_id: "cli_123456".into(),
14640 app_secret: "secret_abc".into(),
14641 encrypt_key: Some("encrypt_key".into()),
14642 verification_token: Some("verify_token".into()),
14643 allowed_users: vec!["*".into()],
14644 mention_only: false,
14645 use_feishu: false,
14646 receive_mode: LarkReceiveMode::Webhook,
14647 port: Some(9898),
14648 proxy_url: None,
14649 };
14650 let toml_str = toml::to_string(&lc).unwrap();
14651 let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
14652 assert_eq!(parsed.app_id, "cli_123456");
14653 assert_eq!(parsed.app_secret, "secret_abc");
14654 assert!(!parsed.use_feishu);
14655 }
14656
14657 #[test]
14658 async fn lark_config_deserializes_without_optional_fields() {
14659 let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
14660 let parsed: LarkConfig = serde_json::from_str(json).unwrap();
14661 assert!(parsed.encrypt_key.is_none());
14662 assert!(parsed.verification_token.is_none());
14663 assert!(parsed.allowed_users.is_empty());
14664 assert!(!parsed.mention_only);
14665 assert!(!parsed.use_feishu);
14666 }
14667
14668 #[test]
14669 async fn lark_config_defaults_to_lark_endpoint() {
14670 let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
14671 let parsed: LarkConfig = serde_json::from_str(json).unwrap();
14672 assert!(
14673 !parsed.use_feishu,
14674 "use_feishu should default to false (Lark)"
14675 );
14676 }
14677
14678 #[test]
14679 async fn lark_config_with_wildcard_allowed_users() {
14680 let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#;
14681 let parsed: LarkConfig = serde_json::from_str(json).unwrap();
14682 assert_eq!(parsed.allowed_users, vec!["*"]);
14683 }
14684
14685 #[test]
14686 async fn feishu_config_serde() {
14687 let fc = FeishuConfig {
14688 app_id: "cli_feishu_123".into(),
14689 app_secret: "secret_abc".into(),
14690 encrypt_key: Some("encrypt_key".into()),
14691 verification_token: Some("verify_token".into()),
14692 allowed_users: vec!["user_123".into(), "user_456".into()],
14693 receive_mode: LarkReceiveMode::Websocket,
14694 port: None,
14695 proxy_url: None,
14696 };
14697 let json = serde_json::to_string(&fc).unwrap();
14698 let parsed: FeishuConfig = serde_json::from_str(&json).unwrap();
14699 assert_eq!(parsed.app_id, "cli_feishu_123");
14700 assert_eq!(parsed.app_secret, "secret_abc");
14701 assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
14702 assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
14703 assert_eq!(parsed.allowed_users.len(), 2);
14704 }
14705
14706 #[test]
14707 async fn feishu_config_toml_roundtrip() {
14708 let fc = FeishuConfig {
14709 app_id: "cli_feishu_123".into(),
14710 app_secret: "secret_abc".into(),
14711 encrypt_key: Some("encrypt_key".into()),
14712 verification_token: Some("verify_token".into()),
14713 allowed_users: vec!["*".into()],
14714 receive_mode: LarkReceiveMode::Webhook,
14715 port: Some(9898),
14716 proxy_url: None,
14717 };
14718 let toml_str = toml::to_string(&fc).unwrap();
14719 let parsed: FeishuConfig = toml::from_str(&toml_str).unwrap();
14720 assert_eq!(parsed.app_id, "cli_feishu_123");
14721 assert_eq!(parsed.app_secret, "secret_abc");
14722 assert_eq!(parsed.receive_mode, LarkReceiveMode::Webhook);
14723 assert_eq!(parsed.port, Some(9898));
14724 }
14725
14726 #[test]
14727 async fn feishu_config_deserializes_without_optional_fields() {
14728 let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
14729 let parsed: FeishuConfig = serde_json::from_str(json).unwrap();
14730 assert!(parsed.encrypt_key.is_none());
14731 assert!(parsed.verification_token.is_none());
14732 assert!(parsed.allowed_users.is_empty());
14733 assert_eq!(parsed.receive_mode, LarkReceiveMode::Websocket);
14734 assert!(parsed.port.is_none());
14735 }
14736
14737 #[test]
14738 async fn nextcloud_talk_config_serde() {
14739 let nc = NextcloudTalkConfig {
14740 base_url: "https://cloud.example.com".into(),
14741 app_token: "app-token".into(),
14742 webhook_secret: Some("webhook-secret".into()),
14743 allowed_users: vec!["user_a".into(), "*".into()],
14744 proxy_url: None,
14745 bot_name: None,
14746 };
14747
14748 let json = serde_json::to_string(&nc).unwrap();
14749 let parsed: NextcloudTalkConfig = serde_json::from_str(&json).unwrap();
14750 assert_eq!(parsed.base_url, "https://cloud.example.com");
14751 assert_eq!(parsed.app_token, "app-token");
14752 assert_eq!(parsed.webhook_secret.as_deref(), Some("webhook-secret"));
14753 assert_eq!(parsed.allowed_users, vec!["user_a", "*"]);
14754 }
14755
14756 #[test]
14757 async fn nextcloud_talk_config_defaults_optional_fields() {
14758 let json = r#"{"base_url":"https://cloud.example.com","app_token":"app-token"}"#;
14759 let parsed: NextcloudTalkConfig = serde_json::from_str(json).unwrap();
14760 assert!(parsed.webhook_secret.is_none());
14761 assert!(parsed.allowed_users.is_empty());
14762 }
14763
14764 #[cfg(unix)]
14767 #[test]
14768 async fn new_config_file_has_restricted_permissions() {
14769 let tmp = tempfile::TempDir::new().unwrap();
14770 let config_path = tmp.path().join("config.toml");
14771
14772 let mut config = Config::default();
14774 config.config_path = config_path.clone();
14775 config.save().await.unwrap();
14776
14777 let meta = fs::metadata(&config_path).await.unwrap();
14778 let mode = meta.permissions().mode() & 0o777;
14779 assert_eq!(
14780 mode, 0o600,
14781 "New config file should be owner-only (0600), got {mode:o}"
14782 );
14783 }
14784
14785 #[cfg(unix)]
14786 #[test]
14787 async fn save_restricts_existing_world_readable_config_to_owner_only() {
14788 let tmp = tempfile::TempDir::new().unwrap();
14789 let config_path = tmp.path().join("config.toml");
14790
14791 let mut config = Config::default();
14792 config.config_path = config_path.clone();
14793 config.save().await.unwrap();
14794
14795 std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
14797 let loose_mode = std::fs::metadata(&config_path)
14798 .unwrap()
14799 .permissions()
14800 .mode()
14801 & 0o777;
14802 assert_eq!(
14803 loose_mode, 0o644,
14804 "test setup requires world-readable config"
14805 );
14806
14807 config.default_temperature = 0.6;
14808 config.save().await.unwrap();
14809
14810 let hardened_mode = std::fs::metadata(&config_path)
14811 .unwrap()
14812 .permissions()
14813 .mode()
14814 & 0o777;
14815 assert_eq!(
14816 hardened_mode, 0o600,
14817 "Saving config should restore owner-only permissions (0600)"
14818 );
14819 }
14820
14821 #[cfg(unix)]
14822 #[test]
14823 async fn world_readable_config_is_detectable() {
14824 use std::os::unix::fs::PermissionsExt;
14825
14826 let tmp = tempfile::TempDir::new().unwrap();
14827 let config_path = tmp.path().join("config.toml");
14828
14829 std::fs::write(&config_path, "# test config").unwrap();
14831 std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
14832
14833 let meta = std::fs::metadata(&config_path).unwrap();
14834 let mode = meta.permissions().mode();
14835 assert!(
14836 mode & 0o004 != 0,
14837 "Test setup: file should be world-readable (mode {mode:o})"
14838 );
14839 }
14840
14841 #[test]
14842 async fn transcription_config_defaults() {
14843 let tc = TranscriptionConfig::default();
14844 assert!(!tc.enabled);
14845 assert!(tc.api_url.contains("groq.com"));
14846 assert_eq!(tc.model, "whisper-large-v3-turbo");
14847 assert!(tc.language.is_none());
14848 assert_eq!(tc.max_duration_secs, 120);
14849 assert!(!tc.transcribe_non_ptt_audio);
14850 }
14851
14852 #[test]
14853 async fn config_roundtrip_with_transcription() {
14854 let mut config = Config::default();
14855 config.transcription.enabled = true;
14856 config.transcription.language = Some("en".into());
14857
14858 let toml_str = toml::to_string_pretty(&config).unwrap();
14859 let parsed = parse_test_config(&toml_str);
14860
14861 assert!(parsed.transcription.enabled);
14862 assert_eq!(parsed.transcription.language.as_deref(), Some("en"));
14863 assert_eq!(parsed.transcription.model, "whisper-large-v3-turbo");
14864 }
14865
14866 #[test]
14867 async fn config_without_transcription_uses_defaults() {
14868 let toml_str = r#"
14869 default_provider = "openrouter"
14870 default_model = "test-model"
14871 default_temperature = 0.7
14872 "#;
14873 let parsed = parse_test_config(toml_str);
14874 assert!(!parsed.transcription.enabled);
14875 assert_eq!(parsed.transcription.max_duration_secs, 120);
14876 }
14877
14878 #[test]
14879 async fn security_defaults_are_backward_compatible() {
14880 let parsed = parse_test_config(
14881 r#"
14882default_provider = "openrouter"
14883default_model = "anthropic/claude-sonnet-4.6"
14884default_temperature = 0.7
14885"#,
14886 );
14887
14888 assert!(!parsed.security.otp.enabled);
14889 assert_eq!(parsed.security.otp.method, OtpMethod::Totp);
14890 assert!(!parsed.security.estop.enabled);
14891 assert!(parsed.security.estop.require_otp_to_resume);
14892 }
14893
14894 #[test]
14895 async fn security_toml_parses_otp_and_estop_sections() {
14896 let parsed = parse_test_config(
14897 r#"
14898default_provider = "openrouter"
14899default_model = "anthropic/claude-sonnet-4.6"
14900default_temperature = 0.7
14901
14902[security.otp]
14903enabled = true
14904method = "totp"
14905token_ttl_secs = 30
14906cache_valid_secs = 120
14907gated_actions = ["shell", "browser_open"]
14908gated_domains = ["*.chase.com", "accounts.google.com"]
14909gated_domain_categories = ["banking"]
14910
14911[security.estop]
14912enabled = true
14913state_file = "~/.construct/estop-state.json"
14914require_otp_to_resume = true
14915"#,
14916 );
14917
14918 assert!(parsed.security.otp.enabled);
14919 assert!(parsed.security.estop.enabled);
14920 assert_eq!(parsed.security.otp.gated_actions.len(), 2);
14921 assert_eq!(parsed.security.otp.gated_domains.len(), 2);
14922 parsed.validate().unwrap();
14923 }
14924
14925 #[test]
14926 async fn security_validation_rejects_invalid_domain_glob() {
14927 let mut config = Config::default();
14928 config.security.otp.gated_domains = vec!["bad domain.com".into()];
14929
14930 let err = config.validate().expect_err("expected invalid domain glob");
14931 assert!(err.to_string().contains("gated_domains"));
14932 }
14933
14934 #[test]
14935 async fn validate_accepts_local_whisper_as_transcription_default_provider() {
14936 let mut config = Config::default();
14937 config.transcription.default_provider = "local_whisper".to_string();
14938
14939 config.validate().expect(
14940 "local_whisper must be accepted by the transcription.default_provider allowlist",
14941 );
14942 }
14943
14944 #[test]
14945 async fn validate_rejects_unknown_transcription_default_provider() {
14946 let mut config = Config::default();
14947 config.transcription.default_provider = "unknown_stt".to_string();
14948
14949 let err = config
14950 .validate()
14951 .expect_err("expected validation to reject unknown transcription provider");
14952 assert!(
14953 err.to_string().contains("transcription.default_provider"),
14954 "got: {err}"
14955 );
14956 }
14957
14958 #[tokio::test]
14959 async fn channel_secret_telegram_bot_token_roundtrip() {
14960 let dir = std::env::temp_dir().join(format!(
14961 "construct_test_tg_bot_token_{}",
14962 uuid::Uuid::new_v4()
14963 ));
14964 fs::create_dir_all(&dir).await.unwrap();
14965
14966 let plaintext_token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";
14967
14968 let mut config = Config::default();
14969 config.workspace_dir = dir.join("workspace");
14970 config.config_path = dir.join("config.toml");
14971 config.channels_config.telegram = Some(TelegramConfig {
14972 bot_token: plaintext_token.into(),
14973 allowed_users: vec!["user1".into()],
14974 stream_mode: StreamMode::default(),
14975 draft_update_interval_ms: default_draft_update_interval_ms(),
14976 interrupt_on_new_message: false,
14977 mention_only: false,
14978 ack_reactions: None,
14979 proxy_url: None,
14980 notification_chat_id: None,
14981 });
14982
14983 config.save().await.unwrap();
14985
14986 let raw_toml = tokio::fs::read_to_string(&config.config_path)
14988 .await
14989 .unwrap();
14990 assert!(
14991 !raw_toml.contains(plaintext_token),
14992 "Saved TOML must not contain the plaintext bot_token"
14993 );
14994
14995 let stored: Config = toml::from_str(&raw_toml).unwrap();
14997 let stored_token = &stored.channels_config.telegram.as_ref().unwrap().bot_token;
14998 assert!(
14999 crate::security::SecretStore::is_encrypted(stored_token),
15000 "Stored bot_token must be marked as encrypted"
15001 );
15002
15003 let store = crate::security::SecretStore::new(&dir, true);
15005 assert_eq!(store.decrypt(stored_token).unwrap(), plaintext_token);
15006
15007 let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
15009 loaded.config_path = dir.join("config.toml");
15010 let load_store = crate::security::SecretStore::new(&dir, loaded.secrets.encrypt);
15011 if let Some(ref mut tg) = loaded.channels_config.telegram {
15012 decrypt_secret(
15013 &load_store,
15014 &mut tg.bot_token,
15015 "config.channels_config.telegram.bot_token",
15016 )
15017 .unwrap();
15018 }
15019 assert_eq!(
15020 loaded.channels_config.telegram.as_ref().unwrap().bot_token,
15021 plaintext_token,
15022 "Loaded bot_token must match the original plaintext after decryption"
15023 );
15024
15025 let _ = fs::remove_dir_all(&dir).await;
15026 }
15027
15028 #[test]
15029 async fn security_validation_rejects_unknown_domain_category() {
15030 let mut config = Config::default();
15031 config.security.otp.gated_domain_categories = vec!["not_real".into()];
15032
15033 let err = config
15034 .validate()
15035 .expect_err("expected unknown domain category");
15036 assert!(err.to_string().contains("gated_domain_categories"));
15037 }
15038
15039 #[test]
15040 async fn security_validation_rejects_zero_token_ttl() {
15041 let mut config = Config::default();
15042 config.security.otp.token_ttl_secs = 0;
15043
15044 let err = config
15045 .validate()
15046 .expect_err("expected ttl validation failure");
15047 assert!(err.to_string().contains("token_ttl_secs"));
15048 }
15049
15050 fn stdio_server(name: &str, command: &str) -> McpServerConfig {
15053 McpServerConfig {
15054 name: name.to_string(),
15055 transport: McpTransport::Stdio,
15056 command: command.to_string(),
15057 ..Default::default()
15058 }
15059 }
15060
15061 fn http_server(name: &str, url: &str) -> McpServerConfig {
15062 McpServerConfig {
15063 name: name.to_string(),
15064 transport: McpTransport::Http,
15065 url: Some(url.to_string()),
15066 ..Default::default()
15067 }
15068 }
15069
15070 fn sse_server(name: &str, url: &str) -> McpServerConfig {
15071 McpServerConfig {
15072 name: name.to_string(),
15073 transport: McpTransport::Sse,
15074 url: Some(url.to_string()),
15075 ..Default::default()
15076 }
15077 }
15078
15079 #[test]
15080 async fn validate_mcp_config_empty_servers_ok() {
15081 let cfg = McpConfig::default();
15082 assert!(validate_mcp_config(&cfg).is_ok());
15083 }
15084
15085 #[test]
15086 async fn validate_mcp_config_valid_stdio_ok() {
15087 let cfg = McpConfig {
15088 enabled: true,
15089 servers: vec![stdio_server("fs", "/usr/bin/mcp-fs")],
15090 ..Default::default()
15091 };
15092 assert!(validate_mcp_config(&cfg).is_ok());
15093 }
15094
15095 #[test]
15096 async fn validate_mcp_config_valid_http_ok() {
15097 let cfg = McpConfig {
15098 enabled: true,
15099 servers: vec![http_server("svc", "http://localhost:8080/mcp")],
15100 ..Default::default()
15101 };
15102 assert!(validate_mcp_config(&cfg).is_ok());
15103 }
15104
15105 #[test]
15106 async fn validate_mcp_config_valid_sse_ok() {
15107 let cfg = McpConfig {
15108 enabled: true,
15109 servers: vec![sse_server("svc", "https://example.com/events")],
15110 ..Default::default()
15111 };
15112 assert!(validate_mcp_config(&cfg).is_ok());
15113 }
15114
15115 #[test]
15116 async fn validate_mcp_config_rejects_empty_name() {
15117 let cfg = McpConfig {
15118 enabled: true,
15119 servers: vec![stdio_server("", "/usr/bin/tool")],
15120 ..Default::default()
15121 };
15122 let err = validate_mcp_config(&cfg).expect_err("empty name should fail");
15123 assert!(
15124 err.to_string().contains("name must not be empty"),
15125 "got: {err}"
15126 );
15127 }
15128
15129 #[test]
15130 async fn validate_mcp_config_rejects_whitespace_name() {
15131 let cfg = McpConfig {
15132 enabled: true,
15133 servers: vec![stdio_server(" ", "/usr/bin/tool")],
15134 ..Default::default()
15135 };
15136 let err = validate_mcp_config(&cfg).expect_err("whitespace name should fail");
15137 assert!(
15138 err.to_string().contains("name must not be empty"),
15139 "got: {err}"
15140 );
15141 }
15142
15143 #[test]
15144 async fn validate_mcp_config_rejects_duplicate_names() {
15145 let cfg = McpConfig {
15146 enabled: true,
15147 servers: vec![
15148 stdio_server("fs", "/usr/bin/mcp-a"),
15149 stdio_server("fs", "/usr/bin/mcp-b"),
15150 ],
15151 ..Default::default()
15152 };
15153 let err = validate_mcp_config(&cfg).expect_err("duplicate name should fail");
15154 assert!(err.to_string().contains("duplicate name"), "got: {err}");
15155 }
15156
15157 #[test]
15158 async fn validate_mcp_config_rejects_zero_timeout() {
15159 let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
15160 server.tool_timeout_secs = Some(0);
15161 let cfg = McpConfig {
15162 enabled: true,
15163 servers: vec![server],
15164 ..Default::default()
15165 };
15166 let err = validate_mcp_config(&cfg).expect_err("zero timeout should fail");
15167 assert!(err.to_string().contains("greater than 0"), "got: {err}");
15168 }
15169
15170 #[test]
15171 async fn validate_mcp_config_rejects_timeout_exceeding_max() {
15172 let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
15173 server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS + 1);
15174 let cfg = McpConfig {
15175 enabled: true,
15176 servers: vec![server],
15177 ..Default::default()
15178 };
15179 let err = validate_mcp_config(&cfg).expect_err("oversized timeout should fail");
15180 assert!(err.to_string().contains("exceeds max"), "got: {err}");
15181 }
15182
15183 #[test]
15184 async fn validate_mcp_config_allows_max_timeout_exactly() {
15185 let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
15186 server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS);
15187 let cfg = McpConfig {
15188 enabled: true,
15189 servers: vec![server],
15190 ..Default::default()
15191 };
15192 assert!(validate_mcp_config(&cfg).is_ok());
15193 }
15194
15195 #[test]
15196 async fn validate_mcp_config_rejects_stdio_with_empty_command() {
15197 let cfg = McpConfig {
15198 enabled: true,
15199 servers: vec![stdio_server("fs", "")],
15200 ..Default::default()
15201 };
15202 let err = validate_mcp_config(&cfg).expect_err("empty command should fail");
15203 assert!(
15204 err.to_string().contains("requires non-empty command"),
15205 "got: {err}"
15206 );
15207 }
15208
15209 #[test]
15210 async fn validate_mcp_config_rejects_http_without_url() {
15211 let cfg = McpConfig {
15212 enabled: true,
15213 servers: vec![McpServerConfig {
15214 name: "svc".to_string(),
15215 transport: McpTransport::Http,
15216 url: None,
15217 ..Default::default()
15218 }],
15219 ..Default::default()
15220 };
15221 let err = validate_mcp_config(&cfg).expect_err("http without url should fail");
15222 assert!(err.to_string().contains("requires url"), "got: {err}");
15223 }
15224
15225 #[test]
15226 async fn validate_mcp_config_rejects_sse_without_url() {
15227 let cfg = McpConfig {
15228 enabled: true,
15229 servers: vec![McpServerConfig {
15230 name: "svc".to_string(),
15231 transport: McpTransport::Sse,
15232 url: None,
15233 ..Default::default()
15234 }],
15235 ..Default::default()
15236 };
15237 let err = validate_mcp_config(&cfg).expect_err("sse without url should fail");
15238 assert!(err.to_string().contains("requires url"), "got: {err}");
15239 }
15240
15241 #[test]
15242 async fn validate_mcp_config_rejects_non_http_scheme() {
15243 let cfg = McpConfig {
15244 enabled: true,
15245 servers: vec![http_server("svc", "ftp://example.com/mcp")],
15246 ..Default::default()
15247 };
15248 let err = validate_mcp_config(&cfg).expect_err("non-http scheme should fail");
15249 assert!(err.to_string().contains("http/https"), "got: {err}");
15250 }
15251
15252 #[test]
15253 async fn validate_mcp_config_rejects_invalid_url() {
15254 let cfg = McpConfig {
15255 enabled: true,
15256 servers: vec![http_server("svc", "not a url at all !!!")],
15257 ..Default::default()
15258 };
15259 let err = validate_mcp_config(&cfg).expect_err("invalid url should fail");
15260 assert!(err.to_string().contains("valid URL"), "got: {err}");
15261 }
15262
15263 #[test]
15264 async fn mcp_config_default_disabled_with_empty_servers() {
15265 let cfg = McpConfig::default();
15266 assert!(!cfg.enabled);
15267 assert!(cfg.servers.is_empty());
15268 }
15269
15270 #[test]
15271 async fn mcp_transport_serde_roundtrip_lowercase() {
15272 let cases = [
15273 (McpTransport::Stdio, "\"stdio\""),
15274 (McpTransport::Http, "\"http\""),
15275 (McpTransport::Sse, "\"sse\""),
15276 ];
15277 for (variant, expected_json) in &cases {
15278 let serialized = serde_json::to_string(variant).expect("serialize");
15279 assert_eq!(&serialized, expected_json, "variant: {variant:?}");
15280 let deserialized: McpTransport =
15281 serde_json::from_str(expected_json).expect("deserialize");
15282 assert_eq!(&deserialized, variant);
15283 }
15284 }
15285
15286 #[test]
15287 async fn swarm_strategy_roundtrip() {
15288 let cases = vec![
15289 (SwarmStrategy::Sequential, "\"sequential\""),
15290 (SwarmStrategy::Parallel, "\"parallel\""),
15291 (SwarmStrategy::Router, "\"router\""),
15292 ];
15293 for (variant, expected_json) in &cases {
15294 let serialized = serde_json::to_string(variant).expect("serialize");
15295 assert_eq!(&serialized, expected_json, "variant: {variant:?}");
15296 let deserialized: SwarmStrategy =
15297 serde_json::from_str(expected_json).expect("deserialize");
15298 assert_eq!(&deserialized, variant);
15299 }
15300 }
15301
15302 #[test]
15303 async fn swarm_config_deserializes_with_defaults() {
15304 let toml_str = r#"
15305 agents = ["researcher", "writer"]
15306 strategy = "sequential"
15307 "#;
15308 let config: SwarmConfig = toml::from_str(toml_str).expect("deserialize");
15309 assert_eq!(config.agents, vec!["researcher", "writer"]);
15310 assert_eq!(config.strategy, SwarmStrategy::Sequential);
15311 assert!(config.router_prompt.is_none());
15312 assert!(config.description.is_none());
15313 assert_eq!(config.timeout_secs, 300);
15314 }
15315
15316 #[test]
15317 async fn swarm_config_deserializes_full() {
15318 let toml_str = r#"
15319 agents = ["a", "b", "c"]
15320 strategy = "router"
15321 router_prompt = "Pick the best."
15322 description = "Multi-agent router"
15323 timeout_secs = 120
15324 "#;
15325 let config: SwarmConfig = toml::from_str(toml_str).expect("deserialize");
15326 assert_eq!(config.agents.len(), 3);
15327 assert_eq!(config.strategy, SwarmStrategy::Router);
15328 assert_eq!(config.router_prompt.as_deref(), Some("Pick the best."));
15329 assert_eq!(config.description.as_deref(), Some("Multi-agent router"));
15330 assert_eq!(config.timeout_secs, 120);
15331 }
15332
15333 #[test]
15334 async fn config_with_swarms_section_deserializes() {
15335 let toml_str = r#"
15336 [agents.researcher]
15337 provider = "ollama"
15338 model = "llama3"
15339
15340 [agents.writer]
15341 provider = "openrouter"
15342 model = "claude-sonnet"
15343
15344 [swarms.pipeline]
15345 agents = ["researcher", "writer"]
15346 strategy = "sequential"
15347 "#;
15348 let config = parse_test_config(toml_str);
15349 assert_eq!(config.agents.len(), 2);
15350 assert_eq!(config.swarms.len(), 1);
15351 assert!(config.swarms.contains_key("pipeline"));
15352 }
15353
15354 #[tokio::test]
15355 async fn nevis_client_secret_encrypt_decrypt_roundtrip() {
15356 let dir = std::env::temp_dir().join(format!(
15357 "construct_test_nevis_secret_{}",
15358 uuid::Uuid::new_v4()
15359 ));
15360 fs::create_dir_all(&dir).await.unwrap();
15361
15362 let plaintext_secret = "nevis-test-client-secret-value";
15363
15364 let mut config = Config::default();
15365 config.workspace_dir = dir.join("workspace");
15366 config.config_path = dir.join("config.toml");
15367 config.security.nevis.client_secret = Some(plaintext_secret.into());
15368
15369 config.save().await.unwrap();
15371
15372 let raw_toml = tokio::fs::read_to_string(&config.config_path)
15374 .await
15375 .unwrap();
15376 assert!(
15377 !raw_toml.contains(plaintext_secret),
15378 "Saved TOML must not contain the plaintext client_secret"
15379 );
15380
15381 let stored: Config = toml::from_str(&raw_toml).unwrap();
15383 let stored_secret = stored.security.nevis.client_secret.as_ref().unwrap();
15384 assert!(
15385 crate::security::SecretStore::is_encrypted(stored_secret),
15386 "Stored client_secret must be marked as encrypted"
15387 );
15388
15389 let store = crate::security::SecretStore::new(&dir, true);
15391 assert_eq!(store.decrypt(stored_secret).unwrap(), plaintext_secret);
15392
15393 let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
15395 loaded.config_path = dir.join("config.toml");
15396 let load_store = crate::security::SecretStore::new(&dir, loaded.secrets.encrypt);
15397 decrypt_optional_secret(
15398 &load_store,
15399 &mut loaded.security.nevis.client_secret,
15400 "config.security.nevis.client_secret",
15401 )
15402 .unwrap();
15403 assert_eq!(
15404 loaded.security.nevis.client_secret.as_deref().unwrap(),
15405 plaintext_secret,
15406 "Loaded client_secret must match the original plaintext after decryption"
15407 );
15408
15409 let _ = fs::remove_dir_all(&dir).await;
15410 }
15411
15412 #[test]
15417 async fn nevis_config_validate_disabled_accepts_empty_fields() {
15418 let cfg = NevisConfig::default();
15419 assert!(!cfg.enabled);
15420 assert!(cfg.validate().is_ok());
15421 }
15422
15423 #[test]
15424 async fn nevis_config_validate_rejects_empty_instance_url() {
15425 let cfg = NevisConfig {
15426 enabled: true,
15427 instance_url: String::new(),
15428 client_id: "test-client".into(),
15429 ..NevisConfig::default()
15430 };
15431 let err = cfg.validate().unwrap_err();
15432 assert!(err.contains("instance_url"));
15433 }
15434
15435 #[test]
15436 async fn nevis_config_validate_rejects_empty_client_id() {
15437 let cfg = NevisConfig {
15438 enabled: true,
15439 instance_url: "https://nevis.example.com".into(),
15440 client_id: String::new(),
15441 ..NevisConfig::default()
15442 };
15443 let err = cfg.validate().unwrap_err();
15444 assert!(err.contains("client_id"));
15445 }
15446
15447 #[test]
15448 async fn nevis_config_validate_rejects_empty_realm() {
15449 let cfg = NevisConfig {
15450 enabled: true,
15451 instance_url: "https://nevis.example.com".into(),
15452 client_id: "test-client".into(),
15453 realm: String::new(),
15454 ..NevisConfig::default()
15455 };
15456 let err = cfg.validate().unwrap_err();
15457 assert!(err.contains("realm"));
15458 }
15459
15460 #[test]
15461 async fn nevis_config_validate_rejects_local_without_jwks() {
15462 let cfg = NevisConfig {
15463 enabled: true,
15464 instance_url: "https://nevis.example.com".into(),
15465 client_id: "test-client".into(),
15466 token_validation: "local".into(),
15467 jwks_url: None,
15468 ..NevisConfig::default()
15469 };
15470 let err = cfg.validate().unwrap_err();
15471 assert!(err.contains("jwks_url"));
15472 }
15473
15474 #[test]
15475 async fn nevis_config_validate_rejects_zero_session_timeout() {
15476 let cfg = NevisConfig {
15477 enabled: true,
15478 instance_url: "https://nevis.example.com".into(),
15479 client_id: "test-client".into(),
15480 token_validation: "remote".into(),
15481 session_timeout_secs: 0,
15482 ..NevisConfig::default()
15483 };
15484 let err = cfg.validate().unwrap_err();
15485 assert!(err.contains("session_timeout_secs"));
15486 }
15487
15488 #[test]
15489 async fn nevis_config_validate_accepts_valid_enabled_config() {
15490 let cfg = NevisConfig {
15491 enabled: true,
15492 instance_url: "https://nevis.example.com".into(),
15493 realm: "master".into(),
15494 client_id: "test-client".into(),
15495 token_validation: "remote".into(),
15496 session_timeout_secs: 3600,
15497 ..NevisConfig::default()
15498 };
15499 assert!(cfg.validate().is_ok());
15500 }
15501
15502 #[test]
15503 async fn nevis_config_validate_rejects_invalid_token_validation() {
15504 let cfg = NevisConfig {
15505 enabled: true,
15506 instance_url: "https://nevis.example.com".into(),
15507 realm: "master".into(),
15508 client_id: "test-client".into(),
15509 token_validation: "invalid_mode".into(),
15510 session_timeout_secs: 3600,
15511 ..NevisConfig::default()
15512 };
15513 let err = cfg.validate().unwrap_err();
15514 assert!(
15515 err.contains("invalid value 'invalid_mode'"),
15516 "Expected invalid token_validation error, got: {err}"
15517 );
15518 }
15519
15520 #[test]
15521 async fn nevis_config_debug_redacts_client_secret() {
15522 let cfg = NevisConfig {
15523 client_secret: Some("super-secret".into()),
15524 ..NevisConfig::default()
15525 };
15526 let debug_output = format!("{:?}", cfg);
15527 assert!(
15528 !debug_output.contains("super-secret"),
15529 "Debug output must not contain the raw client_secret"
15530 );
15531 assert!(
15532 debug_output.contains("[REDACTED]"),
15533 "Debug output must show [REDACTED] for client_secret"
15534 );
15535 }
15536
15537 #[test]
15538 async fn telegram_config_ack_reactions_false_deserializes() {
15539 let toml_str = r#"
15540 bot_token = "123:ABC"
15541 allowed_users = ["alice"]
15542 ack_reactions = false
15543 "#;
15544 let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
15545 assert_eq!(cfg.ack_reactions, Some(false));
15546 }
15547
15548 #[test]
15549 async fn telegram_config_ack_reactions_true_deserializes() {
15550 let toml_str = r#"
15551 bot_token = "123:ABC"
15552 allowed_users = ["alice"]
15553 ack_reactions = true
15554 "#;
15555 let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
15556 assert_eq!(cfg.ack_reactions, Some(true));
15557 }
15558
15559 #[test]
15560 async fn telegram_config_ack_reactions_missing_defaults_to_none() {
15561 let toml_str = r#"
15562 bot_token = "123:ABC"
15563 allowed_users = ["alice"]
15564 "#;
15565 let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
15566 assert_eq!(cfg.ack_reactions, None);
15567 }
15568
15569 #[test]
15570 async fn telegram_config_ack_reactions_channel_overrides_top_level() {
15571 let tg_toml = r#"
15572 bot_token = "123:ABC"
15573 allowed_users = ["alice"]
15574 ack_reactions = false
15575 "#;
15576 let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
15577 let top_level_ack = true;
15578 let effective = tg.ack_reactions.unwrap_or(top_level_ack);
15579 assert!(
15580 !effective,
15581 "channel-level false must override top-level true"
15582 );
15583 }
15584
15585 #[test]
15586 async fn telegram_config_ack_reactions_falls_back_to_top_level() {
15587 let tg_toml = r#"
15588 bot_token = "123:ABC"
15589 allowed_users = ["alice"]
15590 "#;
15591 let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
15592 let top_level_ack = false;
15593 let effective = tg.ack_reactions.unwrap_or(top_level_ack);
15594 assert!(
15595 !effective,
15596 "must fall back to top-level false when channel omits field"
15597 );
15598 }
15599
15600 #[test]
15601 async fn google_workspace_allowed_operations_deserialize_from_toml() {
15602 let toml_str = r#"
15603 enabled = true
15604
15605 [[allowed_operations]]
15606 service = "gmail"
15607 resource = "users"
15608 sub_resource = "drafts"
15609 methods = ["create", "update"]
15610 "#;
15611
15612 let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
15613 assert_eq!(cfg.allowed_operations.len(), 1);
15614 assert_eq!(cfg.allowed_operations[0].service, "gmail");
15615 assert_eq!(cfg.allowed_operations[0].resource, "users");
15616 assert_eq!(
15617 cfg.allowed_operations[0].sub_resource.as_deref(),
15618 Some("drafts")
15619 );
15620 assert_eq!(
15621 cfg.allowed_operations[0].methods,
15622 vec!["create".to_string(), "update".to_string()]
15623 );
15624 }
15625
15626 #[test]
15627 async fn google_workspace_allowed_operations_deserialize_without_sub_resource() {
15628 let toml_str = r#"
15629 enabled = true
15630
15631 [[allowed_operations]]
15632 service = "drive"
15633 resource = "files"
15634 methods = ["list", "get"]
15635 "#;
15636
15637 let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
15638 assert_eq!(cfg.allowed_operations[0].sub_resource, None);
15639 }
15640
15641 #[test]
15642 async fn config_validate_accepts_google_workspace_allowed_operations() {
15643 let mut cfg = Config::default();
15644 cfg.google_workspace.enabled = true;
15645 cfg.google_workspace.allowed_services = vec!["gmail".into()];
15646 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15647 service: "gmail".into(),
15648 resource: "users".into(),
15649 sub_resource: Some("drafts".into()),
15650 methods: vec!["create".into(), "update".into()],
15651 }];
15652
15653 cfg.validate().unwrap();
15654 }
15655
15656 #[test]
15657 async fn config_validate_rejects_duplicate_google_workspace_allowed_operations() {
15658 let mut cfg = Config::default();
15659 cfg.google_workspace.enabled = true;
15660 cfg.google_workspace.allowed_services = vec!["gmail".into()];
15661 cfg.google_workspace.allowed_operations = vec![
15662 GoogleWorkspaceAllowedOperation {
15663 service: "gmail".into(),
15664 resource: "users".into(),
15665 sub_resource: Some("drafts".into()),
15666 methods: vec!["create".into()],
15667 },
15668 GoogleWorkspaceAllowedOperation {
15669 service: "gmail".into(),
15670 resource: "users".into(),
15671 sub_resource: Some("drafts".into()),
15672 methods: vec!["update".into()],
15673 },
15674 ];
15675
15676 let err = cfg.validate().unwrap_err().to_string();
15677 assert!(err.contains("duplicate service/resource/sub_resource entry"));
15678 }
15679
15680 #[test]
15681 async fn config_validate_rejects_operation_service_not_in_allowed_services() {
15682 let mut cfg = Config::default();
15683 cfg.google_workspace.enabled = true;
15684 cfg.google_workspace.allowed_services = vec!["gmail".into()];
15685 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15686 service: "drive".into(), resource: "files".into(),
15688 sub_resource: None,
15689 methods: vec!["list".into()],
15690 }];
15691
15692 let err = cfg.validate().unwrap_err().to_string();
15693 assert!(
15694 err.contains("not in the effective allowed_services"),
15695 "expected not-in-allowed_services error, got: {err}"
15696 );
15697 }
15698
15699 #[test]
15700 async fn config_validate_accepts_default_service_when_allowed_services_empty() {
15701 let mut cfg = Config::default();
15704 cfg.google_workspace.enabled = true;
15705 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15707 service: "drive".into(),
15708 resource: "files".into(),
15709 sub_resource: None,
15710 methods: vec!["list".into()],
15711 }];
15712
15713 assert!(cfg.validate().is_ok());
15714 }
15715
15716 #[test]
15717 async fn config_validate_rejects_unknown_service_when_allowed_services_empty() {
15718 let mut cfg = Config::default();
15722 cfg.google_workspace.enabled = true;
15723 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15725 service: "not_a_real_service".into(),
15726 resource: "files".into(),
15727 sub_resource: None,
15728 methods: vec!["list".into()],
15729 }];
15730
15731 let err = cfg.validate().unwrap_err().to_string();
15732 assert!(
15733 err.contains("not in the effective allowed_services"),
15734 "expected effective-allowed_services error, got: {err}"
15735 );
15736 }
15737
15738 #[tokio::test]
15741 async fn ensure_bootstrap_files_creates_missing_files() {
15742 let tmp = tempfile::TempDir::new().unwrap();
15743 let ws = tmp.path().join("workspace");
15744 let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
15745
15746 ensure_bootstrap_files(&ws).await.unwrap();
15747
15748 let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
15749 let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
15750 .await
15751 .unwrap();
15752 assert!(soul.contains("SOUL.md"));
15753 assert!(identity.contains("IDENTITY.md"));
15754 }
15755
15756 #[tokio::test]
15757 async fn ensure_bootstrap_files_does_not_overwrite_existing() {
15758 let tmp = tempfile::TempDir::new().unwrap();
15759 let ws = tmp.path().join("workspace");
15760 let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
15761
15762 let custom = "# My custom SOUL";
15763 let _: () = tokio::fs::write(ws.join("SOUL.md"), custom).await.unwrap();
15764
15765 ensure_bootstrap_files(&ws).await.unwrap();
15766
15767 let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
15768 assert_eq!(
15769 soul, custom,
15770 "ensure_bootstrap_files must not overwrite existing files"
15771 );
15772
15773 let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
15775 .await
15776 .unwrap();
15777 assert!(identity.contains("IDENTITY.md"));
15778 }
15779
15780 #[test]
15783 async fn pacing_config_serde_defaults_match_manual_default() {
15784 let from_toml: PacingConfig = toml::from_str("").unwrap();
15787 let manual = PacingConfig::default();
15788
15789 assert_eq!(
15790 from_toml.loop_detection_enabled,
15791 manual.loop_detection_enabled
15792 );
15793 assert_eq!(
15794 from_toml.loop_detection_window_size,
15795 manual.loop_detection_window_size
15796 );
15797 assert_eq!(
15798 from_toml.loop_detection_max_repeats,
15799 manual.loop_detection_max_repeats
15800 );
15801
15802 assert!(from_toml.loop_detection_enabled, "default should be true");
15804 assert_eq!(from_toml.loop_detection_window_size, 20);
15805 assert_eq!(from_toml.loop_detection_max_repeats, 3);
15806 }
15807
15808 const DOCKER_CONFIG_TEMPLATE: &str = r#"
15813workspace_dir = "/construct-data/workspace"
15814config_path = "/construct-data/.construct/config.toml"
15815api_key = ""
15816default_provider = "openrouter"
15817default_model = "anthropic/claude-sonnet-4-20250514"
15818default_temperature = 0.7
15819
15820[gateway]
15821port = 42617
15822host = "[::]"
15823allow_public_bind = true
15824
15825[autonomy]
15826level = "supervised"
15827auto_approve = ["file_read", "file_write", "file_edit", "memory_recall", "memory_store", "web_search_tool", "web_fetch", "calculator", "glob_search", "content_search", "image_info", "weather", "git_operations"]
15828"#;
15829
15830 #[test]
15831 async fn docker_config_template_is_parseable() {
15832 let cfg: Config = toml::from_str(DOCKER_CONFIG_TEMPLATE)
15833 .expect("Docker baked config.toml must be valid TOML that deserialises into Config");
15834
15835 let auto = &cfg.autonomy.auto_approve;
15837 for tool in &[
15838 "file_read",
15839 "file_write",
15840 "file_edit",
15841 "memory_recall",
15842 "memory_store",
15843 "web_search_tool",
15844 "web_fetch",
15845 "calculator",
15846 "glob_search",
15847 "content_search",
15848 "image_info",
15849 "weather",
15850 "git_operations",
15851 ] {
15852 assert!(
15853 auto.iter().any(|t| t == tool),
15854 "Docker config auto_approve missing expected tool: {tool}"
15855 );
15856 }
15857 }
15858
15859 #[test]
15860 async fn cost_enforcement_config_defaults() {
15861 let config = CostEnforcementConfig::default();
15862 assert_eq!(config.mode, "warn");
15863 assert_eq!(config.route_down_model, None);
15864 assert_eq!(config.reserve_percent, 10);
15865 }
15866
15867 #[test]
15868 async fn cost_config_includes_enforcement() {
15869 let config = CostConfig::default();
15870 assert_eq!(config.enforcement.mode, "warn");
15871 assert_eq!(config.enforcement.reserve_percent, 10);
15872 }
15873}