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 let _ = dir.sync_all();
10827 Ok(())
10828 }
10829
10830 #[cfg(not(any(unix, windows)))]
10831 {
10832 let _ = path;
10833 Ok(())
10834 }
10835}
10836
10837#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
10845pub struct SopConfig {
10846 #[serde(default)]
10849 pub sops_dir: Option<String>,
10850
10851 #[serde(default = "default_sop_execution_mode")]
10855 pub default_execution_mode: String,
10856
10857 #[serde(default = "default_sop_max_concurrent_total")]
10859 pub max_concurrent_total: usize,
10860
10861 #[serde(default = "default_sop_approval_timeout_secs")]
10865 pub approval_timeout_secs: u64,
10866
10867 #[serde(default = "default_sop_max_finished_runs")]
10870 pub max_finished_runs: usize,
10871}
10872
10873fn default_sop_execution_mode() -> String {
10874 "supervised".to_string()
10875}
10876
10877fn default_sop_max_concurrent_total() -> usize {
10878 4
10879}
10880
10881fn default_sop_approval_timeout_secs() -> u64 {
10882 300
10883}
10884
10885fn default_sop_max_finished_runs() -> usize {
10886 100
10887}
10888
10889impl Default for SopConfig {
10890 fn default() -> Self {
10891 Self {
10892 sops_dir: None,
10893 default_execution_mode: default_sop_execution_mode(),
10894 max_concurrent_total: default_sop_max_concurrent_total(),
10895 approval_timeout_secs: default_sop_approval_timeout_secs(),
10896 max_finished_runs: default_sop_max_finished_runs(),
10897 }
10898 }
10899}
10900
10901#[cfg(test)]
10902mod tests {
10903 use super::*;
10904 use std::io;
10905 #[cfg(unix)]
10906 use std::os::unix::fs::PermissionsExt;
10907 use std::path::PathBuf;
10908 use std::sync::{Arc, Mutex as StdMutex};
10909 use tempfile::TempDir;
10910 use tokio::sync::{Mutex, MutexGuard};
10911 use tokio::test;
10912 use tokio_stream::StreamExt;
10913 use tokio_stream::wrappers::ReadDirStream;
10914
10915 #[test]
10918 async fn expand_tilde_path_handles_absolute_path() {
10919 let path = expand_tilde_path("/absolute/path");
10920 assert_eq!(path, PathBuf::from("/absolute/path"));
10921 }
10922
10923 #[test]
10924 async fn expand_tilde_path_handles_relative_path() {
10925 let path = expand_tilde_path("relative/path");
10926 assert_eq!(path, PathBuf::from("relative/path"));
10927 }
10928
10929 #[test]
10930 async fn expand_tilde_path_expands_tilde_when_home_set() {
10931 let path = expand_tilde_path("~/.construct");
10934 if std::env::var("HOME").is_ok() {
10937 assert!(
10938 !path.to_string_lossy().starts_with('~'),
10939 "Tilde should be expanded when HOME is set"
10940 );
10941 }
10942 }
10943
10944 fn has_test_table(raw: &str, table: &str) -> bool {
10947 let exact = format!("[{table}]");
10948 let nested = format!("[{table}.");
10949 raw.lines()
10950 .map(str::trim)
10951 .any(|line| line == exact || line.starts_with(&nested))
10952 }
10953
10954 fn parse_test_config(raw: &str) -> Config {
10955 let mut merged = raw.trim().to_string();
10956 for table in [
10957 "data_retention",
10958 "cloud_ops",
10959 "conversational_ai",
10960 "security",
10961 "security_ops",
10962 ] {
10963 if has_test_table(&merged, table) {
10964 continue;
10965 }
10966 if !merged.is_empty() {
10967 merged.push_str("\n\n");
10968 }
10969 merged.push('[');
10970 merged.push_str(table);
10971 merged.push(']');
10972 }
10973 merged.push('\n');
10974 let mut config: Config = toml::from_str(&merged).unwrap();
10975 config.autonomy.ensure_default_auto_approve();
10976 config
10977 }
10978
10979 #[test]
10980 async fn http_request_config_default_has_correct_values() {
10981 let cfg = HttpRequestConfig::default();
10982 assert_eq!(cfg.timeout_secs, 30);
10983 assert_eq!(cfg.max_response_size, 1_000_000);
10984 assert!(cfg.enabled);
10985 assert_eq!(cfg.allowed_domains, vec!["*".to_string()]);
10986 }
10987
10988 #[test]
10989 async fn config_default_has_sane_values() {
10990 let c = Config::default();
10991 assert_eq!(c.default_provider.as_deref(), Some("openrouter"));
10992 assert!(c.default_model.as_deref().unwrap().contains("claude"));
10993 assert!((c.default_temperature - 0.7).abs() < f64::EPSILON);
10994 assert!(c.api_key.is_none());
10995 assert!(!c.skills.open_skills_enabled);
10996 assert!(!c.skills.allow_scripts);
10997 assert_eq!(
10998 c.skills.prompt_injection_mode,
10999 SkillsPromptInjectionMode::Full
11000 );
11001 assert_eq!(c.provider_timeout_secs, 120);
11002 assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
11003 assert!(c.config_path.to_string_lossy().contains("config.toml"));
11004 }
11005
11006 #[derive(Clone, Default)]
11007 struct SharedLogBuffer(Arc<StdMutex<Vec<u8>>>);
11008
11009 struct SharedLogWriter(Arc<StdMutex<Vec<u8>>>);
11010
11011 impl SharedLogBuffer {
11012 fn captured(&self) -> String {
11013 String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
11014 }
11015 }
11016
11017 impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedLogBuffer {
11018 type Writer = SharedLogWriter;
11019
11020 fn make_writer(&'a self) -> Self::Writer {
11021 SharedLogWriter(self.0.clone())
11022 }
11023 }
11024
11025 impl io::Write for SharedLogWriter {
11026 fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
11027 self.0.lock().unwrap().extend_from_slice(buf);
11028 Ok(buf.len())
11029 }
11030
11031 fn flush(&mut self) -> io::Result<()> {
11032 Ok(())
11033 }
11034 }
11035
11036 #[test]
11037 async fn config_dir_creation_error_mentions_openrc_and_path() {
11038 let msg = config_dir_creation_error(Path::new("/etc/construct"));
11039 assert!(msg.contains("/etc/construct"));
11040 assert!(msg.contains("OpenRC"));
11041 assert!(msg.contains("construct"));
11042 }
11043
11044 #[test]
11045 async fn config_schema_export_contains_expected_contract_shape() {
11046 let schema = schemars::schema_for!(Config);
11047 let schema_json = serde_json::to_value(&schema).expect("schema should serialize to json");
11048
11049 assert_eq!(
11050 schema_json
11051 .get("$schema")
11052 .and_then(serde_json::Value::as_str),
11053 Some("https://json-schema.org/draft/2020-12/schema")
11054 );
11055
11056 let properties = schema_json
11057 .get("properties")
11058 .and_then(serde_json::Value::as_object)
11059 .expect("schema should expose top-level properties");
11060
11061 assert!(properties.contains_key("default_provider"));
11062 assert!(properties.contains_key("skills"));
11063 assert!(properties.contains_key("gateway"));
11064 assert!(properties.contains_key("channels_config"));
11065 assert!(!properties.contains_key("workspace_dir"));
11066 assert!(!properties.contains_key("config_path"));
11067
11068 assert!(
11069 schema_json
11070 .get("$defs")
11071 .and_then(serde_json::Value::as_object)
11072 .is_some(),
11073 "schema should include reusable type definitions"
11074 );
11075 }
11076
11077 #[cfg(unix)]
11078 #[test]
11079 async fn save_sets_config_permissions_on_new_file() {
11080 let temp = TempDir::new().expect("temp dir");
11081 let config_path = temp.path().join("config.toml");
11082 let workspace_dir = temp.path().join("workspace");
11083
11084 let mut config = Config::default();
11085 config.config_path = config_path.clone();
11086 config.workspace_dir = workspace_dir;
11087
11088 config.save().await.expect("save config");
11089
11090 let mode = std::fs::metadata(&config_path)
11091 .expect("config metadata")
11092 .permissions()
11093 .mode()
11094 & 0o777;
11095 assert_eq!(mode, 0o600);
11096 }
11097
11098 #[test]
11099 async fn observability_config_default() {
11100 let o = ObservabilityConfig::default();
11101 assert_eq!(o.backend, "none");
11102 assert_eq!(o.runtime_trace_mode, "none");
11103 assert_eq!(o.runtime_trace_path, "state/runtime-trace.jsonl");
11104 assert_eq!(o.runtime_trace_max_entries, 200);
11105 }
11106
11107 #[test]
11108 async fn autonomy_config_default() {
11109 let a = AutonomyConfig::default();
11110 assert_eq!(a.level, AutonomyLevel::Supervised);
11111 assert!(a.workspace_only);
11112 assert!(a.allowed_commands.contains(&"git".to_string()));
11113 assert!(a.allowed_commands.contains(&"cargo".to_string()));
11114 assert!(a.forbidden_paths.contains(&"/etc".to_string()));
11115 assert_eq!(a.max_actions_per_hour, 20);
11116 assert_eq!(a.max_cost_per_day_cents, 500);
11117 assert!(a.require_approval_for_medium_risk);
11118 assert!(a.block_high_risk_commands);
11119 assert!(a.shell_env_passthrough.is_empty());
11120 }
11121
11122 #[test]
11123 async fn runtime_config_default() {
11124 let r = RuntimeConfig::default();
11125 assert_eq!(r.kind, "native");
11126 assert_eq!(r.docker.image, "alpine:3.20");
11127 assert_eq!(r.docker.network, "none");
11128 assert_eq!(r.docker.memory_limit_mb, Some(512));
11129 assert_eq!(r.docker.cpu_limit, Some(1.0));
11130 assert!(r.docker.read_only_rootfs);
11131 assert!(r.docker.mount_workspace);
11132 }
11133
11134 #[test]
11135 async fn heartbeat_config_default() {
11136 let h = HeartbeatConfig::default();
11137 assert!(!h.enabled);
11138 assert_eq!(h.interval_minutes, 30);
11139 assert!(h.message.is_none());
11140 assert!(h.target.is_none());
11141 assert!(h.to.is_none());
11142 }
11143
11144 #[test]
11145 async fn heartbeat_config_parses_delivery_aliases() {
11146 let raw = r#"
11147enabled = true
11148interval_minutes = 10
11149message = "Ping"
11150channel = "telegram"
11151recipient = "42"
11152"#;
11153 let parsed: HeartbeatConfig = toml::from_str(raw).unwrap();
11154 assert!(parsed.enabled);
11155 assert_eq!(parsed.interval_minutes, 10);
11156 assert_eq!(parsed.message.as_deref(), Some("Ping"));
11157 assert_eq!(parsed.target.as_deref(), Some("telegram"));
11158 assert_eq!(parsed.to.as_deref(), Some("42"));
11159 }
11160
11161 #[test]
11162 async fn cron_config_default() {
11163 let c = CronConfig::default();
11164 assert!(c.enabled);
11165 assert_eq!(c.max_run_history, 50);
11166 }
11167
11168 #[test]
11169 async fn cron_config_serde_roundtrip() {
11170 let c = CronConfig {
11171 enabled: false,
11172 catch_up_on_startup: false,
11173 max_run_history: 100,
11174 jobs: Vec::new(),
11175 };
11176 let json = serde_json::to_string(&c).unwrap();
11177 let parsed: CronConfig = serde_json::from_str(&json).unwrap();
11178 assert!(!parsed.enabled);
11179 assert!(!parsed.catch_up_on_startup);
11180 assert_eq!(parsed.max_run_history, 100);
11181 }
11182
11183 #[test]
11184 async fn config_defaults_cron_when_section_missing() {
11185 let toml_str = r#"
11186workspace_dir = "/tmp/workspace"
11187config_path = "/tmp/config.toml"
11188default_temperature = 0.7
11189"#;
11190
11191 let parsed = parse_test_config(toml_str);
11192 assert!(parsed.cron.enabled);
11193 assert!(parsed.cron.catch_up_on_startup);
11194 assert_eq!(parsed.cron.max_run_history, 50);
11195 }
11196
11197 #[test]
11198 async fn memory_config_default_hygiene_settings() {
11199 let m = MemoryConfig::default();
11200 assert_eq!(m.backend, "none");
11201 assert!(m.auto_save);
11202 assert!(m.hygiene_enabled);
11203 assert_eq!(m.archive_after_days, 7);
11204 assert_eq!(m.purge_after_days, 30);
11205 assert_eq!(m.conversation_retention_days, 30);
11206 }
11207
11208 #[test]
11209 async fn storage_provider_config_defaults() {
11210 let storage = StorageConfig::default();
11211 assert!(storage.provider.config.provider.is_empty());
11212 assert!(storage.provider.config.db_url.is_none());
11213 assert_eq!(storage.provider.config.schema, "public");
11214 assert_eq!(storage.provider.config.table, "memories");
11215 assert!(storage.provider.config.connect_timeout_secs.is_none());
11216 }
11217
11218 #[test]
11219 async fn channels_config_default() {
11220 let c = ChannelsConfig::default();
11221 assert!(c.cli);
11222 assert!(c.telegram.is_none());
11223 assert!(c.discord.is_none());
11224 assert!(!c.show_tool_calls);
11225 }
11226
11227 #[test]
11230 async fn config_toml_roundtrip() {
11231 let config = Config {
11232 workspace_dir: PathBuf::from("/tmp/test/workspace"),
11233 config_path: PathBuf::from("/tmp/test/config.toml"),
11234 api_key: Some("sk-test-key".into()),
11235 api_url: None,
11236 api_path: None,
11237 default_provider: Some("openrouter".into()),
11238 default_model: Some("gpt-4o".into()),
11239 model_providers: HashMap::new(),
11240 default_temperature: 0.5,
11241 provider_timeout_secs: 120,
11242 provider_max_tokens: None,
11243 extra_headers: HashMap::new(),
11244 observability: ObservabilityConfig {
11245 backend: "log".into(),
11246 ..ObservabilityConfig::default()
11247 },
11248 autonomy: AutonomyConfig {
11249 level: AutonomyLevel::Full,
11250 workspace_only: false,
11251 allowed_commands: vec!["docker".into()],
11252 forbidden_paths: vec!["/secret".into()],
11253 max_actions_per_hour: 50,
11254 max_cost_per_day_cents: 1000,
11255 require_approval_for_medium_risk: false,
11256 block_high_risk_commands: true,
11257 shell_env_passthrough: vec!["DATABASE_URL".into()],
11258 auto_approve: vec!["file_read".into()],
11259 always_ask: vec![],
11260 allowed_roots: vec![],
11261 non_cli_excluded_tools: vec![],
11262 },
11263 trust: crate::trust::TrustConfig::default(),
11264 backup: BackupConfig::default(),
11265 data_retention: DataRetentionConfig::default(),
11266 cloud_ops: CloudOpsConfig::default(),
11267 conversational_ai: ConversationalAiConfig::default(),
11268 security: SecurityConfig::default(),
11269 security_ops: SecurityOpsConfig::default(),
11270 runtime: RuntimeConfig {
11271 kind: "docker".into(),
11272 ..RuntimeConfig::default()
11273 },
11274 reliability: ReliabilityConfig::default(),
11275 scheduler: SchedulerConfig::default(),
11276 skills: SkillsConfig::default(),
11277 pipeline: PipelineConfig::default(),
11278 model_routes: Vec::new(),
11279 embedding_routes: Vec::new(),
11280 query_classification: QueryClassificationConfig::default(),
11281 heartbeat: HeartbeatConfig {
11282 enabled: true,
11283 interval_minutes: 15,
11284 two_phase: true,
11285 message: Some("Check London time".into()),
11286 target: Some("telegram".into()),
11287 to: Some("123456".into()),
11288 ..HeartbeatConfig::default()
11289 },
11290 cron: CronConfig::default(),
11291 channels_config: ChannelsConfig {
11292 cli: true,
11293 telegram: Some(TelegramConfig {
11294 bot_token: "123:ABC".into(),
11295 allowed_users: vec!["user1".into()],
11296 stream_mode: StreamMode::default(),
11297 draft_update_interval_ms: default_draft_update_interval_ms(),
11298 interrupt_on_new_message: false,
11299 mention_only: false,
11300 ack_reactions: None,
11301 proxy_url: None,
11302 notification_chat_id: None,
11303 }),
11304 discord: None,
11305 discord_history: None,
11306 slack: None,
11307 mattermost: None,
11308 webhook: None,
11309 imessage: None,
11310 matrix: None,
11311 signal: None,
11312 whatsapp: None,
11313 linq: None,
11314 wati: None,
11315 nextcloud_talk: None,
11316 email: None,
11317 gmail_push: None,
11318 irc: None,
11319 lark: None,
11320 feishu: None,
11321 dingtalk: None,
11322 wecom: None,
11323 qq: None,
11324 twitter: None,
11325 mochat: None,
11326 #[cfg(feature = "channel-nostr")]
11327 nostr: None,
11328 clawdtalk: None,
11329 reddit: None,
11330 bluesky: None,
11331 voice_call: None,
11332 #[cfg(feature = "voice-wake")]
11333 voice_wake: None,
11334 message_timeout_secs: 300,
11335 ack_reactions: true,
11336 show_tool_calls: true,
11337 session_persistence: true,
11338 session_backend: default_session_backend(),
11339 session_ttl_hours: 0,
11340 debounce_ms: 0,
11341 },
11342 memory: MemoryConfig::default(),
11343 storage: StorageConfig::default(),
11344 tunnel: TunnelConfig::default(),
11345 gateway: GatewayConfig::default(),
11346 composio: ComposioConfig::default(),
11347 microsoft365: Microsoft365Config::default(),
11348 secrets: SecretsConfig::default(),
11349 browser: BrowserConfig::default(),
11350 browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig::default(),
11351 http_request: HttpRequestConfig::default(),
11352 multimodal: MultimodalConfig::default(),
11353 media_pipeline: MediaPipelineConfig::default(),
11354 web_fetch: WebFetchConfig::default(),
11355 link_enricher: LinkEnricherConfig::default(),
11356 text_browser: TextBrowserConfig::default(),
11357 web_search: WebSearchConfig::default(),
11358 project_intel: ProjectIntelConfig::default(),
11359 google_workspace: GoogleWorkspaceConfig::default(),
11360 proxy: ProxyConfig::default(),
11361 agent: AgentConfig::default(),
11362 pacing: PacingConfig::default(),
11363 identity: IdentityConfig::default(),
11364 cost: CostConfig::default(),
11365 peripherals: PeripheralsConfig::default(),
11366 delegate: DelegateToolConfig::default(),
11367 agents: HashMap::new(),
11368 swarms: HashMap::new(),
11369 hooks: HooksConfig::default(),
11370 hardware: HardwareConfig::default(),
11371 transcription: TranscriptionConfig::default(),
11372 tts: TtsConfig::default(),
11373 mcp: McpConfig::default(),
11374 kumiho: KumihoConfig::default(),
11375 operator: OperatorConfig::default(),
11376 nodes: NodesConfig::default(),
11377 clawhub: ClawHubConfig::default(),
11378 workspace: WorkspaceConfig::default(),
11379 notion: NotionConfig::default(),
11380 jira: JiraConfig::default(),
11381 node_transport: NodeTransportConfig::default(),
11382 linkedin: LinkedInConfig::default(),
11383 image_gen: ImageGenConfig::default(),
11384 plugins: PluginsConfig::default(),
11385 locale: None,
11386 verifiable_intent: VerifiableIntentConfig::default(),
11387 claude_code: ClaudeCodeConfig::default(),
11388 claude_code_runner: ClaudeCodeRunnerConfig::default(),
11389 codex_cli: CodexCliConfig::default(),
11390 gemini_cli: GeminiCliConfig::default(),
11391 opencode_cli: OpenCodeCliConfig::default(),
11392 sop: SopConfig::default(),
11393 shell_tool: ShellToolConfig::default(),
11394 };
11395
11396 let toml_str = toml::to_string_pretty(&config).unwrap();
11397 let parsed = parse_test_config(&toml_str);
11398
11399 assert_eq!(parsed.api_key, config.api_key);
11400 assert_eq!(parsed.default_provider, config.default_provider);
11401 assert_eq!(parsed.default_model, config.default_model);
11402 assert!((parsed.default_temperature - config.default_temperature).abs() < f64::EPSILON);
11403 assert_eq!(parsed.observability.backend, "log");
11404 assert_eq!(parsed.observability.runtime_trace_mode, "none");
11405 assert_eq!(parsed.autonomy.level, AutonomyLevel::Full);
11406 assert!(!parsed.autonomy.workspace_only);
11407 assert_eq!(parsed.runtime.kind, "docker");
11408 assert!(parsed.heartbeat.enabled);
11409 assert_eq!(parsed.heartbeat.interval_minutes, 15);
11410 assert_eq!(
11411 parsed.heartbeat.message.as_deref(),
11412 Some("Check London time")
11413 );
11414 assert_eq!(parsed.heartbeat.target.as_deref(), Some("telegram"));
11415 assert_eq!(parsed.heartbeat.to.as_deref(), Some("123456"));
11416 assert!(parsed.channels_config.telegram.is_some());
11417 assert_eq!(
11418 parsed.channels_config.telegram.unwrap().bot_token,
11419 "123:ABC"
11420 );
11421 }
11422
11423 #[test]
11424 async fn config_minimal_toml_uses_defaults() {
11425 let minimal = r#"
11426workspace_dir = "/tmp/ws"
11427config_path = "/tmp/config.toml"
11428default_temperature = 0.7
11429"#;
11430 let parsed = parse_test_config(minimal);
11431 assert!(parsed.api_key.is_none());
11432 assert!(parsed.default_provider.is_none());
11433 assert_eq!(parsed.observability.backend, "none");
11434 assert_eq!(parsed.observability.runtime_trace_mode, "none");
11435 assert_eq!(parsed.autonomy.level, AutonomyLevel::Supervised);
11436 assert_eq!(parsed.runtime.kind, "native");
11437 assert!(!parsed.heartbeat.enabled);
11438 assert!(parsed.channels_config.cli);
11439 assert!(parsed.memory.hygiene_enabled);
11440 assert_eq!(parsed.memory.archive_after_days, 7);
11441 assert_eq!(parsed.memory.purge_after_days, 30);
11442 assert_eq!(parsed.memory.conversation_retention_days, 30);
11443 assert_eq!(parsed.provider_timeout_secs, 120);
11445 }
11446
11447 #[test]
11450 async fn autonomy_section_is_not_silently_ignored() {
11451 let raw = r#"
11452default_temperature = 0.7
11453
11454[autonomy]
11455level = "full"
11456max_actions_per_hour = 99
11457auto_approve = ["file_read", "memory_recall", "http_request"]
11458"#;
11459 let parsed = parse_test_config(raw);
11460 assert_eq!(
11461 parsed.autonomy.level,
11462 AutonomyLevel::Full,
11463 "autonomy.level must be parsed from config (was silently defaulting to Supervised)"
11464 );
11465 assert_eq!(
11466 parsed.autonomy.max_actions_per_hour, 99,
11467 "autonomy.max_actions_per_hour must be parsed from config"
11468 );
11469 assert!(
11470 parsed
11471 .autonomy
11472 .auto_approve
11473 .contains(&"http_request".to_string()),
11474 "autonomy.auto_approve must include http_request from config"
11475 );
11476 }
11477
11478 #[test]
11481 async fn auto_approve_merges_user_entries_with_defaults() {
11482 let raw = r#"
11483default_temperature = 0.7
11484
11485[autonomy]
11486auto_approve = ["my_custom_tool", "another_tool"]
11487"#;
11488 let parsed = parse_test_config(raw);
11489 assert!(
11491 parsed
11492 .autonomy
11493 .auto_approve
11494 .contains(&"my_custom_tool".to_string()),
11495 "user-supplied tool must remain in auto_approve"
11496 );
11497 assert!(
11498 parsed
11499 .autonomy
11500 .auto_approve
11501 .contains(&"another_tool".to_string()),
11502 "user-supplied tool must remain in auto_approve"
11503 );
11504 for default_tool in &[
11506 "file_read",
11507 "memory_recall",
11508 "weather",
11509 "calculator",
11510 "web_fetch",
11511 ] {
11512 assert!(
11513 parsed
11514 .autonomy
11515 .auto_approve
11516 .contains(&String::from(*default_tool)),
11517 "default tool '{default_tool}' must be present in auto_approve even when user provides custom list"
11518 );
11519 }
11520 }
11521
11522 #[test]
11524 async fn auto_approve_empty_list_gets_defaults() {
11525 let raw = r#"
11526default_temperature = 0.7
11527
11528[autonomy]
11529auto_approve = []
11530"#;
11531 let parsed = parse_test_config(raw);
11532 let defaults = default_auto_approve();
11533 for tool in &defaults {
11534 assert!(
11535 parsed.autonomy.auto_approve.contains(tool),
11536 "default tool '{tool}' must be present even when user sets auto_approve = []"
11537 );
11538 }
11539 }
11540
11541 #[test]
11543 async fn auto_approve_defaults_when_no_autonomy_section() {
11544 let raw = r#"
11545default_temperature = 0.7
11546"#;
11547 let parsed = parse_test_config(raw);
11548 let defaults = default_auto_approve();
11549 for tool in &defaults {
11550 assert!(
11551 parsed.autonomy.auto_approve.contains(tool),
11552 "default tool '{tool}' must be present when no [autonomy] section"
11553 );
11554 }
11555 }
11556
11557 #[test]
11560 async fn auto_approve_no_duplicates() {
11561 let raw = r#"
11562default_temperature = 0.7
11563
11564[autonomy]
11565auto_approve = ["weather", "file_read"]
11566"#;
11567 let parsed = parse_test_config(raw);
11568 let weather_count = parsed
11569 .autonomy
11570 .auto_approve
11571 .iter()
11572 .filter(|t| *t == "weather")
11573 .count();
11574 assert_eq!(weather_count, 1, "weather must not be duplicated");
11575 let file_read_count = parsed
11576 .autonomy
11577 .auto_approve
11578 .iter()
11579 .filter(|t| *t == "file_read")
11580 .count();
11581 assert_eq!(file_read_count, 1, "file_read must not be duplicated");
11582 }
11583
11584 #[test]
11585 async fn provider_timeout_secs_parses_from_toml() {
11586 let raw = r#"
11587default_temperature = 0.7
11588provider_timeout_secs = 300
11589"#;
11590 let parsed = parse_test_config(raw);
11591 assert_eq!(parsed.provider_timeout_secs, 300);
11592 }
11593
11594 #[test]
11595 async fn parse_extra_headers_env_basic() {
11596 let headers = parse_extra_headers_env("User-Agent:MyApp/1.0,X-Title:construct");
11597 assert_eq!(headers.len(), 2);
11598 assert_eq!(
11599 headers[0],
11600 ("User-Agent".to_string(), "MyApp/1.0".to_string())
11601 );
11602 assert_eq!(headers[1], ("X-Title".to_string(), "construct".to_string()));
11603 }
11604
11605 #[test]
11606 async fn parse_extra_headers_env_with_url_value() {
11607 let headers = parse_extra_headers_env("HTTP-Referer:https://github.com/KumihoIO/construct");
11608 assert_eq!(headers.len(), 1);
11609 assert_eq!(headers[0].0, "HTTP-Referer");
11611 assert_eq!(headers[0].1, "https://github.com/KumihoIO/construct");
11612 }
11613
11614 #[test]
11615 async fn parse_extra_headers_env_empty_string() {
11616 let headers = parse_extra_headers_env("");
11617 assert!(headers.is_empty());
11618 }
11619
11620 #[test]
11621 async fn parse_extra_headers_env_whitespace_trimming() {
11622 let headers = parse_extra_headers_env(" X-Title : construct , User-Agent : cli/1.0 ");
11623 assert_eq!(headers.len(), 2);
11624 assert_eq!(headers[0], ("X-Title".to_string(), "construct".to_string()));
11625 assert_eq!(
11626 headers[1],
11627 ("User-Agent".to_string(), "cli/1.0".to_string())
11628 );
11629 }
11630
11631 #[test]
11632 async fn parse_extra_headers_env_skips_malformed() {
11633 let headers = parse_extra_headers_env("X-Valid:value,no-colon-here,Another:ok");
11634 assert_eq!(headers.len(), 2);
11635 assert_eq!(headers[0], ("X-Valid".to_string(), "value".to_string()));
11636 assert_eq!(headers[1], ("Another".to_string(), "ok".to_string()));
11637 }
11638
11639 #[test]
11640 async fn parse_extra_headers_env_skips_empty_key() {
11641 let headers = parse_extra_headers_env(":value,X-Valid:ok");
11642 assert_eq!(headers.len(), 1);
11643 assert_eq!(headers[0], ("X-Valid".to_string(), "ok".to_string()));
11644 }
11645
11646 #[test]
11647 async fn parse_extra_headers_env_allows_empty_value() {
11648 let headers = parse_extra_headers_env("X-Empty:");
11649 assert_eq!(headers.len(), 1);
11650 assert_eq!(headers[0], ("X-Empty".to_string(), String::new()));
11651 }
11652
11653 #[test]
11654 async fn parse_extra_headers_env_trailing_comma() {
11655 let headers = parse_extra_headers_env("X-Title:construct,");
11656 assert_eq!(headers.len(), 1);
11657 assert_eq!(headers[0], ("X-Title".to_string(), "construct".to_string()));
11658 }
11659
11660 #[test]
11661 async fn extra_headers_parses_from_toml() {
11662 let raw = r#"
11663default_temperature = 0.7
11664
11665[extra_headers]
11666User-Agent = "MyApp/1.0"
11667X-Title = "construct"
11668"#;
11669 let parsed = parse_test_config(raw);
11670 assert_eq!(parsed.extra_headers.len(), 2);
11671 assert_eq!(parsed.extra_headers.get("User-Agent").unwrap(), "MyApp/1.0");
11672 assert_eq!(parsed.extra_headers.get("X-Title").unwrap(), "construct");
11673 }
11674
11675 #[test]
11676 async fn extra_headers_defaults_to_empty() {
11677 let raw = r#"
11678default_temperature = 0.7
11679"#;
11680 let parsed = parse_test_config(raw);
11681 assert!(parsed.extra_headers.is_empty());
11682 }
11683
11684 #[test]
11685 async fn storage_provider_dburl_alias_deserializes() {
11686 let raw = r#"
11687default_temperature = 0.7
11688
11689[storage.provider.config]
11690provider = "qdrant"
11691dbURL = "http://localhost:6333"
11692schema = "public"
11693table = "memories"
11694connect_timeout_secs = 12
11695"#;
11696
11697 let parsed = parse_test_config(raw);
11698 assert_eq!(parsed.storage.provider.config.provider, "qdrant");
11699 assert_eq!(
11700 parsed.storage.provider.config.db_url.as_deref(),
11701 Some("http://localhost:6333")
11702 );
11703 assert_eq!(parsed.storage.provider.config.schema, "public");
11704 assert_eq!(parsed.storage.provider.config.table, "memories");
11705 assert_eq!(
11706 parsed.storage.provider.config.connect_timeout_secs,
11707 Some(12)
11708 );
11709 }
11710
11711 #[test]
11712 async fn runtime_reasoning_enabled_deserializes() {
11713 let raw = r#"
11714default_temperature = 0.7
11715
11716[runtime]
11717reasoning_enabled = false
11718"#;
11719
11720 let parsed = parse_test_config(raw);
11721 assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
11722 }
11723
11724 #[test]
11725 async fn runtime_reasoning_effort_deserializes() {
11726 let raw = r#"
11727default_temperature = 0.7
11728
11729[runtime]
11730reasoning_effort = "HIGH"
11731"#;
11732
11733 let parsed: Config = toml::from_str(raw).unwrap();
11734 assert_eq!(parsed.runtime.reasoning_effort.as_deref(), Some("high"));
11735 }
11736
11737 #[test]
11738 async fn runtime_reasoning_effort_rejects_invalid_values() {
11739 let raw = r#"
11740default_temperature = 0.7
11741
11742[runtime]
11743reasoning_effort = "turbo"
11744"#;
11745
11746 let error = toml::from_str::<Config>(raw).expect_err("invalid value should fail");
11747 assert!(error.to_string().contains("reasoning_effort"));
11748 }
11749
11750 #[test]
11751 async fn agent_config_defaults() {
11752 let cfg = AgentConfig::default();
11753 assert!(cfg.compact_context);
11754 assert_eq!(cfg.max_tool_iterations, 10);
11755 assert_eq!(cfg.max_history_messages, 50);
11756 assert!(!cfg.parallel_tools);
11757 assert_eq!(cfg.tool_dispatcher, "auto");
11758 }
11759
11760 #[test]
11761 async fn agent_config_deserializes() {
11762 let raw = r#"
11763default_temperature = 0.7
11764[agent]
11765compact_context = true
11766max_tool_iterations = 20
11767max_history_messages = 80
11768parallel_tools = true
11769tool_dispatcher = "xml"
11770"#;
11771 let parsed = parse_test_config(raw);
11772 assert!(parsed.agent.compact_context);
11773 assert_eq!(parsed.agent.max_tool_iterations, 20);
11774 assert_eq!(parsed.agent.max_history_messages, 80);
11775 assert!(parsed.agent.parallel_tools);
11776 assert_eq!(parsed.agent.tool_dispatcher, "xml");
11777 }
11778
11779 #[test]
11780 async fn pacing_config_defaults_are_all_none_or_empty() {
11781 let cfg = PacingConfig::default();
11782 assert!(cfg.step_timeout_secs.is_none());
11783 assert!(cfg.loop_detection_min_elapsed_secs.is_none());
11784 assert!(cfg.loop_ignore_tools.is_empty());
11785 assert!(cfg.message_timeout_scale_max.is_none());
11786 }
11787
11788 #[test]
11789 async fn pacing_config_deserializes_from_toml() {
11790 let raw = r#"
11791default_temperature = 0.7
11792[pacing]
11793step_timeout_secs = 120
11794loop_detection_min_elapsed_secs = 60
11795loop_ignore_tools = ["browser_screenshot", "browser_navigate"]
11796message_timeout_scale_max = 8
11797"#;
11798 let parsed: Config = toml::from_str(raw).unwrap();
11799 assert_eq!(parsed.pacing.step_timeout_secs, Some(120));
11800 assert_eq!(parsed.pacing.loop_detection_min_elapsed_secs, Some(60));
11801 assert_eq!(
11802 parsed.pacing.loop_ignore_tools,
11803 vec!["browser_screenshot", "browser_navigate"]
11804 );
11805 assert_eq!(parsed.pacing.message_timeout_scale_max, Some(8));
11806 }
11807
11808 #[test]
11809 async fn pacing_config_absent_preserves_defaults() {
11810 let raw = r#"
11811default_temperature = 0.7
11812"#;
11813 let parsed: Config = toml::from_str(raw).unwrap();
11814 assert!(parsed.pacing.step_timeout_secs.is_none());
11815 assert!(parsed.pacing.loop_detection_min_elapsed_secs.is_none());
11816 assert!(parsed.pacing.loop_ignore_tools.is_empty());
11817 assert!(parsed.pacing.message_timeout_scale_max.is_none());
11818 }
11819
11820 #[tokio::test]
11821 async fn sync_directory_handles_existing_directory() {
11822 let dir = std::env::temp_dir().join(format!(
11823 "construct_test_sync_directory_{}",
11824 uuid::Uuid::new_v4()
11825 ));
11826 fs::create_dir_all(&dir).await.unwrap();
11827
11828 sync_directory(&dir).await.unwrap();
11829
11830 let _ = fs::remove_dir_all(&dir).await;
11831 }
11832
11833 #[tokio::test]
11834 async fn config_save_and_load_tmpdir() {
11835 let dir = std::env::temp_dir().join("construct_test_config");
11836 let _ = fs::remove_dir_all(&dir).await;
11837 fs::create_dir_all(&dir).await.unwrap();
11838
11839 let config_path = dir.join("config.toml");
11840 let config = Config {
11841 workspace_dir: dir.join("workspace"),
11842 config_path: config_path.clone(),
11843 api_key: Some("sk-roundtrip".into()),
11844 api_url: None,
11845 api_path: None,
11846 default_provider: Some("openrouter".into()),
11847 default_model: Some("test-model".into()),
11848 model_providers: HashMap::new(),
11849 default_temperature: 0.9,
11850 provider_timeout_secs: 120,
11851 provider_max_tokens: None,
11852 extra_headers: HashMap::new(),
11853 observability: ObservabilityConfig::default(),
11854 autonomy: AutonomyConfig::default(),
11855 trust: crate::trust::TrustConfig::default(),
11856 backup: BackupConfig::default(),
11857 data_retention: DataRetentionConfig::default(),
11858 cloud_ops: CloudOpsConfig::default(),
11859 conversational_ai: ConversationalAiConfig::default(),
11860 security: SecurityConfig::default(),
11861 security_ops: SecurityOpsConfig::default(),
11862 runtime: RuntimeConfig::default(),
11863 reliability: ReliabilityConfig::default(),
11864 scheduler: SchedulerConfig::default(),
11865 skills: SkillsConfig::default(),
11866 pipeline: PipelineConfig::default(),
11867 model_routes: Vec::new(),
11868 embedding_routes: Vec::new(),
11869 query_classification: QueryClassificationConfig::default(),
11870 heartbeat: HeartbeatConfig::default(),
11871 cron: CronConfig::default(),
11872 channels_config: ChannelsConfig::default(),
11873 memory: MemoryConfig::default(),
11874 storage: StorageConfig::default(),
11875 tunnel: TunnelConfig::default(),
11876 gateway: GatewayConfig::default(),
11877 composio: ComposioConfig::default(),
11878 microsoft365: Microsoft365Config::default(),
11879 secrets: SecretsConfig::default(),
11880 browser: BrowserConfig::default(),
11881 browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig::default(),
11882 http_request: HttpRequestConfig::default(),
11883 multimodal: MultimodalConfig::default(),
11884 media_pipeline: MediaPipelineConfig::default(),
11885 web_fetch: WebFetchConfig::default(),
11886 link_enricher: LinkEnricherConfig::default(),
11887 text_browser: TextBrowserConfig::default(),
11888 web_search: WebSearchConfig::default(),
11889 project_intel: ProjectIntelConfig::default(),
11890 google_workspace: GoogleWorkspaceConfig::default(),
11891 proxy: ProxyConfig::default(),
11892 agent: AgentConfig::default(),
11893 pacing: PacingConfig::default(),
11894 identity: IdentityConfig::default(),
11895 cost: CostConfig::default(),
11896 peripherals: PeripheralsConfig::default(),
11897 delegate: DelegateToolConfig::default(),
11898 agents: HashMap::new(),
11899 swarms: HashMap::new(),
11900 hooks: HooksConfig::default(),
11901 hardware: HardwareConfig::default(),
11902 transcription: TranscriptionConfig::default(),
11903 tts: TtsConfig::default(),
11904 mcp: McpConfig::default(),
11905 kumiho: KumihoConfig::default(),
11906 operator: OperatorConfig::default(),
11907 nodes: NodesConfig::default(),
11908 clawhub: ClawHubConfig::default(),
11909 workspace: WorkspaceConfig::default(),
11910 notion: NotionConfig::default(),
11911 jira: JiraConfig::default(),
11912 node_transport: NodeTransportConfig::default(),
11913 linkedin: LinkedInConfig::default(),
11914 image_gen: ImageGenConfig::default(),
11915 plugins: PluginsConfig::default(),
11916 locale: None,
11917 verifiable_intent: VerifiableIntentConfig::default(),
11918 claude_code: ClaudeCodeConfig::default(),
11919 claude_code_runner: ClaudeCodeRunnerConfig::default(),
11920 codex_cli: CodexCliConfig::default(),
11921 gemini_cli: GeminiCliConfig::default(),
11922 opencode_cli: OpenCodeCliConfig::default(),
11923 sop: SopConfig::default(),
11924 shell_tool: ShellToolConfig::default(),
11925 };
11926
11927 config.save().await.unwrap();
11928 assert!(config_path.exists());
11929
11930 let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
11931 let loaded: Config = toml::from_str(&contents).unwrap();
11932 assert!(
11933 loaded
11934 .api_key
11935 .as_deref()
11936 .is_some_and(crate::security::SecretStore::is_encrypted)
11937 );
11938 let store = crate::security::SecretStore::new(&dir, true);
11939 let decrypted = store.decrypt(loaded.api_key.as_deref().unwrap()).unwrap();
11940 assert_eq!(decrypted, "sk-roundtrip");
11941 assert_eq!(loaded.default_model.as_deref(), Some("test-model"));
11942 assert!((loaded.default_temperature - 0.9).abs() < f64::EPSILON);
11943
11944 let _ = fs::remove_dir_all(&dir).await;
11945 }
11946
11947 #[tokio::test]
11948 async fn config_save_encrypts_nested_credentials() {
11949 let dir = std::env::temp_dir().join(format!(
11950 "construct_test_nested_credentials_{}",
11951 uuid::Uuid::new_v4()
11952 ));
11953 fs::create_dir_all(&dir).await.unwrap();
11954
11955 let mut config = Config::default();
11956 config.workspace_dir = dir.join("workspace");
11957 config.config_path = dir.join("config.toml");
11958 config.api_key = Some("root-credential".into());
11959 config.composio.api_key = Some("composio-credential".into());
11960 config.browser.computer_use.api_key = Some("browser-credential".into());
11961 config.web_search.brave_api_key = Some("brave-credential".into());
11962 config.storage.provider.config.db_url = Some("postgres://user:pw@host/db".into());
11963 config.channels_config.feishu = Some(FeishuConfig {
11964 app_id: "cli_feishu_123".into(),
11965 app_secret: "feishu-secret".into(),
11966 encrypt_key: Some("feishu-encrypt".into()),
11967 verification_token: Some("feishu-verify".into()),
11968 allowed_users: vec!["*".into()],
11969 receive_mode: LarkReceiveMode::Websocket,
11970 port: None,
11971 proxy_url: None,
11972 });
11973
11974 config.agents.insert(
11975 "worker".into(),
11976 DelegateAgentConfig {
11977 provider: "openrouter".into(),
11978 model: "model-test".into(),
11979 system_prompt: None,
11980 api_key: Some("agent-credential".into()),
11981 temperature: None,
11982 max_depth: 3,
11983 agentic: false,
11984 allowed_tools: Vec::new(),
11985 max_iterations: 10,
11986 timeout_secs: None,
11987 agentic_timeout_secs: None,
11988 skills_directory: None,
11989 },
11990 );
11991
11992 config.save().await.unwrap();
11993
11994 let contents = tokio::fs::read_to_string(config.config_path.clone())
11995 .await
11996 .unwrap();
11997 let stored: Config = toml::from_str(&contents).unwrap();
11998 let store = crate::security::SecretStore::new(&dir, true);
11999
12000 let root_encrypted = stored.api_key.as_deref().unwrap();
12001 assert!(crate::security::SecretStore::is_encrypted(root_encrypted));
12002 assert_eq!(store.decrypt(root_encrypted).unwrap(), "root-credential");
12003
12004 let composio_encrypted = stored.composio.api_key.as_deref().unwrap();
12005 assert!(crate::security::SecretStore::is_encrypted(
12006 composio_encrypted
12007 ));
12008 assert_eq!(
12009 store.decrypt(composio_encrypted).unwrap(),
12010 "composio-credential"
12011 );
12012
12013 let browser_encrypted = stored.browser.computer_use.api_key.as_deref().unwrap();
12014 assert!(crate::security::SecretStore::is_encrypted(
12015 browser_encrypted
12016 ));
12017 assert_eq!(
12018 store.decrypt(browser_encrypted).unwrap(),
12019 "browser-credential"
12020 );
12021
12022 let web_search_encrypted = stored.web_search.brave_api_key.as_deref().unwrap();
12023 assert!(crate::security::SecretStore::is_encrypted(
12024 web_search_encrypted
12025 ));
12026 assert_eq!(
12027 store.decrypt(web_search_encrypted).unwrap(),
12028 "brave-credential"
12029 );
12030
12031 let worker = stored.agents.get("worker").unwrap();
12032 let worker_encrypted = worker.api_key.as_deref().unwrap();
12033 assert!(crate::security::SecretStore::is_encrypted(worker_encrypted));
12034 assert_eq!(store.decrypt(worker_encrypted).unwrap(), "agent-credential");
12035
12036 let storage_db_url = stored.storage.provider.config.db_url.as_deref().unwrap();
12037 assert!(crate::security::SecretStore::is_encrypted(storage_db_url));
12038 assert_eq!(
12039 store.decrypt(storage_db_url).unwrap(),
12040 "postgres://user:pw@host/db"
12041 );
12042
12043 let feishu = stored.channels_config.feishu.as_ref().unwrap();
12044 assert!(crate::security::SecretStore::is_encrypted(
12045 &feishu.app_secret
12046 ));
12047 assert_eq!(store.decrypt(&feishu.app_secret).unwrap(), "feishu-secret");
12048 assert!(
12049 feishu
12050 .encrypt_key
12051 .as_deref()
12052 .is_some_and(crate::security::SecretStore::is_encrypted)
12053 );
12054 assert_eq!(
12055 store
12056 .decrypt(feishu.encrypt_key.as_deref().unwrap())
12057 .unwrap(),
12058 "feishu-encrypt"
12059 );
12060 assert!(
12061 feishu
12062 .verification_token
12063 .as_deref()
12064 .is_some_and(crate::security::SecretStore::is_encrypted)
12065 );
12066 assert_eq!(
12067 store
12068 .decrypt(feishu.verification_token.as_deref().unwrap())
12069 .unwrap(),
12070 "feishu-verify"
12071 );
12072
12073 let _ = fs::remove_dir_all(&dir).await;
12074 }
12075
12076 #[tokio::test]
12077 async fn config_save_atomic_cleanup() {
12078 let dir =
12079 std::env::temp_dir().join(format!("construct_test_config_{}", uuid::Uuid::new_v4()));
12080 fs::create_dir_all(&dir).await.unwrap();
12081
12082 let config_path = dir.join("config.toml");
12083 let mut config = Config::default();
12084 config.workspace_dir = dir.join("workspace");
12085 config.config_path = config_path.clone();
12086 config.default_model = Some("model-a".into());
12087 config.save().await.unwrap();
12088 assert!(config_path.exists());
12089
12090 config.default_model = Some("model-b".into());
12091 config.save().await.unwrap();
12092
12093 let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
12094 assert!(contents.contains("model-b"));
12095
12096 let names: Vec<String> = ReadDirStream::new(fs::read_dir(&dir).await.unwrap())
12097 .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
12098 .collect()
12099 .await;
12100 assert!(!names.iter().any(|name| name.contains(".tmp-")));
12101 assert!(!names.iter().any(|name| name.ends_with(".bak")));
12102
12103 let _ = fs::remove_dir_all(&dir).await;
12104 }
12105
12106 #[test]
12109 async fn telegram_config_serde() {
12110 let tc = TelegramConfig {
12111 bot_token: "123:XYZ".into(),
12112 allowed_users: vec!["alice".into(), "bob".into()],
12113 stream_mode: StreamMode::Partial,
12114 draft_update_interval_ms: 500,
12115 interrupt_on_new_message: true,
12116 mention_only: false,
12117 ack_reactions: None,
12118 proxy_url: None,
12119 notification_chat_id: None,
12120 };
12121 let json = serde_json::to_string(&tc).unwrap();
12122 let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
12123 assert_eq!(parsed.bot_token, "123:XYZ");
12124 assert_eq!(parsed.allowed_users.len(), 2);
12125 assert_eq!(parsed.stream_mode, StreamMode::Partial);
12126 assert_eq!(parsed.draft_update_interval_ms, 500);
12127 assert!(parsed.interrupt_on_new_message);
12128 }
12129
12130 #[test]
12131 async fn telegram_config_defaults_stream_off() {
12132 let json = r#"{"bot_token":"tok","allowed_users":[]}"#;
12133 let parsed: TelegramConfig = serde_json::from_str(json).unwrap();
12134 assert_eq!(parsed.stream_mode, StreamMode::Off);
12135 assert_eq!(parsed.draft_update_interval_ms, 1000);
12136 assert!(!parsed.interrupt_on_new_message);
12137 }
12138
12139 #[test]
12140 async fn discord_config_serde() {
12141 let dc = DiscordConfig {
12142 bot_token: "discord-token".into(),
12143 guild_id: Some("12345".into()),
12144 allowed_users: vec![],
12145 listen_to_bots: false,
12146 interrupt_on_new_message: false,
12147 mention_only: false,
12148 proxy_url: None,
12149 stream_mode: StreamMode::default(),
12150 draft_update_interval_ms: 1000,
12151 multi_message_delay_ms: 800,
12152 notification_channel_id: None,
12153 };
12154 let json = serde_json::to_string(&dc).unwrap();
12155 let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
12156 assert_eq!(parsed.bot_token, "discord-token");
12157 assert_eq!(parsed.guild_id.as_deref(), Some("12345"));
12158 }
12159
12160 #[test]
12161 async fn discord_config_optional_guild() {
12162 let dc = DiscordConfig {
12163 bot_token: "tok".into(),
12164 guild_id: None,
12165 allowed_users: vec![],
12166 listen_to_bots: false,
12167 interrupt_on_new_message: false,
12168 mention_only: false,
12169 proxy_url: None,
12170 stream_mode: StreamMode::default(),
12171 draft_update_interval_ms: 1000,
12172 multi_message_delay_ms: 800,
12173 notification_channel_id: None,
12174 };
12175 let json = serde_json::to_string(&dc).unwrap();
12176 let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
12177 assert!(parsed.guild_id.is_none());
12178 }
12179
12180 #[test]
12183 async fn imessage_config_serde() {
12184 let ic = IMessageConfig {
12185 allowed_contacts: vec!["+1234567890".into(), "user@icloud.com".into()],
12186 };
12187 let json = serde_json::to_string(&ic).unwrap();
12188 let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
12189 assert_eq!(parsed.allowed_contacts.len(), 2);
12190 assert_eq!(parsed.allowed_contacts[0], "+1234567890");
12191 }
12192
12193 #[test]
12194 async fn imessage_config_empty_contacts() {
12195 let ic = IMessageConfig {
12196 allowed_contacts: vec![],
12197 };
12198 let json = serde_json::to_string(&ic).unwrap();
12199 let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
12200 assert!(parsed.allowed_contacts.is_empty());
12201 }
12202
12203 #[test]
12204 async fn imessage_config_wildcard() {
12205 let ic = IMessageConfig {
12206 allowed_contacts: vec!["*".into()],
12207 };
12208 let toml_str = toml::to_string(&ic).unwrap();
12209 let parsed: IMessageConfig = toml::from_str(&toml_str).unwrap();
12210 assert_eq!(parsed.allowed_contacts, vec!["*"]);
12211 }
12212
12213 #[test]
12214 async fn matrix_config_serde() {
12215 let mc = MatrixConfig {
12216 homeserver: "https://matrix.org".into(),
12217 access_token: "syt_token_abc".into(),
12218 user_id: Some("@bot:matrix.org".into()),
12219 device_id: Some("DEVICE123".into()),
12220 room_id: "!room123:matrix.org".into(),
12221 allowed_users: vec!["@user:matrix.org".into()],
12222 allowed_rooms: vec![],
12223 interrupt_on_new_message: false,
12224 stream_mode: StreamMode::default(),
12225 draft_update_interval_ms: 1500,
12226 multi_message_delay_ms: 800,
12227 recovery_key: None,
12228 };
12229 let json = serde_json::to_string(&mc).unwrap();
12230 let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
12231 assert_eq!(parsed.homeserver, "https://matrix.org");
12232 assert_eq!(parsed.access_token, "syt_token_abc");
12233 assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org"));
12234 assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123"));
12235 assert_eq!(parsed.room_id, "!room123:matrix.org");
12236 assert_eq!(parsed.allowed_users.len(), 1);
12237 }
12238
12239 #[test]
12240 async fn matrix_config_toml_roundtrip() {
12241 let mc = MatrixConfig {
12242 homeserver: "https://synapse.local:8448".into(),
12243 access_token: "tok".into(),
12244 user_id: None,
12245 device_id: None,
12246 room_id: "!abc:synapse.local".into(),
12247 allowed_users: vec!["@admin:synapse.local".into(), "*".into()],
12248 allowed_rooms: vec![],
12249 interrupt_on_new_message: false,
12250 stream_mode: StreamMode::default(),
12251 draft_update_interval_ms: 1500,
12252 multi_message_delay_ms: 800,
12253 recovery_key: None,
12254 };
12255 let toml_str = toml::to_string(&mc).unwrap();
12256 let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
12257 assert_eq!(parsed.homeserver, "https://synapse.local:8448");
12258 assert_eq!(parsed.allowed_users.len(), 2);
12259 }
12260
12261 #[test]
12262 async fn matrix_config_backward_compatible_without_session_hints() {
12263 let toml = r#"
12264homeserver = "https://matrix.org"
12265access_token = "tok"
12266room_id = "!ops:matrix.org"
12267allowed_users = ["@ops:matrix.org"]
12268"#;
12269
12270 let parsed: MatrixConfig = toml::from_str(toml).unwrap();
12271 assert_eq!(parsed.homeserver, "https://matrix.org");
12272 assert!(parsed.user_id.is_none());
12273 assert!(parsed.device_id.is_none());
12274 }
12275
12276 #[test]
12277 async fn signal_config_serde() {
12278 let sc = SignalConfig {
12279 http_url: "http://127.0.0.1:8686".into(),
12280 account: "+1234567890".into(),
12281 group_id: Some("group123".into()),
12282 allowed_from: vec!["+1111111111".into()],
12283 ignore_attachments: true,
12284 ignore_stories: false,
12285 proxy_url: None,
12286 };
12287 let json = serde_json::to_string(&sc).unwrap();
12288 let parsed: SignalConfig = serde_json::from_str(&json).unwrap();
12289 assert_eq!(parsed.http_url, "http://127.0.0.1:8686");
12290 assert_eq!(parsed.account, "+1234567890");
12291 assert_eq!(parsed.group_id.as_deref(), Some("group123"));
12292 assert_eq!(parsed.allowed_from.len(), 1);
12293 assert!(parsed.ignore_attachments);
12294 assert!(!parsed.ignore_stories);
12295 }
12296
12297 #[test]
12298 async fn signal_config_toml_roundtrip() {
12299 let sc = SignalConfig {
12300 http_url: "http://localhost:8080".into(),
12301 account: "+9876543210".into(),
12302 group_id: None,
12303 allowed_from: vec!["*".into()],
12304 ignore_attachments: false,
12305 ignore_stories: true,
12306 proxy_url: None,
12307 };
12308 let toml_str = toml::to_string(&sc).unwrap();
12309 let parsed: SignalConfig = toml::from_str(&toml_str).unwrap();
12310 assert_eq!(parsed.http_url, "http://localhost:8080");
12311 assert_eq!(parsed.account, "+9876543210");
12312 assert!(parsed.group_id.is_none());
12313 assert!(parsed.ignore_stories);
12314 }
12315
12316 #[test]
12317 async fn signal_config_defaults() {
12318 let json = r#"{"http_url":"http://127.0.0.1:8686","account":"+1234567890"}"#;
12319 let parsed: SignalConfig = serde_json::from_str(json).unwrap();
12320 assert!(parsed.group_id.is_none());
12321 assert!(parsed.allowed_from.is_empty());
12322 assert!(!parsed.ignore_attachments);
12323 assert!(!parsed.ignore_stories);
12324 }
12325
12326 #[test]
12327 async fn channels_config_with_imessage_and_matrix() {
12328 let c = ChannelsConfig {
12329 cli: true,
12330 telegram: None,
12331 discord: None,
12332 discord_history: None,
12333 slack: None,
12334 mattermost: None,
12335 webhook: None,
12336 imessage: Some(IMessageConfig {
12337 allowed_contacts: vec!["+1".into()],
12338 }),
12339 matrix: Some(MatrixConfig {
12340 homeserver: "https://m.org".into(),
12341 access_token: "tok".into(),
12342 user_id: None,
12343 device_id: None,
12344 room_id: "!r:m".into(),
12345 allowed_users: vec!["@u:m".into()],
12346 allowed_rooms: vec![],
12347 interrupt_on_new_message: false,
12348 stream_mode: StreamMode::default(),
12349 draft_update_interval_ms: 1500,
12350 multi_message_delay_ms: 800,
12351 recovery_key: None,
12352 }),
12353 signal: None,
12354 whatsapp: None,
12355 linq: None,
12356 wati: None,
12357 nextcloud_talk: None,
12358 email: None,
12359 gmail_push: None,
12360 irc: None,
12361 lark: None,
12362 feishu: None,
12363 dingtalk: None,
12364 wecom: None,
12365 qq: None,
12366 twitter: None,
12367 mochat: None,
12368 #[cfg(feature = "channel-nostr")]
12369 nostr: None,
12370 clawdtalk: None,
12371 reddit: None,
12372 bluesky: None,
12373 voice_call: None,
12374 #[cfg(feature = "voice-wake")]
12375 voice_wake: None,
12376 message_timeout_secs: 300,
12377 ack_reactions: true,
12378 show_tool_calls: true,
12379 session_persistence: true,
12380 session_backend: default_session_backend(),
12381 session_ttl_hours: 0,
12382 debounce_ms: 0,
12383 };
12384 let toml_str = toml::to_string_pretty(&c).unwrap();
12385 let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
12386 assert!(parsed.imessage.is_some());
12387 assert!(parsed.matrix.is_some());
12388 assert_eq!(parsed.imessage.unwrap().allowed_contacts, vec!["+1"]);
12389 assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org");
12390 }
12391
12392 #[test]
12393 async fn channels_config_default_has_no_imessage_matrix() {
12394 let c = ChannelsConfig::default();
12395 assert!(c.imessage.is_none());
12396 assert!(c.matrix.is_none());
12397 }
12398
12399 #[test]
12402 async fn discord_config_deserializes_without_allowed_users() {
12403 let json = r#"{"bot_token":"tok","guild_id":"123"}"#;
12405 let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12406 assert!(parsed.allowed_users.is_empty());
12407 }
12408
12409 #[test]
12410 async fn discord_config_deserializes_with_allowed_users() {
12411 let json = r#"{"bot_token":"tok","guild_id":"123","allowed_users":["111","222"]}"#;
12412 let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12413 assert_eq!(parsed.allowed_users, vec!["111", "222"]);
12414 }
12415
12416 #[test]
12417 async fn slack_config_deserializes_without_allowed_users() {
12418 let json = r#"{"bot_token":"xoxb-tok"}"#;
12419 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12420 assert!(parsed.channel_ids.is_empty());
12421 assert!(parsed.allowed_users.is_empty());
12422 assert!(!parsed.interrupt_on_new_message);
12423 assert_eq!(parsed.thread_replies, None);
12424 assert!(!parsed.mention_only);
12425 }
12426
12427 #[test]
12428 async fn slack_config_deserializes_with_allowed_users() {
12429 let json = r#"{"bot_token":"xoxb-tok","allowed_users":["U111"]}"#;
12430 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12431 assert!(parsed.channel_ids.is_empty());
12432 assert_eq!(parsed.allowed_users, vec!["U111"]);
12433 assert!(!parsed.interrupt_on_new_message);
12434 assert_eq!(parsed.thread_replies, None);
12435 assert!(!parsed.mention_only);
12436 }
12437
12438 #[test]
12439 async fn slack_config_deserializes_with_channel_ids() {
12440 let json = r#"{"bot_token":"xoxb-tok","channel_ids":["C111","D222"]}"#;
12441 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12442 assert_eq!(parsed.channel_ids, vec!["C111", "D222"]);
12443 assert!(parsed.allowed_users.is_empty());
12444 assert!(!parsed.interrupt_on_new_message);
12445 assert_eq!(parsed.thread_replies, None);
12446 assert!(!parsed.mention_only);
12447 }
12448
12449 #[test]
12450 async fn slack_config_deserializes_with_mention_only() {
12451 let json = r#"{"bot_token":"xoxb-tok","mention_only":true}"#;
12452 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12453 assert!(parsed.mention_only);
12454 assert!(!parsed.interrupt_on_new_message);
12455 assert_eq!(parsed.thread_replies, None);
12456 }
12457
12458 #[test]
12459 async fn slack_config_deserializes_interrupt_on_new_message() {
12460 let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
12461 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12462 assert!(parsed.interrupt_on_new_message);
12463 assert_eq!(parsed.thread_replies, None);
12464 assert!(!parsed.mention_only);
12465 }
12466
12467 #[test]
12468 async fn slack_config_deserializes_thread_replies() {
12469 let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#;
12470 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12471 assert_eq!(parsed.thread_replies, Some(false));
12472 assert!(!parsed.interrupt_on_new_message);
12473 assert!(!parsed.mention_only);
12474 }
12475
12476 #[test]
12477 async fn discord_config_default_interrupt_on_new_message_is_false() {
12478 let json = r#"{"bot_token":"tok"}"#;
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_deserializes_interrupt_on_new_message_true() {
12485 let json = r#"{"bot_token":"tok","interrupt_on_new_message":true}"#;
12486 let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12487 assert!(parsed.interrupt_on_new_message);
12488 }
12489
12490 #[test]
12491 async fn discord_config_toml_backward_compat() {
12492 let toml_str = r#"
12493bot_token = "tok"
12494guild_id = "123"
12495"#;
12496 let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
12497 assert!(parsed.allowed_users.is_empty());
12498 assert_eq!(parsed.bot_token, "tok");
12499 }
12500
12501 #[test]
12502 async fn slack_config_toml_backward_compat() {
12503 let toml_str = r#"
12504bot_token = "xoxb-tok"
12505channel_id = "C123"
12506"#;
12507 let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
12508 assert!(parsed.channel_ids.is_empty());
12509 assert!(parsed.allowed_users.is_empty());
12510 assert!(!parsed.interrupt_on_new_message);
12511 assert_eq!(parsed.thread_replies, None);
12512 assert!(!parsed.mention_only);
12513 assert_eq!(parsed.channel_id.as_deref(), Some("C123"));
12514 }
12515
12516 #[test]
12517 async fn slack_config_toml_accepts_channel_ids() {
12518 let toml_str = r#"
12519bot_token = "xoxb-tok"
12520channel_ids = ["C123", "D456"]
12521"#;
12522 let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
12523 assert_eq!(parsed.channel_ids, vec!["C123", "D456"]);
12524 assert!(parsed.allowed_users.is_empty());
12525 assert!(!parsed.interrupt_on_new_message);
12526 assert_eq!(parsed.thread_replies, None);
12527 assert!(!parsed.mention_only);
12528 assert!(parsed.channel_id.is_none());
12529 }
12530
12531 #[test]
12532 async fn mattermost_config_default_interrupt_on_new_message_is_false() {
12533 let json = r#"{"url":"https://mm.example.com","bot_token":"tok"}"#;
12534 let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
12535 assert!(!parsed.interrupt_on_new_message);
12536 }
12537
12538 #[test]
12539 async fn mattermost_config_deserializes_interrupt_on_new_message_true() {
12540 let json =
12541 r#"{"url":"https://mm.example.com","bot_token":"tok","interrupt_on_new_message":true}"#;
12542 let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
12543 assert!(parsed.interrupt_on_new_message);
12544 }
12545
12546 #[test]
12547 async fn webhook_config_with_secret() {
12548 let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
12549 let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
12550 assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
12551 }
12552
12553 #[test]
12554 async fn webhook_config_without_secret() {
12555 let json = r#"{"port":8080}"#;
12556 let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
12557 assert!(parsed.secret.is_none());
12558 assert_eq!(parsed.port, 8080);
12559 }
12560
12561 #[test]
12564 async fn whatsapp_config_serde() {
12565 let wc = WhatsAppConfig {
12566 access_token: Some("EAABx...".into()),
12567 phone_number_id: Some("123456789".into()),
12568 verify_token: Some("my-verify-token".into()),
12569 app_secret: None,
12570 session_path: None,
12571 pair_phone: None,
12572 pair_code: None,
12573 allowed_numbers: vec!["+1234567890".into(), "+9876543210".into()],
12574 mode: WhatsAppWebMode::default(),
12575 dm_policy: WhatsAppChatPolicy::default(),
12576 group_policy: WhatsAppChatPolicy::default(),
12577 self_chat_mode: false,
12578 dm_mention_patterns: vec![],
12579 group_mention_patterns: vec![],
12580 proxy_url: None,
12581 };
12582 let json = serde_json::to_string(&wc).unwrap();
12583 let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
12584 assert_eq!(parsed.access_token, Some("EAABx...".into()));
12585 assert_eq!(parsed.phone_number_id, Some("123456789".into()));
12586 assert_eq!(parsed.verify_token, Some("my-verify-token".into()));
12587 assert_eq!(parsed.allowed_numbers.len(), 2);
12588 }
12589
12590 #[test]
12591 async fn whatsapp_config_toml_roundtrip() {
12592 let wc = WhatsAppConfig {
12593 access_token: Some("tok".into()),
12594 phone_number_id: Some("12345".into()),
12595 verify_token: Some("verify".into()),
12596 app_secret: Some("secret123".into()),
12597 session_path: None,
12598 pair_phone: None,
12599 pair_code: None,
12600 allowed_numbers: vec!["+1".into()],
12601 mode: WhatsAppWebMode::default(),
12602 dm_policy: WhatsAppChatPolicy::default(),
12603 group_policy: WhatsAppChatPolicy::default(),
12604 self_chat_mode: false,
12605 dm_mention_patterns: vec![],
12606 group_mention_patterns: vec![],
12607 proxy_url: None,
12608 };
12609 let toml_str = toml::to_string(&wc).unwrap();
12610 let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
12611 assert_eq!(parsed.phone_number_id, Some("12345".into()));
12612 assert_eq!(parsed.allowed_numbers, vec!["+1"]);
12613 }
12614
12615 #[test]
12616 async fn whatsapp_config_deserializes_without_allowed_numbers() {
12617 let json = r#"{"access_token":"tok","phone_number_id":"123","verify_token":"ver"}"#;
12618 let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
12619 assert!(parsed.allowed_numbers.is_empty());
12620 }
12621
12622 #[test]
12623 async fn whatsapp_config_wildcard_allowed() {
12624 let wc = WhatsAppConfig {
12625 access_token: Some("tok".into()),
12626 phone_number_id: Some("123".into()),
12627 verify_token: Some("ver".into()),
12628 app_secret: None,
12629 session_path: None,
12630 pair_phone: None,
12631 pair_code: None,
12632 allowed_numbers: vec!["*".into()],
12633 mode: WhatsAppWebMode::default(),
12634 dm_policy: WhatsAppChatPolicy::default(),
12635 group_policy: WhatsAppChatPolicy::default(),
12636 self_chat_mode: false,
12637 dm_mention_patterns: vec![],
12638 group_mention_patterns: vec![],
12639 proxy_url: None,
12640 };
12641 let toml_str = toml::to_string(&wc).unwrap();
12642 let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
12643 assert_eq!(parsed.allowed_numbers, vec!["*"]);
12644 }
12645
12646 #[test]
12647 async fn whatsapp_config_backend_type_cloud_precedence_when_ambiguous() {
12648 let wc = WhatsAppConfig {
12649 access_token: Some("tok".into()),
12650 phone_number_id: Some("123".into()),
12651 verify_token: Some("ver".into()),
12652 app_secret: None,
12653 session_path: Some("~/.construct/state/whatsapp-web/session.db".into()),
12654 pair_phone: None,
12655 pair_code: None,
12656 allowed_numbers: vec!["+1".into()],
12657 mode: WhatsAppWebMode::default(),
12658 dm_policy: WhatsAppChatPolicy::default(),
12659 group_policy: WhatsAppChatPolicy::default(),
12660 self_chat_mode: false,
12661 dm_mention_patterns: vec![],
12662 group_mention_patterns: vec![],
12663 proxy_url: None,
12664 };
12665 assert!(wc.is_ambiguous_config());
12666 assert_eq!(wc.backend_type(), "cloud");
12667 }
12668
12669 #[test]
12670 async fn whatsapp_config_backend_type_web() {
12671 let wc = WhatsAppConfig {
12672 access_token: None,
12673 phone_number_id: None,
12674 verify_token: None,
12675 app_secret: None,
12676 session_path: Some("~/.construct/state/whatsapp-web/session.db".into()),
12677 pair_phone: None,
12678 pair_code: None,
12679 allowed_numbers: vec![],
12680 mode: WhatsAppWebMode::default(),
12681 dm_policy: WhatsAppChatPolicy::default(),
12682 group_policy: WhatsAppChatPolicy::default(),
12683 self_chat_mode: false,
12684 dm_mention_patterns: vec![],
12685 group_mention_patterns: vec![],
12686 proxy_url: None,
12687 };
12688 assert!(!wc.is_ambiguous_config());
12689 assert_eq!(wc.backend_type(), "web");
12690 }
12691
12692 #[test]
12693 async fn channels_config_with_whatsapp() {
12694 let c = ChannelsConfig {
12695 cli: true,
12696 telegram: None,
12697 discord: None,
12698 discord_history: None,
12699 slack: None,
12700 mattermost: None,
12701 webhook: None,
12702 imessage: None,
12703 matrix: None,
12704 signal: None,
12705 whatsapp: Some(WhatsAppConfig {
12706 access_token: Some("tok".into()),
12707 phone_number_id: Some("123".into()),
12708 verify_token: Some("ver".into()),
12709 app_secret: None,
12710 session_path: None,
12711 pair_phone: None,
12712 pair_code: None,
12713 allowed_numbers: vec!["+1".into()],
12714 mode: WhatsAppWebMode::default(),
12715 dm_policy: WhatsAppChatPolicy::default(),
12716 group_policy: WhatsAppChatPolicy::default(),
12717 self_chat_mode: false,
12718 dm_mention_patterns: vec![],
12719 group_mention_patterns: vec![],
12720 proxy_url: None,
12721 }),
12722 linq: None,
12723 wati: None,
12724 nextcloud_talk: None,
12725 email: None,
12726 gmail_push: None,
12727 irc: None,
12728 lark: None,
12729 feishu: None,
12730 dingtalk: None,
12731 wecom: None,
12732 qq: None,
12733 twitter: None,
12734 mochat: None,
12735 #[cfg(feature = "channel-nostr")]
12736 nostr: None,
12737 clawdtalk: None,
12738 reddit: None,
12739 bluesky: None,
12740 voice_call: None,
12741 #[cfg(feature = "voice-wake")]
12742 voice_wake: None,
12743 message_timeout_secs: 300,
12744 ack_reactions: true,
12745 show_tool_calls: true,
12746 session_persistence: true,
12747 session_backend: default_session_backend(),
12748 session_ttl_hours: 0,
12749 debounce_ms: 0,
12750 };
12751 let toml_str = toml::to_string_pretty(&c).unwrap();
12752 let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
12753 assert!(parsed.whatsapp.is_some());
12754 let wa = parsed.whatsapp.unwrap();
12755 assert_eq!(wa.phone_number_id, Some("123".into()));
12756 assert_eq!(wa.allowed_numbers, vec!["+1"]);
12757 }
12758
12759 #[test]
12760 async fn channels_config_default_has_no_whatsapp() {
12761 let c = ChannelsConfig::default();
12762 assert!(c.whatsapp.is_none());
12763 }
12764
12765 #[test]
12766 async fn channels_config_default_has_no_nextcloud_talk() {
12767 let c = ChannelsConfig::default();
12768 assert!(c.nextcloud_talk.is_none());
12769 }
12770
12771 #[test]
12776 async fn checklist_gateway_default_requires_pairing() {
12777 let g = GatewayConfig::default();
12778 assert!(g.require_pairing, "Pairing must be required by default");
12779 }
12780
12781 #[test]
12782 async fn checklist_gateway_default_blocks_public_bind() {
12783 let g = GatewayConfig::default();
12784 assert!(
12785 !g.allow_public_bind,
12786 "Public bind must be blocked by default"
12787 );
12788 }
12789
12790 #[test]
12791 async fn checklist_gateway_default_no_tokens() {
12792 let g = GatewayConfig::default();
12793 assert!(
12794 g.paired_tokens.is_empty(),
12795 "No pre-paired tokens by default"
12796 );
12797 assert_eq!(g.pair_rate_limit_per_minute, 10);
12798 assert_eq!(g.webhook_rate_limit_per_minute, 60);
12799 assert!(!g.trust_forwarded_headers);
12800 assert_eq!(g.rate_limit_max_keys, 10_000);
12801 assert_eq!(g.idempotency_ttl_secs, 300);
12802 assert_eq!(g.idempotency_max_keys, 10_000);
12803 }
12804
12805 #[test]
12806 async fn checklist_gateway_cli_default_host_is_localhost() {
12807 let c = Config::default();
12810 assert!(
12811 c.gateway.require_pairing,
12812 "Config default must require pairing"
12813 );
12814 assert!(
12815 !c.gateway.allow_public_bind,
12816 "Config default must block public bind"
12817 );
12818 }
12819
12820 #[test]
12821 async fn checklist_gateway_serde_roundtrip() {
12822 let g = GatewayConfig {
12823 port: 42617,
12824 host: "127.0.0.1".into(),
12825 require_pairing: true,
12826 allow_public_bind: false,
12827 paired_tokens: vec!["zc_test_token".into()],
12828 pair_rate_limit_per_minute: 12,
12829 webhook_rate_limit_per_minute: 80,
12830 trust_forwarded_headers: true,
12831 path_prefix: Some("/construct".into()),
12832 rate_limit_max_keys: 2048,
12833 idempotency_ttl_secs: 600,
12834 idempotency_max_keys: 4096,
12835 session_persistence: true,
12836 session_ttl_hours: 0,
12837 pairing_dashboard: PairingDashboardConfig::default(),
12838 tls: None,
12839 };
12840 let toml_str = toml::to_string(&g).unwrap();
12841 let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
12842 assert!(parsed.require_pairing);
12843 assert!(parsed.session_persistence);
12844 assert_eq!(parsed.session_ttl_hours, 0);
12845 assert!(!parsed.allow_public_bind);
12846 assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
12847 assert_eq!(parsed.pair_rate_limit_per_minute, 12);
12848 assert_eq!(parsed.webhook_rate_limit_per_minute, 80);
12849 assert!(parsed.trust_forwarded_headers);
12850 assert_eq!(parsed.path_prefix.as_deref(), Some("/construct"));
12851 assert_eq!(parsed.rate_limit_max_keys, 2048);
12852 assert_eq!(parsed.idempotency_ttl_secs, 600);
12853 assert_eq!(parsed.idempotency_max_keys, 4096);
12854 }
12855
12856 #[test]
12857 async fn checklist_gateway_backward_compat_no_gateway_section() {
12858 let minimal = r#"
12860workspace_dir = "/tmp/ws"
12861config_path = "/tmp/config.toml"
12862default_temperature = 0.7
12863"#;
12864 let parsed = parse_test_config(minimal);
12865 assert!(
12866 parsed.gateway.require_pairing,
12867 "Missing [gateway] must default to require_pairing=true"
12868 );
12869 assert!(
12870 !parsed.gateway.allow_public_bind,
12871 "Missing [gateway] must default to allow_public_bind=false"
12872 );
12873 }
12874
12875 #[test]
12876 async fn checklist_autonomy_default_is_workspace_scoped() {
12877 let a = AutonomyConfig::default();
12878 assert!(a.workspace_only, "Default autonomy must be workspace_only");
12879 assert!(
12880 a.forbidden_paths.contains(&"/etc".to_string()),
12881 "Must block /etc"
12882 );
12883 assert!(
12884 a.forbidden_paths.contains(&"/proc".to_string()),
12885 "Must block /proc"
12886 );
12887 assert!(
12888 a.forbidden_paths.contains(&"~/.ssh".to_string()),
12889 "Must block ~/.ssh"
12890 );
12891 }
12892
12893 #[test]
12898 async fn composio_config_default_disabled() {
12899 let c = ComposioConfig::default();
12900 assert!(!c.enabled, "Composio must be disabled by default");
12901 assert!(c.api_key.is_none(), "No API key by default");
12902 assert_eq!(c.entity_id, "default");
12903 }
12904
12905 #[test]
12906 async fn composio_config_serde_roundtrip() {
12907 let c = ComposioConfig {
12908 enabled: true,
12909 api_key: Some("comp-key-123".into()),
12910 entity_id: "user42".into(),
12911 };
12912 let toml_str = toml::to_string(&c).unwrap();
12913 let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
12914 assert!(parsed.enabled);
12915 assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
12916 assert_eq!(parsed.entity_id, "user42");
12917 }
12918
12919 #[test]
12920 async fn composio_config_backward_compat_missing_section() {
12921 let minimal = r#"
12922workspace_dir = "/tmp/ws"
12923config_path = "/tmp/config.toml"
12924default_temperature = 0.7
12925"#;
12926 let parsed = parse_test_config(minimal);
12927 assert!(
12928 !parsed.composio.enabled,
12929 "Missing [composio] must default to disabled"
12930 );
12931 assert!(parsed.composio.api_key.is_none());
12932 }
12933
12934 #[test]
12935 async fn composio_config_partial_toml() {
12936 let toml_str = r"
12937enabled = true
12938";
12939 let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
12940 assert!(parsed.enabled);
12941 assert!(parsed.api_key.is_none());
12942 assert_eq!(parsed.entity_id, "default");
12943 }
12944
12945 #[test]
12946 async fn composio_config_enable_alias_supported() {
12947 let toml_str = r"
12948enable = true
12949";
12950 let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
12951 assert!(parsed.enabled);
12952 assert!(parsed.api_key.is_none());
12953 assert_eq!(parsed.entity_id, "default");
12954 }
12955
12956 #[test]
12961 async fn secrets_config_default_encrypts() {
12962 let s = SecretsConfig::default();
12963 assert!(s.encrypt, "Encryption must be enabled by default");
12964 }
12965
12966 #[test]
12967 async fn secrets_config_serde_roundtrip() {
12968 let s = SecretsConfig { encrypt: false };
12969 let toml_str = toml::to_string(&s).unwrap();
12970 let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
12971 assert!(!parsed.encrypt);
12972 }
12973
12974 #[test]
12975 async fn secrets_config_backward_compat_missing_section() {
12976 let minimal = r#"
12977workspace_dir = "/tmp/ws"
12978config_path = "/tmp/config.toml"
12979default_temperature = 0.7
12980"#;
12981 let parsed = parse_test_config(minimal);
12982 assert!(
12983 parsed.secrets.encrypt,
12984 "Missing [secrets] must default to encrypt=true"
12985 );
12986 }
12987
12988 #[test]
12989 async fn config_default_has_composio_and_secrets() {
12990 let c = Config::default();
12991 assert!(!c.composio.enabled);
12992 assert!(c.composio.api_key.is_none());
12993 assert!(c.secrets.encrypt);
12994 assert!(c.browser.enabled);
12995 assert_eq!(c.browser.allowed_domains, vec!["*".to_string()]);
12996 }
12997
12998 #[test]
12999 async fn browser_config_default_enabled() {
13000 let b = BrowserConfig::default();
13001 assert!(b.enabled);
13002 assert_eq!(b.allowed_domains, vec!["*".to_string()]);
13003 assert_eq!(b.backend, "agent_browser");
13004 assert!(b.native_headless);
13005 assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
13006 assert!(b.native_chrome_path.is_none());
13007 assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions");
13008 assert_eq!(b.computer_use.timeout_ms, 15_000);
13009 assert!(!b.computer_use.allow_remote_endpoint);
13010 assert!(b.computer_use.window_allowlist.is_empty());
13011 assert!(b.computer_use.max_coordinate_x.is_none());
13012 assert!(b.computer_use.max_coordinate_y.is_none());
13013 }
13014
13015 #[test]
13016 async fn browser_config_serde_roundtrip() {
13017 let b = BrowserConfig {
13018 enabled: true,
13019 allowed_domains: vec!["example.com".into(), "docs.example.com".into()],
13020 session_name: None,
13021 backend: "auto".into(),
13022 native_headless: false,
13023 native_webdriver_url: "http://localhost:4444".into(),
13024 native_chrome_path: Some("/usr/bin/chromium".into()),
13025 computer_use: BrowserComputerUseConfig {
13026 endpoint: "https://computer-use.example.com/v1/actions".into(),
13027 api_key: Some("test-token".into()),
13028 timeout_ms: 8_000,
13029 allow_remote_endpoint: true,
13030 window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()],
13031 max_coordinate_x: Some(3840),
13032 max_coordinate_y: Some(2160),
13033 },
13034 };
13035 let toml_str = toml::to_string(&b).unwrap();
13036 let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap();
13037 assert!(parsed.enabled);
13038 assert_eq!(parsed.allowed_domains.len(), 2);
13039 assert_eq!(parsed.allowed_domains[0], "example.com");
13040 assert_eq!(parsed.backend, "auto");
13041 assert!(!parsed.native_headless);
13042 assert_eq!(parsed.native_webdriver_url, "http://localhost:4444");
13043 assert_eq!(
13044 parsed.native_chrome_path.as_deref(),
13045 Some("/usr/bin/chromium")
13046 );
13047 assert_eq!(
13048 parsed.computer_use.endpoint,
13049 "https://computer-use.example.com/v1/actions"
13050 );
13051 assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token"));
13052 assert_eq!(parsed.computer_use.timeout_ms, 8_000);
13053 assert!(parsed.computer_use.allow_remote_endpoint);
13054 assert_eq!(parsed.computer_use.window_allowlist.len(), 2);
13055 assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840));
13056 assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160));
13057 }
13058
13059 #[test]
13060 async fn browser_config_backward_compat_missing_section() {
13061 let minimal = r#"
13062workspace_dir = "/tmp/ws"
13063config_path = "/tmp/config.toml"
13064default_temperature = 0.7
13065"#;
13066 let parsed = parse_test_config(minimal);
13067 assert!(parsed.browser.enabled);
13068 assert_eq!(parsed.browser.allowed_domains, vec!["*".to_string()]);
13069 }
13070
13071 async fn env_override_lock() -> MutexGuard<'static, ()> {
13074 static ENV_OVERRIDE_TEST_LOCK: Mutex<()> = Mutex::const_new(());
13075 ENV_OVERRIDE_TEST_LOCK.lock().await
13076 }
13077
13078 fn clear_proxy_env_test_vars() {
13079 for key in [
13080 "CONSTRUCT_PROXY_ENABLED",
13081 "CONSTRUCT_HTTP_PROXY",
13082 "CONSTRUCT_HTTPS_PROXY",
13083 "CONSTRUCT_ALL_PROXY",
13084 "CONSTRUCT_NO_PROXY",
13085 "CONSTRUCT_PROXY_SCOPE",
13086 "CONSTRUCT_PROXY_SERVICES",
13087 "HTTP_PROXY",
13088 "HTTPS_PROXY",
13089 "ALL_PROXY",
13090 "NO_PROXY",
13091 "http_proxy",
13092 "https_proxy",
13093 "all_proxy",
13094 "no_proxy",
13095 ] {
13096 unsafe { std::env::remove_var(key) };
13098 }
13099 }
13100
13101 #[test]
13102 async fn env_override_api_key() {
13103 let _env_guard = env_override_lock().await;
13104 let mut config = Config::default();
13105 assert!(config.api_key.is_none());
13106
13107 unsafe { std::env::set_var("CONSTRUCT_API_KEY", "sk-test-env-key") };
13109 config.apply_env_overrides();
13110 assert_eq!(config.api_key.as_deref(), Some("sk-test-env-key"));
13111
13112 unsafe { std::env::remove_var("CONSTRUCT_API_KEY") };
13114 }
13115
13116 #[test]
13117 async fn env_override_api_key_fallback() {
13118 let _env_guard = env_override_lock().await;
13119 let mut config = Config::default();
13120
13121 unsafe { std::env::remove_var("CONSTRUCT_API_KEY") };
13123 unsafe { std::env::set_var("API_KEY", "sk-fallback-key") };
13125 config.apply_env_overrides();
13126 assert_eq!(config.api_key.as_deref(), Some("sk-fallback-key"));
13127
13128 unsafe { std::env::remove_var("API_KEY") };
13130 }
13131
13132 #[test]
13133 async fn env_override_provider() {
13134 let _env_guard = env_override_lock().await;
13135 let mut config = Config::default();
13136
13137 unsafe { std::env::set_var("CONSTRUCT_PROVIDER", "anthropic") };
13139 config.apply_env_overrides();
13140 assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
13141
13142 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13144 }
13145
13146 #[test]
13147 async fn env_override_model_provider_alias() {
13148 let _env_guard = env_override_lock().await;
13149 let mut config = Config::default();
13150
13151 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13153 unsafe { std::env::set_var("CONSTRUCT_MODEL_PROVIDER", "openai-codex") };
13155 config.apply_env_overrides();
13156 assert_eq!(config.default_provider.as_deref(), Some("openai-codex"));
13157
13158 unsafe { std::env::remove_var("CONSTRUCT_MODEL_PROVIDER") };
13160 }
13161
13162 #[test]
13163 async fn toml_supports_model_provider_and_model_alias_fields() {
13164 let raw = r#"
13165default_temperature = 0.7
13166model_provider = "sub2api"
13167model = "gpt-5.3-codex"
13168
13169[model_providers.sub2api]
13170name = "sub2api"
13171base_url = "https://api.tonsof.blue/v1"
13172wire_api = "responses"
13173requires_openai_auth = true
13174"#;
13175
13176 let parsed = parse_test_config(raw);
13177 assert_eq!(parsed.default_provider.as_deref(), Some("sub2api"));
13178 assert_eq!(parsed.default_model.as_deref(), Some("gpt-5.3-codex"));
13179 let profile = parsed
13180 .model_providers
13181 .get("sub2api")
13182 .expect("profile should exist");
13183 assert_eq!(profile.wire_api.as_deref(), Some("responses"));
13184 assert!(profile.requires_openai_auth);
13185 }
13186
13187 #[test]
13188 async fn env_override_open_skills_enabled_and_dir() {
13189 let _env_guard = env_override_lock().await;
13190 let mut config = Config::default();
13191 assert!(!config.skills.open_skills_enabled);
13192 assert!(config.skills.open_skills_dir.is_none());
13193 assert_eq!(
13194 config.skills.prompt_injection_mode,
13195 SkillsPromptInjectionMode::Full
13196 );
13197
13198 unsafe { std::env::set_var("CONSTRUCT_OPEN_SKILLS_ENABLED", "true") };
13200 unsafe { std::env::set_var("CONSTRUCT_OPEN_SKILLS_DIR", "/tmp/open-skills") };
13202 unsafe { std::env::set_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS", "yes") };
13204 unsafe { std::env::set_var("CONSTRUCT_SKILLS_PROMPT_MODE", "compact") };
13206 config.apply_env_overrides();
13207
13208 assert!(config.skills.open_skills_enabled);
13209 assert!(config.skills.allow_scripts);
13210 assert_eq!(
13211 config.skills.open_skills_dir.as_deref(),
13212 Some("/tmp/open-skills")
13213 );
13214 assert_eq!(
13215 config.skills.prompt_injection_mode,
13216 SkillsPromptInjectionMode::Compact
13217 );
13218
13219 unsafe { std::env::remove_var("CONSTRUCT_OPEN_SKILLS_ENABLED") };
13221 unsafe { std::env::remove_var("CONSTRUCT_OPEN_SKILLS_DIR") };
13223 unsafe { std::env::remove_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS") };
13225 unsafe { std::env::remove_var("CONSTRUCT_SKILLS_PROMPT_MODE") };
13227 }
13228
13229 #[test]
13230 async fn env_override_open_skills_enabled_invalid_value_keeps_existing_value() {
13231 let _env_guard = env_override_lock().await;
13232 let mut config = Config::default();
13233 config.skills.open_skills_enabled = true;
13234 config.skills.allow_scripts = true;
13235 config.skills.prompt_injection_mode = SkillsPromptInjectionMode::Compact;
13236
13237 unsafe { std::env::set_var("CONSTRUCT_OPEN_SKILLS_ENABLED", "maybe") };
13239 unsafe { std::env::set_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS", "maybe") };
13241 unsafe { std::env::set_var("CONSTRUCT_SKILLS_PROMPT_MODE", "invalid") };
13243 config.apply_env_overrides();
13244
13245 assert!(config.skills.open_skills_enabled);
13246 assert!(config.skills.allow_scripts);
13247 assert_eq!(
13248 config.skills.prompt_injection_mode,
13249 SkillsPromptInjectionMode::Compact
13250 );
13251 unsafe { std::env::remove_var("CONSTRUCT_OPEN_SKILLS_ENABLED") };
13253 unsafe { std::env::remove_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS") };
13255 unsafe { std::env::remove_var("CONSTRUCT_SKILLS_PROMPT_MODE") };
13257 }
13258
13259 #[test]
13260 async fn env_override_provider_fallback() {
13261 let _env_guard = env_override_lock().await;
13262 let mut config = Config::default();
13263
13264 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13266 unsafe { std::env::set_var("PROVIDER", "openai") };
13268 config.apply_env_overrides();
13269 assert_eq!(config.default_provider.as_deref(), Some("openai"));
13270
13271 unsafe { std::env::remove_var("PROVIDER") };
13273 }
13274
13275 #[test]
13276 async fn env_override_provider_fallback_does_not_replace_non_default_provider() {
13277 let _env_guard = env_override_lock().await;
13278 let mut config = Config {
13279 default_provider: Some("custom:https://proxy.example.com/v1".to_string()),
13280 ..Config::default()
13281 };
13282
13283 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13285 unsafe { std::env::set_var("PROVIDER", "openrouter") };
13287 config.apply_env_overrides();
13288 assert_eq!(
13289 config.default_provider.as_deref(),
13290 Some("custom:https://proxy.example.com/v1")
13291 );
13292
13293 unsafe { std::env::remove_var("PROVIDER") };
13295 }
13296
13297 #[test]
13298 async fn env_override_zero_claw_provider_overrides_non_default_provider() {
13299 let _env_guard = env_override_lock().await;
13300 let mut config = Config {
13301 default_provider: Some("custom:https://proxy.example.com/v1".to_string()),
13302 ..Config::default()
13303 };
13304
13305 unsafe { std::env::set_var("CONSTRUCT_PROVIDER", "openrouter") };
13307 unsafe { std::env::set_var("PROVIDER", "anthropic") };
13309 config.apply_env_overrides();
13310 assert_eq!(config.default_provider.as_deref(), Some("openrouter"));
13311
13312 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13314 unsafe { std::env::remove_var("PROVIDER") };
13316 }
13317
13318 #[test]
13319 async fn env_override_glm_api_key_for_regional_aliases() {
13320 let _env_guard = env_override_lock().await;
13321 let mut config = Config {
13322 default_provider: Some("glm-cn".to_string()),
13323 ..Config::default()
13324 };
13325
13326 unsafe { std::env::set_var("GLM_API_KEY", "glm-regional-key") };
13328 config.apply_env_overrides();
13329 assert_eq!(config.api_key.as_deref(), Some("glm-regional-key"));
13330
13331 unsafe { std::env::remove_var("GLM_API_KEY") };
13333 }
13334
13335 #[test]
13336 async fn env_override_zai_api_key_for_regional_aliases() {
13337 let _env_guard = env_override_lock().await;
13338 let mut config = Config {
13339 default_provider: Some("zai-cn".to_string()),
13340 ..Config::default()
13341 };
13342
13343 unsafe { std::env::set_var("ZAI_API_KEY", "zai-regional-key") };
13345 config.apply_env_overrides();
13346 assert_eq!(config.api_key.as_deref(), Some("zai-regional-key"));
13347
13348 unsafe { std::env::remove_var("ZAI_API_KEY") };
13350 }
13351
13352 #[test]
13353 async fn env_override_model() {
13354 let _env_guard = env_override_lock().await;
13355 let mut config = Config::default();
13356
13357 unsafe { std::env::set_var("CONSTRUCT_MODEL", "gpt-4o") };
13359 config.apply_env_overrides();
13360 assert_eq!(config.default_model.as_deref(), Some("gpt-4o"));
13361
13362 unsafe { std::env::remove_var("CONSTRUCT_MODEL") };
13364 }
13365
13366 #[test]
13367 async fn model_provider_profile_maps_to_custom_endpoint() {
13368 let _env_guard = env_override_lock().await;
13369 let mut config = Config {
13370 default_provider: Some("sub2api".to_string()),
13371 model_providers: HashMap::from([(
13372 "sub2api".to_string(),
13373 ModelProviderConfig {
13374 name: Some("sub2api".to_string()),
13375 base_url: Some("https://api.tonsof.blue/v1".to_string()),
13376 wire_api: None,
13377 requires_openai_auth: false,
13378 azure_openai_resource: None,
13379 azure_openai_deployment: None,
13380 azure_openai_api_version: None,
13381 api_path: None,
13382 max_tokens: None,
13383 },
13384 )]),
13385 ..Config::default()
13386 };
13387
13388 config.apply_env_overrides();
13389 assert_eq!(
13390 config.default_provider.as_deref(),
13391 Some("custom:https://api.tonsof.blue/v1")
13392 );
13393 assert_eq!(
13394 config.api_url.as_deref(),
13395 Some("https://api.tonsof.blue/v1")
13396 );
13397 }
13398
13399 #[test]
13400 async fn model_provider_profile_responses_uses_openai_codex_and_openai_key() {
13401 let _env_guard = env_override_lock().await;
13402 let mut config = Config {
13403 default_provider: Some("sub2api".to_string()),
13404 model_providers: HashMap::from([(
13405 "sub2api".to_string(),
13406 ModelProviderConfig {
13407 name: Some("sub2api".to_string()),
13408 base_url: Some("https://api.tonsof.blue".to_string()),
13409 wire_api: Some("responses".to_string()),
13410 requires_openai_auth: true,
13411 azure_openai_resource: None,
13412 azure_openai_deployment: None,
13413 azure_openai_api_version: None,
13414 api_path: None,
13415 max_tokens: None,
13416 },
13417 )]),
13418 api_key: None,
13419 ..Config::default()
13420 };
13421
13422 unsafe { std::env::set_var("OPENAI_API_KEY", "sk-test-codex-key") };
13424 config.apply_env_overrides();
13425 unsafe { std::env::remove_var("OPENAI_API_KEY") };
13427
13428 assert_eq!(config.default_provider.as_deref(), Some("openai-codex"));
13429 assert_eq!(config.api_url.as_deref(), Some("https://api.tonsof.blue"));
13430 assert_eq!(config.api_key.as_deref(), Some("sk-test-codex-key"));
13431 }
13432
13433 #[test]
13434 async fn save_repairs_bare_config_filename_using_runtime_resolution() {
13435 let _env_guard = env_override_lock().await;
13436 let temp_home =
13437 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13438 let workspace_dir = temp_home.join("workspace");
13439 let resolved_config_path = temp_home.join(".construct").join("config.toml");
13440
13441 let original_home = std::env::var("HOME").ok();
13442 unsafe { std::env::set_var("HOME", &temp_home) };
13444 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13446
13447 let mut config = Config::default();
13448 config.workspace_dir = workspace_dir;
13449 config.config_path = PathBuf::from("config.toml");
13450 config.default_temperature = 0.5;
13451 config.save().await.unwrap();
13452
13453 assert!(resolved_config_path.exists());
13454 let saved = tokio::fs::read_to_string(&resolved_config_path)
13455 .await
13456 .unwrap();
13457 let parsed = parse_test_config(&saved);
13458 assert_eq!(parsed.default_temperature, 0.5);
13459
13460 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13462 if let Some(home) = original_home {
13463 unsafe { std::env::set_var("HOME", home) };
13465 } else {
13466 unsafe { std::env::remove_var("HOME") };
13468 }
13469 let _ = tokio::fs::remove_dir_all(temp_home).await;
13470 }
13471
13472 #[test]
13473 async fn validate_ollama_cloud_model_requires_remote_api_url() {
13474 let _env_guard = env_override_lock().await;
13475 let config = Config {
13476 default_provider: Some("ollama".to_string()),
13477 default_model: Some("glm-5:cloud".to_string()),
13478 api_url: None,
13479 api_key: Some("ollama-key".to_string()),
13480 ..Config::default()
13481 };
13482
13483 let error = config.validate().expect_err("expected validation to fail");
13484 assert!(error.to_string().contains(
13485 "default_model uses ':cloud' with provider 'ollama', but api_url is local or unset"
13486 ));
13487 }
13488
13489 #[test]
13490 async fn validate_ollama_cloud_model_accepts_remote_endpoint_and_env_key() {
13491 let _env_guard = env_override_lock().await;
13492 let config = Config {
13493 default_provider: Some("ollama".to_string()),
13494 default_model: Some("glm-5:cloud".to_string()),
13495 api_url: Some("https://ollama.com/api".to_string()),
13496 api_key: None,
13497 ..Config::default()
13498 };
13499
13500 unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-env-key") };
13502 let result = config.validate();
13503 unsafe { std::env::remove_var("OLLAMA_API_KEY") };
13505
13506 assert!(result.is_ok(), "expected validation to pass: {result:?}");
13507 }
13508
13509 #[test]
13510 async fn validate_rejects_unknown_model_provider_wire_api() {
13511 let _env_guard = env_override_lock().await;
13512 let config = Config {
13513 default_provider: Some("sub2api".to_string()),
13514 model_providers: HashMap::from([(
13515 "sub2api".to_string(),
13516 ModelProviderConfig {
13517 name: Some("sub2api".to_string()),
13518 base_url: Some("https://api.tonsof.blue/v1".to_string()),
13519 wire_api: Some("ws".to_string()),
13520 requires_openai_auth: false,
13521 azure_openai_resource: None,
13522 azure_openai_deployment: None,
13523 azure_openai_api_version: None,
13524 api_path: None,
13525 max_tokens: None,
13526 },
13527 )]),
13528 ..Config::default()
13529 };
13530
13531 let error = config.validate().expect_err("expected validation failure");
13532 assert!(
13533 error
13534 .to_string()
13535 .contains("wire_api must be one of: responses, chat_completions")
13536 );
13537 }
13538
13539 #[test]
13540 async fn env_override_model_fallback() {
13541 let _env_guard = env_override_lock().await;
13542 let mut config = Config::default();
13543
13544 unsafe { std::env::remove_var("CONSTRUCT_MODEL") };
13546 unsafe { std::env::set_var("MODEL", "anthropic/claude-3.5-sonnet") };
13548 config.apply_env_overrides();
13549 assert_eq!(
13550 config.default_model.as_deref(),
13551 Some("anthropic/claude-3.5-sonnet")
13552 );
13553
13554 unsafe { std::env::remove_var("MODEL") };
13556 }
13557
13558 #[test]
13559 async fn env_override_workspace() {
13560 let _env_guard = env_override_lock().await;
13561 let mut config = Config::default();
13562
13563 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", "/custom/workspace") };
13565 config.apply_env_overrides();
13566 assert_eq!(config.workspace_dir, PathBuf::from("/custom/workspace"));
13567
13568 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13570 }
13571
13572 #[test]
13573 async fn resolve_runtime_config_dirs_uses_env_workspace_first() {
13574 let _env_guard = env_override_lock().await;
13575 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13576 let default_workspace_dir = default_config_dir.join("workspace");
13577 let workspace_dir = default_config_dir.join("profile-a");
13578
13579 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13581 let (config_dir, resolved_workspace_dir, source) =
13582 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13583 .await
13584 .unwrap();
13585
13586 assert_eq!(source, ConfigResolutionSource::EnvWorkspace);
13587 assert_eq!(config_dir, workspace_dir);
13588 assert_eq!(resolved_workspace_dir, workspace_dir.join("workspace"));
13589
13590 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13592 let _ = fs::remove_dir_all(default_config_dir).await;
13593 }
13594
13595 #[test]
13596 async fn resolve_runtime_config_dirs_uses_env_config_dir_first() {
13597 let _env_guard = env_override_lock().await;
13598 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13599 let default_workspace_dir = default_config_dir.join("workspace");
13600 let explicit_config_dir = default_config_dir.join("explicit-config");
13601 let marker_config_dir = default_config_dir.join("profiles").join("alpha");
13602 let state_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
13603
13604 fs::create_dir_all(&default_config_dir).await.unwrap();
13605 let state = ActiveWorkspaceState {
13606 config_dir: marker_config_dir.to_string_lossy().into_owned(),
13607 };
13608 fs::write(&state_path, toml::to_string(&state).unwrap())
13609 .await
13610 .unwrap();
13611
13612 unsafe { std::env::set_var("CONSTRUCT_CONFIG_DIR", &explicit_config_dir) };
13614 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13616
13617 let (config_dir, resolved_workspace_dir, source) =
13618 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13619 .await
13620 .unwrap();
13621
13622 assert_eq!(source, ConfigResolutionSource::EnvConfigDir);
13623 assert_eq!(config_dir, explicit_config_dir);
13624 assert_eq!(
13625 resolved_workspace_dir,
13626 explicit_config_dir.join("workspace")
13627 );
13628
13629 unsafe { std::env::remove_var("CONSTRUCT_CONFIG_DIR") };
13631 let _ = fs::remove_dir_all(default_config_dir).await;
13632 }
13633
13634 #[test]
13635 async fn resolve_runtime_config_dirs_uses_active_workspace_marker() {
13636 let _env_guard = env_override_lock().await;
13637 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13638 let default_workspace_dir = default_config_dir.join("workspace");
13639 let marker_config_dir = default_config_dir.join("profiles").join("alpha");
13640 let state_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
13641
13642 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13644 fs::create_dir_all(&default_config_dir).await.unwrap();
13645 let state = ActiveWorkspaceState {
13646 config_dir: marker_config_dir.to_string_lossy().into_owned(),
13647 };
13648 fs::write(&state_path, toml::to_string(&state).unwrap())
13649 .await
13650 .unwrap();
13651
13652 let (config_dir, resolved_workspace_dir, source) =
13653 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13654 .await
13655 .unwrap();
13656
13657 assert_eq!(source, ConfigResolutionSource::ActiveWorkspaceMarker);
13658 assert_eq!(config_dir, marker_config_dir);
13659 assert_eq!(resolved_workspace_dir, marker_config_dir.join("workspace"));
13660
13661 let _ = fs::remove_dir_all(default_config_dir).await;
13662 }
13663
13664 #[test]
13665 async fn resolve_runtime_config_dirs_falls_back_to_default_layout() {
13666 let _env_guard = env_override_lock().await;
13667 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13668 let default_workspace_dir = default_config_dir.join("workspace");
13669
13670 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13672 let (config_dir, resolved_workspace_dir, source) =
13673 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13674 .await
13675 .unwrap();
13676
13677 assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
13678 assert_eq!(config_dir, default_config_dir);
13679 assert_eq!(resolved_workspace_dir, default_workspace_dir);
13680
13681 let _ = fs::remove_dir_all(default_config_dir).await;
13682 }
13683
13684 #[test]
13685 async fn load_or_init_workspace_override_uses_workspace_root_for_config() {
13686 let _env_guard = env_override_lock().await;
13687 let temp_home =
13688 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13689 let workspace_dir = temp_home.join("profile-a");
13690
13691 let original_home = std::env::var("HOME").ok();
13692 unsafe { std::env::set_var("HOME", &temp_home) };
13694 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13696
13697 let config = Box::pin(Config::load_or_init()).await.unwrap();
13698
13699 assert_eq!(config.workspace_dir, workspace_dir.join("workspace"));
13700 assert_eq!(config.config_path, workspace_dir.join("config.toml"));
13701 assert!(workspace_dir.join("config.toml").exists());
13702
13703 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13705 if let Some(home) = original_home {
13706 unsafe { std::env::set_var("HOME", home) };
13708 } else {
13709 unsafe { std::env::remove_var("HOME") };
13711 }
13712 let _ = fs::remove_dir_all(temp_home).await;
13713 }
13714
13715 #[test]
13716 async fn load_or_init_workspace_suffix_uses_legacy_config_layout() {
13717 let _env_guard = env_override_lock().await;
13718 let temp_home =
13719 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13720 let workspace_dir = temp_home.join("workspace");
13721 let legacy_config_path = temp_home.join(".construct").join("config.toml");
13722
13723 let original_home = std::env::var("HOME").ok();
13724 unsafe { std::env::set_var("HOME", &temp_home) };
13726 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13728
13729 let config = Box::pin(Config::load_or_init()).await.unwrap();
13730
13731 assert_eq!(config.workspace_dir, workspace_dir);
13732 assert_eq!(config.config_path, legacy_config_path);
13733 assert!(config.config_path.exists());
13734
13735 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13737 if let Some(home) = original_home {
13738 unsafe { std::env::set_var("HOME", home) };
13740 } else {
13741 unsafe { std::env::remove_var("HOME") };
13743 }
13744 let _ = fs::remove_dir_all(temp_home).await;
13745 }
13746
13747 #[test]
13748 async fn load_or_init_workspace_override_keeps_existing_legacy_config() {
13749 let _env_guard = env_override_lock().await;
13750 let temp_home =
13751 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13752 let workspace_dir = temp_home.join("custom-workspace");
13753 let legacy_config_dir = temp_home.join(".construct");
13754 let legacy_config_path = legacy_config_dir.join("config.toml");
13755
13756 fs::create_dir_all(&legacy_config_dir).await.unwrap();
13757 fs::write(
13758 &legacy_config_path,
13759 r#"default_temperature = 0.7
13760default_model = "legacy-model"
13761"#,
13762 )
13763 .await
13764 .unwrap();
13765
13766 let original_home = std::env::var("HOME").ok();
13767 unsafe { std::env::set_var("HOME", &temp_home) };
13769 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13771
13772 let config = Box::pin(Config::load_or_init()).await.unwrap();
13773
13774 assert_eq!(config.workspace_dir, workspace_dir);
13775 assert_eq!(config.config_path, legacy_config_path);
13776 assert_eq!(config.default_model.as_deref(), Some("legacy-model"));
13777
13778 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13780 if let Some(home) = original_home {
13781 unsafe { std::env::set_var("HOME", home) };
13783 } else {
13784 unsafe { std::env::remove_var("HOME") };
13786 }
13787 let _ = fs::remove_dir_all(temp_home).await;
13788 }
13789
13790 #[test]
13791 async fn load_or_init_decrypts_feishu_channel_secrets() {
13792 let _env_guard = env_override_lock().await;
13793 let temp_home =
13794 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13795 let config_dir = temp_home.join(".construct");
13796 let config_path = config_dir.join("config.toml");
13797
13798 fs::create_dir_all(&config_dir).await.unwrap();
13799
13800 let original_home = std::env::var("HOME").ok();
13801 unsafe { std::env::set_var("HOME", &temp_home) };
13803 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13805
13806 let mut config = Config::default();
13807 config.config_path = config_path.clone();
13808 config.workspace_dir = config_dir.join("workspace");
13809 config.secrets.encrypt = true;
13810 config.channels_config.feishu = Some(FeishuConfig {
13811 app_id: "cli_feishu_123".into(),
13812 app_secret: "feishu-secret".into(),
13813 encrypt_key: Some("feishu-encrypt".into()),
13814 verification_token: Some("feishu-verify".into()),
13815 allowed_users: vec!["*".into()],
13816 receive_mode: LarkReceiveMode::Websocket,
13817 port: None,
13818 proxy_url: None,
13819 });
13820 config.save().await.unwrap();
13821
13822 let loaded = Box::pin(Config::load_or_init()).await.unwrap();
13823 let feishu = loaded.channels_config.feishu.as_ref().unwrap();
13824 assert_eq!(feishu.app_secret, "feishu-secret");
13825 assert_eq!(feishu.encrypt_key.as_deref(), Some("feishu-encrypt"));
13826 assert_eq!(feishu.verification_token.as_deref(), Some("feishu-verify"));
13827
13828 if let Some(home) = original_home {
13829 unsafe { std::env::set_var("HOME", home) };
13831 } else {
13832 unsafe { std::env::remove_var("HOME") };
13834 }
13835 let _ = fs::remove_dir_all(temp_home).await;
13836 }
13837
13838 #[test]
13839 async fn load_or_init_uses_persisted_active_workspace_marker() {
13840 let _env_guard = env_override_lock().await;
13841 let temp_home =
13842 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13843 let temp_default_dir = temp_home.join(".construct");
13844 let custom_config_dir = temp_home.join("profiles").join("agent-alpha");
13845
13846 fs::create_dir_all(&custom_config_dir).await.unwrap();
13847 fs::create_dir_all(&temp_default_dir).await.unwrap();
13851 fs::write(
13852 custom_config_dir.join("config.toml"),
13853 "default_temperature = 0.7\ndefault_model = \"persisted-profile\"\n",
13854 )
13855 .await
13856 .unwrap();
13857
13858 persist_active_workspace_config_dir_in(&custom_config_dir, &temp_default_dir)
13861 .await
13862 .unwrap();
13863
13864 let original_home = std::env::var("HOME").ok();
13868 unsafe { std::env::set_var("HOME", &temp_home) };
13870 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13872
13873 let config = Box::pin(Config::load_or_init()).await.unwrap();
13874
13875 assert_eq!(config.config_path, custom_config_dir.join("config.toml"));
13876 assert_eq!(config.workspace_dir, custom_config_dir.join("workspace"));
13877 assert_eq!(config.default_model.as_deref(), Some("persisted-profile"));
13878
13879 if let Some(home) = original_home {
13880 unsafe { std::env::set_var("HOME", home) };
13882 } else {
13883 unsafe { std::env::remove_var("HOME") };
13885 }
13886 let _ = fs::remove_dir_all(temp_home).await;
13887 }
13888
13889 #[test]
13890 async fn load_or_init_env_workspace_override_takes_priority_over_marker() {
13891 let _env_guard = env_override_lock().await;
13892 let temp_home =
13893 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13894 let temp_default_dir = temp_home.join(".construct");
13895 let marker_config_dir = temp_home.join("profiles").join("persisted-profile");
13896 let env_workspace_dir = temp_home.join("env-workspace");
13897
13898 fs::create_dir_all(&marker_config_dir).await.unwrap();
13899 fs::write(
13900 marker_config_dir.join("config.toml"),
13901 "default_temperature = 0.7\ndefault_model = \"marker-model\"\n",
13902 )
13903 .await
13904 .unwrap();
13905
13906 persist_active_workspace_config_dir_in(&marker_config_dir, &temp_default_dir)
13908 .await
13909 .unwrap();
13910
13911 let original_home = std::env::var("HOME").ok();
13912 unsafe { std::env::set_var("HOME", &temp_home) };
13914 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &env_workspace_dir) };
13916
13917 let config = Box::pin(Config::load_or_init()).await.unwrap();
13918
13919 assert_eq!(config.workspace_dir, env_workspace_dir.join("workspace"));
13920 assert_eq!(config.config_path, env_workspace_dir.join("config.toml"));
13921
13922 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13924 if let Some(home) = original_home {
13925 unsafe { std::env::set_var("HOME", home) };
13927 } else {
13928 unsafe { std::env::remove_var("HOME") };
13930 }
13931 let _ = fs::remove_dir_all(temp_home).await;
13932 }
13933
13934 #[test]
13935 async fn persist_active_workspace_marker_is_cleared_for_default_config_dir() {
13936 let temp_home =
13937 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13938 let default_config_dir = temp_home.join(".construct");
13939 let custom_config_dir = temp_home.join("profiles").join("custom-profile");
13940 let marker_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
13941
13942 persist_active_workspace_config_dir_in(&custom_config_dir, &default_config_dir)
13945 .await
13946 .unwrap();
13947 assert!(marker_path.exists());
13948
13949 persist_active_workspace_config_dir_in(&default_config_dir, &default_config_dir)
13950 .await
13951 .unwrap();
13952 assert!(!marker_path.exists());
13953
13954 let _ = fs::remove_dir_all(temp_home).await;
13955 }
13956
13957 #[test]
13958 #[allow(clippy::large_futures)]
13959 async fn load_or_init_logs_existing_config_as_initialized() {
13960 let _env_guard = env_override_lock().await;
13961 let temp_home =
13962 std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13963 let workspace_dir = temp_home.join("profile-a");
13964 let config_path = workspace_dir.join("config.toml");
13965
13966 fs::create_dir_all(&workspace_dir).await.unwrap();
13967 fs::write(
13968 &config_path,
13969 r#"default_temperature = 0.7
13970default_model = "persisted-profile"
13971"#,
13972 )
13973 .await
13974 .unwrap();
13975
13976 let original_home = std::env::var("HOME").ok();
13977 unsafe { std::env::set_var("HOME", &temp_home) };
13979 unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13981
13982 let capture = SharedLogBuffer::default();
13983 let subscriber = tracing_subscriber::fmt()
13984 .with_ansi(false)
13985 .without_time()
13986 .with_target(false)
13987 .with_writer(capture.clone())
13988 .finish();
13989 let dispatch = tracing::Dispatch::new(subscriber);
13990 let guard = tracing::dispatcher::set_default(&dispatch);
13991
13992 let config = Box::pin(Config::load_or_init()).await.unwrap();
13993
13994 drop(guard);
13995 let logs = capture.captured();
13996
13997 assert_eq!(config.workspace_dir, workspace_dir.join("workspace"));
13998 assert_eq!(config.config_path, config_path);
13999 assert_eq!(config.default_model.as_deref(), Some("persisted-profile"));
14000 assert!(logs.contains("Config loaded"), "{logs}");
14001 assert!(logs.contains("initialized=true"), "{logs}");
14002 assert!(!logs.contains("initialized=false"), "{logs}");
14003
14004 unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
14006 if let Some(home) = original_home {
14007 unsafe { std::env::set_var("HOME", home) };
14009 } else {
14010 unsafe { std::env::remove_var("HOME") };
14012 }
14013 let _ = fs::remove_dir_all(temp_home).await;
14014 }
14015
14016 #[test]
14017 async fn env_override_empty_values_ignored() {
14018 let _env_guard = env_override_lock().await;
14019 let mut config = Config::default();
14020 let original_provider = config.default_provider.clone();
14021
14022 unsafe { std::env::set_var("CONSTRUCT_PROVIDER", "") };
14024 config.apply_env_overrides();
14025 assert_eq!(config.default_provider, original_provider);
14026
14027 unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
14029 }
14030
14031 #[test]
14032 async fn env_override_gateway_port() {
14033 let _env_guard = env_override_lock().await;
14034 let mut config = Config::default();
14035 assert_eq!(config.gateway.port, 42617);
14036
14037 unsafe { std::env::set_var("CONSTRUCT_GATEWAY_PORT", "8080") };
14039 config.apply_env_overrides();
14040 assert_eq!(config.gateway.port, 8080);
14041
14042 unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_PORT") };
14044 }
14045
14046 #[test]
14047 async fn env_override_port_fallback() {
14048 let _env_guard = env_override_lock().await;
14049 let mut config = Config::default();
14050
14051 unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_PORT") };
14053 unsafe { std::env::set_var("PORT", "9000") };
14055 config.apply_env_overrides();
14056 assert_eq!(config.gateway.port, 9000);
14057
14058 unsafe { std::env::remove_var("PORT") };
14060 }
14061
14062 #[test]
14063 async fn env_override_gateway_host() {
14064 let _env_guard = env_override_lock().await;
14065 let mut config = Config::default();
14066 assert_eq!(config.gateway.host, "127.0.0.1");
14067
14068 unsafe { std::env::set_var("CONSTRUCT_GATEWAY_HOST", "0.0.0.0") };
14070 config.apply_env_overrides();
14071 assert_eq!(config.gateway.host, "0.0.0.0");
14072
14073 unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_HOST") };
14075 }
14076
14077 #[test]
14078 async fn env_override_host_fallback() {
14079 let _env_guard = env_override_lock().await;
14080 let mut config = Config::default();
14081
14082 unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_HOST") };
14084 unsafe { std::env::set_var("HOST", "0.0.0.0") };
14086 config.apply_env_overrides();
14087 assert_eq!(config.gateway.host, "0.0.0.0");
14088
14089 unsafe { std::env::remove_var("HOST") };
14091 }
14092
14093 #[test]
14094 async fn env_override_require_pairing() {
14095 let _env_guard = env_override_lock().await;
14096 let mut config = Config::default();
14097 assert!(config.gateway.require_pairing);
14098
14099 unsafe { std::env::set_var("CONSTRUCT_REQUIRE_PAIRING", "false") };
14101 config.apply_env_overrides();
14102 assert!(!config.gateway.require_pairing);
14103
14104 unsafe { std::env::set_var("CONSTRUCT_REQUIRE_PAIRING", "true") };
14106 config.apply_env_overrides();
14107 assert!(config.gateway.require_pairing);
14108
14109 unsafe { std::env::remove_var("CONSTRUCT_REQUIRE_PAIRING") };
14111 }
14112
14113 #[test]
14114 async fn env_override_temperature() {
14115 let _env_guard = env_override_lock().await;
14116 let mut config = Config::default();
14117
14118 unsafe { std::env::set_var("CONSTRUCT_TEMPERATURE", "0.5") };
14120 config.apply_env_overrides();
14121 assert!((config.default_temperature - 0.5).abs() < f64::EPSILON);
14122
14123 unsafe { std::env::remove_var("CONSTRUCT_TEMPERATURE") };
14125 }
14126
14127 #[test]
14128 async fn env_override_temperature_out_of_range_ignored() {
14129 let _env_guard = env_override_lock().await;
14130 unsafe { std::env::remove_var("CONSTRUCT_TEMPERATURE") };
14133
14134 let mut config = Config::default();
14135 let original_temp = config.default_temperature;
14136
14137 unsafe { std::env::set_var("CONSTRUCT_TEMPERATURE", "3.0") };
14140 config.apply_env_overrides();
14141 assert!(
14142 (config.default_temperature - original_temp).abs() < f64::EPSILON,
14143 "Temperature 3.0 should be ignored (out of range)"
14144 );
14145
14146 unsafe { std::env::remove_var("CONSTRUCT_TEMPERATURE") };
14148 }
14149
14150 #[test]
14151 async fn env_override_reasoning_enabled() {
14152 let _env_guard = env_override_lock().await;
14153 let mut config = Config::default();
14154 assert_eq!(config.runtime.reasoning_enabled, None);
14155
14156 unsafe { std::env::set_var("CONSTRUCT_REASONING_ENABLED", "false") };
14158 config.apply_env_overrides();
14159 assert_eq!(config.runtime.reasoning_enabled, Some(false));
14160
14161 unsafe { std::env::set_var("CONSTRUCT_REASONING_ENABLED", "true") };
14163 config.apply_env_overrides();
14164 assert_eq!(config.runtime.reasoning_enabled, Some(true));
14165
14166 unsafe { std::env::remove_var("CONSTRUCT_REASONING_ENABLED") };
14168 }
14169
14170 #[test]
14171 async fn env_override_reasoning_invalid_value_ignored() {
14172 let _env_guard = env_override_lock().await;
14173 let mut config = Config::default();
14174 config.runtime.reasoning_enabled = Some(false);
14175
14176 unsafe { std::env::set_var("CONSTRUCT_REASONING_ENABLED", "maybe") };
14178 config.apply_env_overrides();
14179 assert_eq!(config.runtime.reasoning_enabled, Some(false));
14180
14181 unsafe { std::env::remove_var("CONSTRUCT_REASONING_ENABLED") };
14183 }
14184
14185 #[test]
14186 async fn env_override_reasoning_effort() {
14187 let _env_guard = env_override_lock().await;
14188 let mut config = Config::default();
14189 assert_eq!(config.runtime.reasoning_effort, None);
14190
14191 unsafe { std::env::set_var("CONSTRUCT_REASONING_EFFORT", "HIGH") };
14193 config.apply_env_overrides();
14194 assert_eq!(config.runtime.reasoning_effort.as_deref(), Some("high"));
14195
14196 unsafe { std::env::remove_var("CONSTRUCT_REASONING_EFFORT") };
14198 }
14199
14200 #[test]
14201 async fn env_override_reasoning_effort_legacy_codex_env() {
14202 let _env_guard = env_override_lock().await;
14203 let mut config = Config::default();
14204
14205 unsafe { std::env::set_var("CONSTRUCT_CODEX_REASONING_EFFORT", "minimal") };
14207 config.apply_env_overrides();
14208 assert_eq!(config.runtime.reasoning_effort.as_deref(), Some("minimal"));
14209
14210 unsafe { std::env::remove_var("CONSTRUCT_CODEX_REASONING_EFFORT") };
14212 }
14213
14214 #[test]
14215 async fn env_override_invalid_port_ignored() {
14216 let _env_guard = env_override_lock().await;
14217 let mut config = Config::default();
14218 let original_port = config.gateway.port;
14219
14220 unsafe { std::env::set_var("PORT", "not_a_number") };
14222 config.apply_env_overrides();
14223 assert_eq!(config.gateway.port, original_port);
14224
14225 unsafe { std::env::remove_var("PORT") };
14227 }
14228
14229 #[test]
14230 async fn env_override_web_search_config() {
14231 let _env_guard = env_override_lock().await;
14232 let mut config = Config::default();
14233
14234 unsafe { std::env::set_var("WEB_SEARCH_ENABLED", "false") };
14236 unsafe { std::env::set_var("WEB_SEARCH_PROVIDER", "brave") };
14238 unsafe { std::env::set_var("WEB_SEARCH_MAX_RESULTS", "7") };
14240 unsafe { std::env::set_var("WEB_SEARCH_TIMEOUT_SECS", "20") };
14242 unsafe { std::env::set_var("BRAVE_API_KEY", "brave-test-key") };
14244
14245 config.apply_env_overrides();
14246
14247 assert!(!config.web_search.enabled);
14248 assert_eq!(config.web_search.provider, "brave");
14249 assert_eq!(config.web_search.max_results, 7);
14250 assert_eq!(config.web_search.timeout_secs, 20);
14251 assert_eq!(
14252 config.web_search.brave_api_key.as_deref(),
14253 Some("brave-test-key")
14254 );
14255
14256 unsafe { std::env::remove_var("WEB_SEARCH_ENABLED") };
14258 unsafe { std::env::remove_var("WEB_SEARCH_PROVIDER") };
14260 unsafe { std::env::remove_var("WEB_SEARCH_MAX_RESULTS") };
14262 unsafe { std::env::remove_var("WEB_SEARCH_TIMEOUT_SECS") };
14264 unsafe { std::env::remove_var("BRAVE_API_KEY") };
14266 }
14267
14268 #[test]
14269 async fn env_override_web_search_invalid_values_ignored() {
14270 let _env_guard = env_override_lock().await;
14271 let mut config = Config::default();
14272 let original_max_results = config.web_search.max_results;
14273 let original_timeout = config.web_search.timeout_secs;
14274
14275 unsafe { std::env::set_var("WEB_SEARCH_MAX_RESULTS", "99") };
14277 unsafe { std::env::set_var("WEB_SEARCH_TIMEOUT_SECS", "0") };
14279
14280 config.apply_env_overrides();
14281
14282 assert_eq!(config.web_search.max_results, original_max_results);
14283 assert_eq!(config.web_search.timeout_secs, original_timeout);
14284
14285 unsafe { std::env::remove_var("WEB_SEARCH_MAX_RESULTS") };
14287 unsafe { std::env::remove_var("WEB_SEARCH_TIMEOUT_SECS") };
14289 }
14290
14291 #[test]
14292 async fn env_override_storage_provider_config() {
14293 let _env_guard = env_override_lock().await;
14294 let mut config = Config::default();
14295
14296 unsafe { std::env::set_var("CONSTRUCT_STORAGE_PROVIDER", "qdrant") };
14298 unsafe { std::env::set_var("CONSTRUCT_STORAGE_DB_URL", "http://localhost:6333") };
14300 unsafe { std::env::set_var("CONSTRUCT_STORAGE_CONNECT_TIMEOUT_SECS", "15") };
14302
14303 config.apply_env_overrides();
14304
14305 assert_eq!(config.storage.provider.config.provider, "qdrant");
14306 assert_eq!(
14307 config.storage.provider.config.db_url.as_deref(),
14308 Some("http://localhost:6333")
14309 );
14310 assert_eq!(
14311 config.storage.provider.config.connect_timeout_secs,
14312 Some(15)
14313 );
14314
14315 unsafe { std::env::remove_var("CONSTRUCT_STORAGE_PROVIDER") };
14317 unsafe { std::env::remove_var("CONSTRUCT_STORAGE_DB_URL") };
14319 unsafe { std::env::remove_var("CONSTRUCT_STORAGE_CONNECT_TIMEOUT_SECS") };
14321 }
14322
14323 #[test]
14324 async fn proxy_config_scope_services_requires_entries_when_enabled() {
14325 let proxy = ProxyConfig {
14326 enabled: true,
14327 http_proxy: Some("http://127.0.0.1:7890".into()),
14328 https_proxy: None,
14329 all_proxy: None,
14330 no_proxy: Vec::new(),
14331 scope: ProxyScope::Services,
14332 services: Vec::new(),
14333 };
14334
14335 let error = proxy.validate().unwrap_err().to_string();
14336 assert!(error.contains("proxy.scope='services'"));
14337 }
14338
14339 #[test]
14340 async fn env_override_proxy_scope_services() {
14341 let _env_guard = env_override_lock().await;
14342 clear_proxy_env_test_vars();
14343
14344 let mut config = Config::default();
14345 unsafe { std::env::set_var("CONSTRUCT_PROXY_ENABLED", "true") };
14347 unsafe { std::env::set_var("CONSTRUCT_HTTP_PROXY", "http://127.0.0.1:7890") };
14349 unsafe {
14351 std::env::set_var(
14352 "CONSTRUCT_PROXY_SERVICES",
14353 "provider.openai, tool.http_request",
14354 );
14355 }
14356 unsafe { std::env::set_var("CONSTRUCT_PROXY_SCOPE", "services") };
14358
14359 config.apply_env_overrides();
14360
14361 assert!(config.proxy.enabled);
14362 assert_eq!(config.proxy.scope, ProxyScope::Services);
14363 assert_eq!(
14364 config.proxy.http_proxy.as_deref(),
14365 Some("http://127.0.0.1:7890")
14366 );
14367 assert!(config.proxy.should_apply_to_service("provider.openai"));
14368 assert!(config.proxy.should_apply_to_service("tool.http_request"));
14369 assert!(!config.proxy.should_apply_to_service("provider.anthropic"));
14370
14371 clear_proxy_env_test_vars();
14372 }
14373
14374 #[test]
14375 async fn env_override_proxy_scope_environment_applies_process_env() {
14376 let _env_guard = env_override_lock().await;
14377 clear_proxy_env_test_vars();
14378
14379 let mut config = Config::default();
14380 unsafe { std::env::set_var("CONSTRUCT_PROXY_ENABLED", "true") };
14382 unsafe { std::env::set_var("CONSTRUCT_PROXY_SCOPE", "environment") };
14384 unsafe { std::env::set_var("CONSTRUCT_HTTP_PROXY", "http://127.0.0.1:7890") };
14386 unsafe { std::env::set_var("CONSTRUCT_HTTPS_PROXY", "http://127.0.0.1:7891") };
14388 unsafe { std::env::set_var("CONSTRUCT_NO_PROXY", "localhost,127.0.0.1") };
14390
14391 config.apply_env_overrides();
14392
14393 assert_eq!(config.proxy.scope, ProxyScope::Environment);
14394 assert_eq!(
14395 std::env::var("HTTP_PROXY").ok().as_deref(),
14396 Some("http://127.0.0.1:7890")
14397 );
14398 assert_eq!(
14399 std::env::var("HTTPS_PROXY").ok().as_deref(),
14400 Some("http://127.0.0.1:7891")
14401 );
14402 assert!(
14403 std::env::var("NO_PROXY")
14404 .ok()
14405 .is_some_and(|value| value.contains("localhost"))
14406 );
14407
14408 clear_proxy_env_test_vars();
14409 }
14410
14411 #[test]
14412 async fn google_workspace_allowed_operations_require_methods() {
14413 let mut config = Config::default();
14414 config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
14415 service: "gmail".into(),
14416 resource: "users".into(),
14417 sub_resource: Some("drafts".into()),
14418 methods: Vec::new(),
14419 }];
14420
14421 let err = config.validate().unwrap_err().to_string();
14422 assert!(err.contains("google_workspace.allowed_operations[0].methods"));
14423 }
14424
14425 #[test]
14426 async fn google_workspace_allowed_operations_reject_duplicate_service_resource_sub_resource_entries()
14427 {
14428 let mut config = Config::default();
14429 config.google_workspace.allowed_operations = vec![
14430 GoogleWorkspaceAllowedOperation {
14431 service: "gmail".into(),
14432 resource: "users".into(),
14433 sub_resource: Some("drafts".into()),
14434 methods: vec!["create".into()],
14435 },
14436 GoogleWorkspaceAllowedOperation {
14437 service: "gmail".into(),
14438 resource: "users".into(),
14439 sub_resource: Some("drafts".into()),
14440 methods: vec!["update".into()],
14441 },
14442 ];
14443
14444 let err = config.validate().unwrap_err().to_string();
14445 assert!(err.contains("duplicate service/resource/sub_resource entry"));
14446 }
14447
14448 #[test]
14449 async fn google_workspace_allowed_operations_allow_same_resource_different_sub_resource() {
14450 let mut config = Config::default();
14451 config.google_workspace.allowed_operations = vec![
14452 GoogleWorkspaceAllowedOperation {
14453 service: "gmail".into(),
14454 resource: "users".into(),
14455 sub_resource: Some("messages".into()),
14456 methods: vec!["list".into(), "get".into()],
14457 },
14458 GoogleWorkspaceAllowedOperation {
14459 service: "gmail".into(),
14460 resource: "users".into(),
14461 sub_resource: Some("drafts".into()),
14462 methods: vec!["create".into(), "update".into()],
14463 },
14464 ];
14465
14466 assert!(config.validate().is_ok());
14467 }
14468
14469 #[test]
14470 async fn google_workspace_allowed_operations_reject_duplicate_methods_within_entry() {
14471 let mut config = Config::default();
14472 config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
14473 service: "gmail".into(),
14474 resource: "users".into(),
14475 sub_resource: Some("drafts".into()),
14476 methods: vec!["create".into(), "create".into()],
14477 }];
14478
14479 let err = config.validate().unwrap_err().to_string();
14480 assert!(
14481 err.contains("duplicate entry"),
14482 "expected duplicate entry error, got: {err}"
14483 );
14484 }
14485
14486 #[test]
14487 async fn google_workspace_allowed_operations_accept_valid_entries() {
14488 let mut config = Config::default();
14489 config.google_workspace.allowed_operations = vec![
14490 GoogleWorkspaceAllowedOperation {
14491 service: "gmail".into(),
14492 resource: "users".into(),
14493 sub_resource: Some("messages".into()),
14494 methods: vec!["list".into(), "get".into()],
14495 },
14496 GoogleWorkspaceAllowedOperation {
14497 service: "drive".into(),
14498 resource: "files".into(),
14499 sub_resource: None,
14500 methods: vec!["list".into(), "get".into()],
14501 },
14502 ];
14503
14504 assert!(config.validate().is_ok());
14505 }
14506
14507 #[test]
14508 async fn google_workspace_allowed_operations_reject_invalid_sub_resource_characters() {
14509 let mut config = Config::default();
14510 config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
14511 service: "gmail".into(),
14512 resource: "users".into(),
14513 sub_resource: Some("bad resource!".into()),
14514 methods: vec!["list".into()],
14515 }];
14516
14517 let err = config.validate().unwrap_err().to_string();
14518 assert!(err.contains("sub_resource contains invalid characters"));
14519 }
14520
14521 fn runtime_proxy_cache_contains(cache_key: &str) -> bool {
14522 match runtime_proxy_client_cache().read() {
14523 Ok(guard) => guard.contains_key(cache_key),
14524 Err(poisoned) => poisoned.into_inner().contains_key(cache_key),
14525 }
14526 }
14527
14528 #[test]
14529 async fn runtime_proxy_client_cache_reuses_default_profile_key() {
14530 let service_key = format!(
14531 "provider.cache_test.{}",
14532 std::time::SystemTime::now()
14533 .duration_since(std::time::UNIX_EPOCH)
14534 .expect("system clock should be after unix epoch")
14535 .as_nanos()
14536 );
14537 let cache_key = runtime_proxy_cache_key(&service_key, None, None);
14538
14539 clear_runtime_proxy_client_cache();
14540 assert!(!runtime_proxy_cache_contains(&cache_key));
14541
14542 let _ = build_runtime_proxy_client(&service_key);
14543 assert!(runtime_proxy_cache_contains(&cache_key));
14544
14545 let _ = build_runtime_proxy_client(&service_key);
14546 assert!(runtime_proxy_cache_contains(&cache_key));
14547 }
14548
14549 #[test]
14550 async fn set_runtime_proxy_config_clears_runtime_proxy_client_cache() {
14551 let service_key = format!(
14552 "provider.cache_timeout_test.{}",
14553 std::time::SystemTime::now()
14554 .duration_since(std::time::UNIX_EPOCH)
14555 .expect("system clock should be after unix epoch")
14556 .as_nanos()
14557 );
14558 let cache_key = runtime_proxy_cache_key(&service_key, Some(30), Some(5));
14559
14560 clear_runtime_proxy_client_cache();
14561 let _ = build_runtime_proxy_client_with_timeouts(&service_key, 30, 5);
14562 assert!(runtime_proxy_cache_contains(&cache_key));
14563
14564 set_runtime_proxy_config(ProxyConfig::default());
14565 assert!(!runtime_proxy_cache_contains(&cache_key));
14566 }
14567
14568 #[test]
14569 async fn gateway_config_default_values() {
14570 let g = GatewayConfig::default();
14571 assert_eq!(g.port, 42617);
14572 assert_eq!(g.host, "127.0.0.1");
14573 assert!(g.require_pairing);
14574 assert!(!g.allow_public_bind);
14575 assert!(g.paired_tokens.is_empty());
14576 assert!(!g.trust_forwarded_headers);
14577 assert_eq!(g.rate_limit_max_keys, 10_000);
14578 assert_eq!(g.idempotency_max_keys, 10_000);
14579 }
14580
14581 #[test]
14584 async fn peripherals_config_default_disabled() {
14585 let p = PeripheralsConfig::default();
14586 assert!(!p.enabled);
14587 assert!(p.boards.is_empty());
14588 }
14589
14590 #[test]
14591 async fn peripheral_board_config_defaults() {
14592 let b = PeripheralBoardConfig::default();
14593 assert!(b.board.is_empty());
14594 assert_eq!(b.transport, "serial");
14595 assert!(b.path.is_none());
14596 assert_eq!(b.baud, 115_200);
14597 }
14598
14599 #[test]
14600 async fn peripherals_config_toml_roundtrip() {
14601 let p = PeripheralsConfig {
14602 enabled: true,
14603 boards: vec![PeripheralBoardConfig {
14604 board: "nucleo-f401re".into(),
14605 transport: "serial".into(),
14606 path: Some("/dev/ttyACM0".into()),
14607 baud: 115_200,
14608 }],
14609 datasheet_dir: None,
14610 };
14611 let toml_str = toml::to_string(&p).unwrap();
14612 let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
14613 assert!(parsed.enabled);
14614 assert_eq!(parsed.boards.len(), 1);
14615 assert_eq!(parsed.boards[0].board, "nucleo-f401re");
14616 assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
14617 }
14618
14619 #[test]
14620 async fn lark_config_serde() {
14621 let lc = LarkConfig {
14622 app_id: "cli_123456".into(),
14623 app_secret: "secret_abc".into(),
14624 encrypt_key: Some("encrypt_key".into()),
14625 verification_token: Some("verify_token".into()),
14626 allowed_users: vec!["user_123".into(), "user_456".into()],
14627 mention_only: false,
14628 use_feishu: true,
14629 receive_mode: LarkReceiveMode::Websocket,
14630 port: None,
14631 proxy_url: None,
14632 };
14633 let json = serde_json::to_string(&lc).unwrap();
14634 let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
14635 assert_eq!(parsed.app_id, "cli_123456");
14636 assert_eq!(parsed.app_secret, "secret_abc");
14637 assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
14638 assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
14639 assert_eq!(parsed.allowed_users.len(), 2);
14640 assert!(parsed.use_feishu);
14641 }
14642
14643 #[test]
14644 async fn lark_config_toml_roundtrip() {
14645 let lc = LarkConfig {
14646 app_id: "cli_123456".into(),
14647 app_secret: "secret_abc".into(),
14648 encrypt_key: Some("encrypt_key".into()),
14649 verification_token: Some("verify_token".into()),
14650 allowed_users: vec!["*".into()],
14651 mention_only: false,
14652 use_feishu: false,
14653 receive_mode: LarkReceiveMode::Webhook,
14654 port: Some(9898),
14655 proxy_url: None,
14656 };
14657 let toml_str = toml::to_string(&lc).unwrap();
14658 let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
14659 assert_eq!(parsed.app_id, "cli_123456");
14660 assert_eq!(parsed.app_secret, "secret_abc");
14661 assert!(!parsed.use_feishu);
14662 }
14663
14664 #[test]
14665 async fn lark_config_deserializes_without_optional_fields() {
14666 let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
14667 let parsed: LarkConfig = serde_json::from_str(json).unwrap();
14668 assert!(parsed.encrypt_key.is_none());
14669 assert!(parsed.verification_token.is_none());
14670 assert!(parsed.allowed_users.is_empty());
14671 assert!(!parsed.mention_only);
14672 assert!(!parsed.use_feishu);
14673 }
14674
14675 #[test]
14676 async fn lark_config_defaults_to_lark_endpoint() {
14677 let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
14678 let parsed: LarkConfig = serde_json::from_str(json).unwrap();
14679 assert!(
14680 !parsed.use_feishu,
14681 "use_feishu should default to false (Lark)"
14682 );
14683 }
14684
14685 #[test]
14686 async fn lark_config_with_wildcard_allowed_users() {
14687 let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#;
14688 let parsed: LarkConfig = serde_json::from_str(json).unwrap();
14689 assert_eq!(parsed.allowed_users, vec!["*"]);
14690 }
14691
14692 #[test]
14693 async fn feishu_config_serde() {
14694 let fc = FeishuConfig {
14695 app_id: "cli_feishu_123".into(),
14696 app_secret: "secret_abc".into(),
14697 encrypt_key: Some("encrypt_key".into()),
14698 verification_token: Some("verify_token".into()),
14699 allowed_users: vec!["user_123".into(), "user_456".into()],
14700 receive_mode: LarkReceiveMode::Websocket,
14701 port: None,
14702 proxy_url: None,
14703 };
14704 let json = serde_json::to_string(&fc).unwrap();
14705 let parsed: FeishuConfig = serde_json::from_str(&json).unwrap();
14706 assert_eq!(parsed.app_id, "cli_feishu_123");
14707 assert_eq!(parsed.app_secret, "secret_abc");
14708 assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
14709 assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
14710 assert_eq!(parsed.allowed_users.len(), 2);
14711 }
14712
14713 #[test]
14714 async fn feishu_config_toml_roundtrip() {
14715 let fc = FeishuConfig {
14716 app_id: "cli_feishu_123".into(),
14717 app_secret: "secret_abc".into(),
14718 encrypt_key: Some("encrypt_key".into()),
14719 verification_token: Some("verify_token".into()),
14720 allowed_users: vec!["*".into()],
14721 receive_mode: LarkReceiveMode::Webhook,
14722 port: Some(9898),
14723 proxy_url: None,
14724 };
14725 let toml_str = toml::to_string(&fc).unwrap();
14726 let parsed: FeishuConfig = toml::from_str(&toml_str).unwrap();
14727 assert_eq!(parsed.app_id, "cli_feishu_123");
14728 assert_eq!(parsed.app_secret, "secret_abc");
14729 assert_eq!(parsed.receive_mode, LarkReceiveMode::Webhook);
14730 assert_eq!(parsed.port, Some(9898));
14731 }
14732
14733 #[test]
14734 async fn feishu_config_deserializes_without_optional_fields() {
14735 let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
14736 let parsed: FeishuConfig = serde_json::from_str(json).unwrap();
14737 assert!(parsed.encrypt_key.is_none());
14738 assert!(parsed.verification_token.is_none());
14739 assert!(parsed.allowed_users.is_empty());
14740 assert_eq!(parsed.receive_mode, LarkReceiveMode::Websocket);
14741 assert!(parsed.port.is_none());
14742 }
14743
14744 #[test]
14745 async fn nextcloud_talk_config_serde() {
14746 let nc = NextcloudTalkConfig {
14747 base_url: "https://cloud.example.com".into(),
14748 app_token: "app-token".into(),
14749 webhook_secret: Some("webhook-secret".into()),
14750 allowed_users: vec!["user_a".into(), "*".into()],
14751 proxy_url: None,
14752 bot_name: None,
14753 };
14754
14755 let json = serde_json::to_string(&nc).unwrap();
14756 let parsed: NextcloudTalkConfig = serde_json::from_str(&json).unwrap();
14757 assert_eq!(parsed.base_url, "https://cloud.example.com");
14758 assert_eq!(parsed.app_token, "app-token");
14759 assert_eq!(parsed.webhook_secret.as_deref(), Some("webhook-secret"));
14760 assert_eq!(parsed.allowed_users, vec!["user_a", "*"]);
14761 }
14762
14763 #[test]
14764 async fn nextcloud_talk_config_defaults_optional_fields() {
14765 let json = r#"{"base_url":"https://cloud.example.com","app_token":"app-token"}"#;
14766 let parsed: NextcloudTalkConfig = serde_json::from_str(json).unwrap();
14767 assert!(parsed.webhook_secret.is_none());
14768 assert!(parsed.allowed_users.is_empty());
14769 }
14770
14771 #[cfg(unix)]
14774 #[test]
14775 async fn new_config_file_has_restricted_permissions() {
14776 let tmp = tempfile::TempDir::new().unwrap();
14777 let config_path = tmp.path().join("config.toml");
14778
14779 let mut config = Config::default();
14781 config.config_path = config_path.clone();
14782 config.save().await.unwrap();
14783
14784 let meta = fs::metadata(&config_path).await.unwrap();
14785 let mode = meta.permissions().mode() & 0o777;
14786 assert_eq!(
14787 mode, 0o600,
14788 "New config file should be owner-only (0600), got {mode:o}"
14789 );
14790 }
14791
14792 #[cfg(unix)]
14793 #[test]
14794 async fn save_restricts_existing_world_readable_config_to_owner_only() {
14795 let tmp = tempfile::TempDir::new().unwrap();
14796 let config_path = tmp.path().join("config.toml");
14797
14798 let mut config = Config::default();
14799 config.config_path = config_path.clone();
14800 config.save().await.unwrap();
14801
14802 std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
14804 let loose_mode = std::fs::metadata(&config_path)
14805 .unwrap()
14806 .permissions()
14807 .mode()
14808 & 0o777;
14809 assert_eq!(
14810 loose_mode, 0o644,
14811 "test setup requires world-readable config"
14812 );
14813
14814 config.default_temperature = 0.6;
14815 config.save().await.unwrap();
14816
14817 let hardened_mode = std::fs::metadata(&config_path)
14818 .unwrap()
14819 .permissions()
14820 .mode()
14821 & 0o777;
14822 assert_eq!(
14823 hardened_mode, 0o600,
14824 "Saving config should restore owner-only permissions (0600)"
14825 );
14826 }
14827
14828 #[cfg(unix)]
14829 #[test]
14830 async fn world_readable_config_is_detectable() {
14831 use std::os::unix::fs::PermissionsExt;
14832
14833 let tmp = tempfile::TempDir::new().unwrap();
14834 let config_path = tmp.path().join("config.toml");
14835
14836 std::fs::write(&config_path, "# test config").unwrap();
14838 std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
14839
14840 let meta = std::fs::metadata(&config_path).unwrap();
14841 let mode = meta.permissions().mode();
14842 assert!(
14843 mode & 0o004 != 0,
14844 "Test setup: file should be world-readable (mode {mode:o})"
14845 );
14846 }
14847
14848 #[test]
14849 async fn transcription_config_defaults() {
14850 let tc = TranscriptionConfig::default();
14851 assert!(!tc.enabled);
14852 assert!(tc.api_url.contains("groq.com"));
14853 assert_eq!(tc.model, "whisper-large-v3-turbo");
14854 assert!(tc.language.is_none());
14855 assert_eq!(tc.max_duration_secs, 120);
14856 assert!(!tc.transcribe_non_ptt_audio);
14857 }
14858
14859 #[test]
14860 async fn config_roundtrip_with_transcription() {
14861 let mut config = Config::default();
14862 config.transcription.enabled = true;
14863 config.transcription.language = Some("en".into());
14864
14865 let toml_str = toml::to_string_pretty(&config).unwrap();
14866 let parsed = parse_test_config(&toml_str);
14867
14868 assert!(parsed.transcription.enabled);
14869 assert_eq!(parsed.transcription.language.as_deref(), Some("en"));
14870 assert_eq!(parsed.transcription.model, "whisper-large-v3-turbo");
14871 }
14872
14873 #[test]
14874 async fn config_without_transcription_uses_defaults() {
14875 let toml_str = r#"
14876 default_provider = "openrouter"
14877 default_model = "test-model"
14878 default_temperature = 0.7
14879 "#;
14880 let parsed = parse_test_config(toml_str);
14881 assert!(!parsed.transcription.enabled);
14882 assert_eq!(parsed.transcription.max_duration_secs, 120);
14883 }
14884
14885 #[test]
14886 async fn security_defaults_are_backward_compatible() {
14887 let parsed = parse_test_config(
14888 r#"
14889default_provider = "openrouter"
14890default_model = "anthropic/claude-sonnet-4.6"
14891default_temperature = 0.7
14892"#,
14893 );
14894
14895 assert!(!parsed.security.otp.enabled);
14896 assert_eq!(parsed.security.otp.method, OtpMethod::Totp);
14897 assert!(!parsed.security.estop.enabled);
14898 assert!(parsed.security.estop.require_otp_to_resume);
14899 }
14900
14901 #[test]
14902 async fn security_toml_parses_otp_and_estop_sections() {
14903 let parsed = parse_test_config(
14904 r#"
14905default_provider = "openrouter"
14906default_model = "anthropic/claude-sonnet-4.6"
14907default_temperature = 0.7
14908
14909[security.otp]
14910enabled = true
14911method = "totp"
14912token_ttl_secs = 30
14913cache_valid_secs = 120
14914gated_actions = ["shell", "browser_open"]
14915gated_domains = ["*.chase.com", "accounts.google.com"]
14916gated_domain_categories = ["banking"]
14917
14918[security.estop]
14919enabled = true
14920state_file = "~/.construct/estop-state.json"
14921require_otp_to_resume = true
14922"#,
14923 );
14924
14925 assert!(parsed.security.otp.enabled);
14926 assert!(parsed.security.estop.enabled);
14927 assert_eq!(parsed.security.otp.gated_actions.len(), 2);
14928 assert_eq!(parsed.security.otp.gated_domains.len(), 2);
14929 parsed.validate().unwrap();
14930 }
14931
14932 #[test]
14933 async fn security_validation_rejects_invalid_domain_glob() {
14934 let mut config = Config::default();
14935 config.security.otp.gated_domains = vec!["bad domain.com".into()];
14936
14937 let err = config.validate().expect_err("expected invalid domain glob");
14938 assert!(err.to_string().contains("gated_domains"));
14939 }
14940
14941 #[test]
14942 async fn validate_accepts_local_whisper_as_transcription_default_provider() {
14943 let mut config = Config::default();
14944 config.transcription.default_provider = "local_whisper".to_string();
14945
14946 config.validate().expect(
14947 "local_whisper must be accepted by the transcription.default_provider allowlist",
14948 );
14949 }
14950
14951 #[test]
14952 async fn validate_rejects_unknown_transcription_default_provider() {
14953 let mut config = Config::default();
14954 config.transcription.default_provider = "unknown_stt".to_string();
14955
14956 let err = config
14957 .validate()
14958 .expect_err("expected validation to reject unknown transcription provider");
14959 assert!(
14960 err.to_string().contains("transcription.default_provider"),
14961 "got: {err}"
14962 );
14963 }
14964
14965 #[tokio::test]
14966 async fn channel_secret_telegram_bot_token_roundtrip() {
14967 let dir = std::env::temp_dir().join(format!(
14968 "construct_test_tg_bot_token_{}",
14969 uuid::Uuid::new_v4()
14970 ));
14971 fs::create_dir_all(&dir).await.unwrap();
14972
14973 let plaintext_token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";
14974
14975 let mut config = Config::default();
14976 config.workspace_dir = dir.join("workspace");
14977 config.config_path = dir.join("config.toml");
14978 config.channels_config.telegram = Some(TelegramConfig {
14979 bot_token: plaintext_token.into(),
14980 allowed_users: vec!["user1".into()],
14981 stream_mode: StreamMode::default(),
14982 draft_update_interval_ms: default_draft_update_interval_ms(),
14983 interrupt_on_new_message: false,
14984 mention_only: false,
14985 ack_reactions: None,
14986 proxy_url: None,
14987 notification_chat_id: None,
14988 });
14989
14990 config.save().await.unwrap();
14992
14993 let raw_toml = tokio::fs::read_to_string(&config.config_path)
14995 .await
14996 .unwrap();
14997 assert!(
14998 !raw_toml.contains(plaintext_token),
14999 "Saved TOML must not contain the plaintext bot_token"
15000 );
15001
15002 let stored: Config = toml::from_str(&raw_toml).unwrap();
15004 let stored_token = &stored.channels_config.telegram.as_ref().unwrap().bot_token;
15005 assert!(
15006 crate::security::SecretStore::is_encrypted(stored_token),
15007 "Stored bot_token must be marked as encrypted"
15008 );
15009
15010 let store = crate::security::SecretStore::new(&dir, true);
15012 assert_eq!(store.decrypt(stored_token).unwrap(), plaintext_token);
15013
15014 let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
15016 loaded.config_path = dir.join("config.toml");
15017 let load_store = crate::security::SecretStore::new(&dir, loaded.secrets.encrypt);
15018 if let Some(ref mut tg) = loaded.channels_config.telegram {
15019 decrypt_secret(
15020 &load_store,
15021 &mut tg.bot_token,
15022 "config.channels_config.telegram.bot_token",
15023 )
15024 .unwrap();
15025 }
15026 assert_eq!(
15027 loaded.channels_config.telegram.as_ref().unwrap().bot_token,
15028 plaintext_token,
15029 "Loaded bot_token must match the original plaintext after decryption"
15030 );
15031
15032 let _ = fs::remove_dir_all(&dir).await;
15033 }
15034
15035 #[test]
15036 async fn security_validation_rejects_unknown_domain_category() {
15037 let mut config = Config::default();
15038 config.security.otp.gated_domain_categories = vec!["not_real".into()];
15039
15040 let err = config
15041 .validate()
15042 .expect_err("expected unknown domain category");
15043 assert!(err.to_string().contains("gated_domain_categories"));
15044 }
15045
15046 #[test]
15047 async fn security_validation_rejects_zero_token_ttl() {
15048 let mut config = Config::default();
15049 config.security.otp.token_ttl_secs = 0;
15050
15051 let err = config
15052 .validate()
15053 .expect_err("expected ttl validation failure");
15054 assert!(err.to_string().contains("token_ttl_secs"));
15055 }
15056
15057 fn stdio_server(name: &str, command: &str) -> McpServerConfig {
15060 McpServerConfig {
15061 name: name.to_string(),
15062 transport: McpTransport::Stdio,
15063 command: command.to_string(),
15064 ..Default::default()
15065 }
15066 }
15067
15068 fn http_server(name: &str, url: &str) -> McpServerConfig {
15069 McpServerConfig {
15070 name: name.to_string(),
15071 transport: McpTransport::Http,
15072 url: Some(url.to_string()),
15073 ..Default::default()
15074 }
15075 }
15076
15077 fn sse_server(name: &str, url: &str) -> McpServerConfig {
15078 McpServerConfig {
15079 name: name.to_string(),
15080 transport: McpTransport::Sse,
15081 url: Some(url.to_string()),
15082 ..Default::default()
15083 }
15084 }
15085
15086 #[test]
15087 async fn validate_mcp_config_empty_servers_ok() {
15088 let cfg = McpConfig::default();
15089 assert!(validate_mcp_config(&cfg).is_ok());
15090 }
15091
15092 #[test]
15093 async fn validate_mcp_config_valid_stdio_ok() {
15094 let cfg = McpConfig {
15095 enabled: true,
15096 servers: vec![stdio_server("fs", "/usr/bin/mcp-fs")],
15097 ..Default::default()
15098 };
15099 assert!(validate_mcp_config(&cfg).is_ok());
15100 }
15101
15102 #[test]
15103 async fn validate_mcp_config_valid_http_ok() {
15104 let cfg = McpConfig {
15105 enabled: true,
15106 servers: vec![http_server("svc", "http://localhost:8080/mcp")],
15107 ..Default::default()
15108 };
15109 assert!(validate_mcp_config(&cfg).is_ok());
15110 }
15111
15112 #[test]
15113 async fn validate_mcp_config_valid_sse_ok() {
15114 let cfg = McpConfig {
15115 enabled: true,
15116 servers: vec![sse_server("svc", "https://example.com/events")],
15117 ..Default::default()
15118 };
15119 assert!(validate_mcp_config(&cfg).is_ok());
15120 }
15121
15122 #[test]
15123 async fn validate_mcp_config_rejects_empty_name() {
15124 let cfg = McpConfig {
15125 enabled: true,
15126 servers: vec![stdio_server("", "/usr/bin/tool")],
15127 ..Default::default()
15128 };
15129 let err = validate_mcp_config(&cfg).expect_err("empty name should fail");
15130 assert!(
15131 err.to_string().contains("name must not be empty"),
15132 "got: {err}"
15133 );
15134 }
15135
15136 #[test]
15137 async fn validate_mcp_config_rejects_whitespace_name() {
15138 let cfg = McpConfig {
15139 enabled: true,
15140 servers: vec![stdio_server(" ", "/usr/bin/tool")],
15141 ..Default::default()
15142 };
15143 let err = validate_mcp_config(&cfg).expect_err("whitespace name should fail");
15144 assert!(
15145 err.to_string().contains("name must not be empty"),
15146 "got: {err}"
15147 );
15148 }
15149
15150 #[test]
15151 async fn validate_mcp_config_rejects_duplicate_names() {
15152 let cfg = McpConfig {
15153 enabled: true,
15154 servers: vec![
15155 stdio_server("fs", "/usr/bin/mcp-a"),
15156 stdio_server("fs", "/usr/bin/mcp-b"),
15157 ],
15158 ..Default::default()
15159 };
15160 let err = validate_mcp_config(&cfg).expect_err("duplicate name should fail");
15161 assert!(err.to_string().contains("duplicate name"), "got: {err}");
15162 }
15163
15164 #[test]
15165 async fn validate_mcp_config_rejects_zero_timeout() {
15166 let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
15167 server.tool_timeout_secs = Some(0);
15168 let cfg = McpConfig {
15169 enabled: true,
15170 servers: vec![server],
15171 ..Default::default()
15172 };
15173 let err = validate_mcp_config(&cfg).expect_err("zero timeout should fail");
15174 assert!(err.to_string().contains("greater than 0"), "got: {err}");
15175 }
15176
15177 #[test]
15178 async fn validate_mcp_config_rejects_timeout_exceeding_max() {
15179 let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
15180 server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS + 1);
15181 let cfg = McpConfig {
15182 enabled: true,
15183 servers: vec![server],
15184 ..Default::default()
15185 };
15186 let err = validate_mcp_config(&cfg).expect_err("oversized timeout should fail");
15187 assert!(err.to_string().contains("exceeds max"), "got: {err}");
15188 }
15189
15190 #[test]
15191 async fn validate_mcp_config_allows_max_timeout_exactly() {
15192 let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
15193 server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS);
15194 let cfg = McpConfig {
15195 enabled: true,
15196 servers: vec![server],
15197 ..Default::default()
15198 };
15199 assert!(validate_mcp_config(&cfg).is_ok());
15200 }
15201
15202 #[test]
15203 async fn validate_mcp_config_rejects_stdio_with_empty_command() {
15204 let cfg = McpConfig {
15205 enabled: true,
15206 servers: vec![stdio_server("fs", "")],
15207 ..Default::default()
15208 };
15209 let err = validate_mcp_config(&cfg).expect_err("empty command should fail");
15210 assert!(
15211 err.to_string().contains("requires non-empty command"),
15212 "got: {err}"
15213 );
15214 }
15215
15216 #[test]
15217 async fn validate_mcp_config_rejects_http_without_url() {
15218 let cfg = McpConfig {
15219 enabled: true,
15220 servers: vec![McpServerConfig {
15221 name: "svc".to_string(),
15222 transport: McpTransport::Http,
15223 url: None,
15224 ..Default::default()
15225 }],
15226 ..Default::default()
15227 };
15228 let err = validate_mcp_config(&cfg).expect_err("http without url should fail");
15229 assert!(err.to_string().contains("requires url"), "got: {err}");
15230 }
15231
15232 #[test]
15233 async fn validate_mcp_config_rejects_sse_without_url() {
15234 let cfg = McpConfig {
15235 enabled: true,
15236 servers: vec![McpServerConfig {
15237 name: "svc".to_string(),
15238 transport: McpTransport::Sse,
15239 url: None,
15240 ..Default::default()
15241 }],
15242 ..Default::default()
15243 };
15244 let err = validate_mcp_config(&cfg).expect_err("sse without url should fail");
15245 assert!(err.to_string().contains("requires url"), "got: {err}");
15246 }
15247
15248 #[test]
15249 async fn validate_mcp_config_rejects_non_http_scheme() {
15250 let cfg = McpConfig {
15251 enabled: true,
15252 servers: vec![http_server("svc", "ftp://example.com/mcp")],
15253 ..Default::default()
15254 };
15255 let err = validate_mcp_config(&cfg).expect_err("non-http scheme should fail");
15256 assert!(err.to_string().contains("http/https"), "got: {err}");
15257 }
15258
15259 #[test]
15260 async fn validate_mcp_config_rejects_invalid_url() {
15261 let cfg = McpConfig {
15262 enabled: true,
15263 servers: vec![http_server("svc", "not a url at all !!!")],
15264 ..Default::default()
15265 };
15266 let err = validate_mcp_config(&cfg).expect_err("invalid url should fail");
15267 assert!(err.to_string().contains("valid URL"), "got: {err}");
15268 }
15269
15270 #[test]
15271 async fn mcp_config_default_disabled_with_empty_servers() {
15272 let cfg = McpConfig::default();
15273 assert!(!cfg.enabled);
15274 assert!(cfg.servers.is_empty());
15275 }
15276
15277 #[test]
15278 async fn mcp_transport_serde_roundtrip_lowercase() {
15279 let cases = [
15280 (McpTransport::Stdio, "\"stdio\""),
15281 (McpTransport::Http, "\"http\""),
15282 (McpTransport::Sse, "\"sse\""),
15283 ];
15284 for (variant, expected_json) in &cases {
15285 let serialized = serde_json::to_string(variant).expect("serialize");
15286 assert_eq!(&serialized, expected_json, "variant: {variant:?}");
15287 let deserialized: McpTransport =
15288 serde_json::from_str(expected_json).expect("deserialize");
15289 assert_eq!(&deserialized, variant);
15290 }
15291 }
15292
15293 #[test]
15294 async fn swarm_strategy_roundtrip() {
15295 let cases = vec![
15296 (SwarmStrategy::Sequential, "\"sequential\""),
15297 (SwarmStrategy::Parallel, "\"parallel\""),
15298 (SwarmStrategy::Router, "\"router\""),
15299 ];
15300 for (variant, expected_json) in &cases {
15301 let serialized = serde_json::to_string(variant).expect("serialize");
15302 assert_eq!(&serialized, expected_json, "variant: {variant:?}");
15303 let deserialized: SwarmStrategy =
15304 serde_json::from_str(expected_json).expect("deserialize");
15305 assert_eq!(&deserialized, variant);
15306 }
15307 }
15308
15309 #[test]
15310 async fn swarm_config_deserializes_with_defaults() {
15311 let toml_str = r#"
15312 agents = ["researcher", "writer"]
15313 strategy = "sequential"
15314 "#;
15315 let config: SwarmConfig = toml::from_str(toml_str).expect("deserialize");
15316 assert_eq!(config.agents, vec!["researcher", "writer"]);
15317 assert_eq!(config.strategy, SwarmStrategy::Sequential);
15318 assert!(config.router_prompt.is_none());
15319 assert!(config.description.is_none());
15320 assert_eq!(config.timeout_secs, 300);
15321 }
15322
15323 #[test]
15324 async fn swarm_config_deserializes_full() {
15325 let toml_str = r#"
15326 agents = ["a", "b", "c"]
15327 strategy = "router"
15328 router_prompt = "Pick the best."
15329 description = "Multi-agent router"
15330 timeout_secs = 120
15331 "#;
15332 let config: SwarmConfig = toml::from_str(toml_str).expect("deserialize");
15333 assert_eq!(config.agents.len(), 3);
15334 assert_eq!(config.strategy, SwarmStrategy::Router);
15335 assert_eq!(config.router_prompt.as_deref(), Some("Pick the best."));
15336 assert_eq!(config.description.as_deref(), Some("Multi-agent router"));
15337 assert_eq!(config.timeout_secs, 120);
15338 }
15339
15340 #[test]
15341 async fn config_with_swarms_section_deserializes() {
15342 let toml_str = r#"
15343 [agents.researcher]
15344 provider = "ollama"
15345 model = "llama3"
15346
15347 [agents.writer]
15348 provider = "openrouter"
15349 model = "claude-sonnet"
15350
15351 [swarms.pipeline]
15352 agents = ["researcher", "writer"]
15353 strategy = "sequential"
15354 "#;
15355 let config = parse_test_config(toml_str);
15356 assert_eq!(config.agents.len(), 2);
15357 assert_eq!(config.swarms.len(), 1);
15358 assert!(config.swarms.contains_key("pipeline"));
15359 }
15360
15361 #[tokio::test]
15362 async fn nevis_client_secret_encrypt_decrypt_roundtrip() {
15363 let dir = std::env::temp_dir().join(format!(
15364 "construct_test_nevis_secret_{}",
15365 uuid::Uuid::new_v4()
15366 ));
15367 fs::create_dir_all(&dir).await.unwrap();
15368
15369 let plaintext_secret = "nevis-test-client-secret-value";
15370
15371 let mut config = Config::default();
15372 config.workspace_dir = dir.join("workspace");
15373 config.config_path = dir.join("config.toml");
15374 config.security.nevis.client_secret = Some(plaintext_secret.into());
15375
15376 config.save().await.unwrap();
15378
15379 let raw_toml = tokio::fs::read_to_string(&config.config_path)
15381 .await
15382 .unwrap();
15383 assert!(
15384 !raw_toml.contains(plaintext_secret),
15385 "Saved TOML must not contain the plaintext client_secret"
15386 );
15387
15388 let stored: Config = toml::from_str(&raw_toml).unwrap();
15390 let stored_secret = stored.security.nevis.client_secret.as_ref().unwrap();
15391 assert!(
15392 crate::security::SecretStore::is_encrypted(stored_secret),
15393 "Stored client_secret must be marked as encrypted"
15394 );
15395
15396 let store = crate::security::SecretStore::new(&dir, true);
15398 assert_eq!(store.decrypt(stored_secret).unwrap(), plaintext_secret);
15399
15400 let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
15402 loaded.config_path = dir.join("config.toml");
15403 let load_store = crate::security::SecretStore::new(&dir, loaded.secrets.encrypt);
15404 decrypt_optional_secret(
15405 &load_store,
15406 &mut loaded.security.nevis.client_secret,
15407 "config.security.nevis.client_secret",
15408 )
15409 .unwrap();
15410 assert_eq!(
15411 loaded.security.nevis.client_secret.as_deref().unwrap(),
15412 plaintext_secret,
15413 "Loaded client_secret must match the original plaintext after decryption"
15414 );
15415
15416 let _ = fs::remove_dir_all(&dir).await;
15417 }
15418
15419 #[test]
15424 async fn nevis_config_validate_disabled_accepts_empty_fields() {
15425 let cfg = NevisConfig::default();
15426 assert!(!cfg.enabled);
15427 assert!(cfg.validate().is_ok());
15428 }
15429
15430 #[test]
15431 async fn nevis_config_validate_rejects_empty_instance_url() {
15432 let cfg = NevisConfig {
15433 enabled: true,
15434 instance_url: String::new(),
15435 client_id: "test-client".into(),
15436 ..NevisConfig::default()
15437 };
15438 let err = cfg.validate().unwrap_err();
15439 assert!(err.contains("instance_url"));
15440 }
15441
15442 #[test]
15443 async fn nevis_config_validate_rejects_empty_client_id() {
15444 let cfg = NevisConfig {
15445 enabled: true,
15446 instance_url: "https://nevis.example.com".into(),
15447 client_id: String::new(),
15448 ..NevisConfig::default()
15449 };
15450 let err = cfg.validate().unwrap_err();
15451 assert!(err.contains("client_id"));
15452 }
15453
15454 #[test]
15455 async fn nevis_config_validate_rejects_empty_realm() {
15456 let cfg = NevisConfig {
15457 enabled: true,
15458 instance_url: "https://nevis.example.com".into(),
15459 client_id: "test-client".into(),
15460 realm: String::new(),
15461 ..NevisConfig::default()
15462 };
15463 let err = cfg.validate().unwrap_err();
15464 assert!(err.contains("realm"));
15465 }
15466
15467 #[test]
15468 async fn nevis_config_validate_rejects_local_without_jwks() {
15469 let cfg = NevisConfig {
15470 enabled: true,
15471 instance_url: "https://nevis.example.com".into(),
15472 client_id: "test-client".into(),
15473 token_validation: "local".into(),
15474 jwks_url: None,
15475 ..NevisConfig::default()
15476 };
15477 let err = cfg.validate().unwrap_err();
15478 assert!(err.contains("jwks_url"));
15479 }
15480
15481 #[test]
15482 async fn nevis_config_validate_rejects_zero_session_timeout() {
15483 let cfg = NevisConfig {
15484 enabled: true,
15485 instance_url: "https://nevis.example.com".into(),
15486 client_id: "test-client".into(),
15487 token_validation: "remote".into(),
15488 session_timeout_secs: 0,
15489 ..NevisConfig::default()
15490 };
15491 let err = cfg.validate().unwrap_err();
15492 assert!(err.contains("session_timeout_secs"));
15493 }
15494
15495 #[test]
15496 async fn nevis_config_validate_accepts_valid_enabled_config() {
15497 let cfg = NevisConfig {
15498 enabled: true,
15499 instance_url: "https://nevis.example.com".into(),
15500 realm: "master".into(),
15501 client_id: "test-client".into(),
15502 token_validation: "remote".into(),
15503 session_timeout_secs: 3600,
15504 ..NevisConfig::default()
15505 };
15506 assert!(cfg.validate().is_ok());
15507 }
15508
15509 #[test]
15510 async fn nevis_config_validate_rejects_invalid_token_validation() {
15511 let cfg = NevisConfig {
15512 enabled: true,
15513 instance_url: "https://nevis.example.com".into(),
15514 realm: "master".into(),
15515 client_id: "test-client".into(),
15516 token_validation: "invalid_mode".into(),
15517 session_timeout_secs: 3600,
15518 ..NevisConfig::default()
15519 };
15520 let err = cfg.validate().unwrap_err();
15521 assert!(
15522 err.contains("invalid value 'invalid_mode'"),
15523 "Expected invalid token_validation error, got: {err}"
15524 );
15525 }
15526
15527 #[test]
15528 async fn nevis_config_debug_redacts_client_secret() {
15529 let cfg = NevisConfig {
15530 client_secret: Some("super-secret".into()),
15531 ..NevisConfig::default()
15532 };
15533 let debug_output = format!("{:?}", cfg);
15534 assert!(
15535 !debug_output.contains("super-secret"),
15536 "Debug output must not contain the raw client_secret"
15537 );
15538 assert!(
15539 debug_output.contains("[REDACTED]"),
15540 "Debug output must show [REDACTED] for client_secret"
15541 );
15542 }
15543
15544 #[test]
15545 async fn telegram_config_ack_reactions_false_deserializes() {
15546 let toml_str = r#"
15547 bot_token = "123:ABC"
15548 allowed_users = ["alice"]
15549 ack_reactions = false
15550 "#;
15551 let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
15552 assert_eq!(cfg.ack_reactions, Some(false));
15553 }
15554
15555 #[test]
15556 async fn telegram_config_ack_reactions_true_deserializes() {
15557 let toml_str = r#"
15558 bot_token = "123:ABC"
15559 allowed_users = ["alice"]
15560 ack_reactions = true
15561 "#;
15562 let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
15563 assert_eq!(cfg.ack_reactions, Some(true));
15564 }
15565
15566 #[test]
15567 async fn telegram_config_ack_reactions_missing_defaults_to_none() {
15568 let toml_str = r#"
15569 bot_token = "123:ABC"
15570 allowed_users = ["alice"]
15571 "#;
15572 let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
15573 assert_eq!(cfg.ack_reactions, None);
15574 }
15575
15576 #[test]
15577 async fn telegram_config_ack_reactions_channel_overrides_top_level() {
15578 let tg_toml = r#"
15579 bot_token = "123:ABC"
15580 allowed_users = ["alice"]
15581 ack_reactions = false
15582 "#;
15583 let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
15584 let top_level_ack = true;
15585 let effective = tg.ack_reactions.unwrap_or(top_level_ack);
15586 assert!(
15587 !effective,
15588 "channel-level false must override top-level true"
15589 );
15590 }
15591
15592 #[test]
15593 async fn telegram_config_ack_reactions_falls_back_to_top_level() {
15594 let tg_toml = r#"
15595 bot_token = "123:ABC"
15596 allowed_users = ["alice"]
15597 "#;
15598 let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
15599 let top_level_ack = false;
15600 let effective = tg.ack_reactions.unwrap_or(top_level_ack);
15601 assert!(
15602 !effective,
15603 "must fall back to top-level false when channel omits field"
15604 );
15605 }
15606
15607 #[test]
15608 async fn google_workspace_allowed_operations_deserialize_from_toml() {
15609 let toml_str = r#"
15610 enabled = true
15611
15612 [[allowed_operations]]
15613 service = "gmail"
15614 resource = "users"
15615 sub_resource = "drafts"
15616 methods = ["create", "update"]
15617 "#;
15618
15619 let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
15620 assert_eq!(cfg.allowed_operations.len(), 1);
15621 assert_eq!(cfg.allowed_operations[0].service, "gmail");
15622 assert_eq!(cfg.allowed_operations[0].resource, "users");
15623 assert_eq!(
15624 cfg.allowed_operations[0].sub_resource.as_deref(),
15625 Some("drafts")
15626 );
15627 assert_eq!(
15628 cfg.allowed_operations[0].methods,
15629 vec!["create".to_string(), "update".to_string()]
15630 );
15631 }
15632
15633 #[test]
15634 async fn google_workspace_allowed_operations_deserialize_without_sub_resource() {
15635 let toml_str = r#"
15636 enabled = true
15637
15638 [[allowed_operations]]
15639 service = "drive"
15640 resource = "files"
15641 methods = ["list", "get"]
15642 "#;
15643
15644 let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
15645 assert_eq!(cfg.allowed_operations[0].sub_resource, None);
15646 }
15647
15648 #[test]
15649 async fn config_validate_accepts_google_workspace_allowed_operations() {
15650 let mut cfg = Config::default();
15651 cfg.google_workspace.enabled = true;
15652 cfg.google_workspace.allowed_services = vec!["gmail".into()];
15653 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15654 service: "gmail".into(),
15655 resource: "users".into(),
15656 sub_resource: Some("drafts".into()),
15657 methods: vec!["create".into(), "update".into()],
15658 }];
15659
15660 cfg.validate().unwrap();
15661 }
15662
15663 #[test]
15664 async fn config_validate_rejects_duplicate_google_workspace_allowed_operations() {
15665 let mut cfg = Config::default();
15666 cfg.google_workspace.enabled = true;
15667 cfg.google_workspace.allowed_services = vec!["gmail".into()];
15668 cfg.google_workspace.allowed_operations = vec![
15669 GoogleWorkspaceAllowedOperation {
15670 service: "gmail".into(),
15671 resource: "users".into(),
15672 sub_resource: Some("drafts".into()),
15673 methods: vec!["create".into()],
15674 },
15675 GoogleWorkspaceAllowedOperation {
15676 service: "gmail".into(),
15677 resource: "users".into(),
15678 sub_resource: Some("drafts".into()),
15679 methods: vec!["update".into()],
15680 },
15681 ];
15682
15683 let err = cfg.validate().unwrap_err().to_string();
15684 assert!(err.contains("duplicate service/resource/sub_resource entry"));
15685 }
15686
15687 #[test]
15688 async fn config_validate_rejects_operation_service_not_in_allowed_services() {
15689 let mut cfg = Config::default();
15690 cfg.google_workspace.enabled = true;
15691 cfg.google_workspace.allowed_services = vec!["gmail".into()];
15692 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15693 service: "drive".into(), resource: "files".into(),
15695 sub_resource: None,
15696 methods: vec!["list".into()],
15697 }];
15698
15699 let err = cfg.validate().unwrap_err().to_string();
15700 assert!(
15701 err.contains("not in the effective allowed_services"),
15702 "expected not-in-allowed_services error, got: {err}"
15703 );
15704 }
15705
15706 #[test]
15707 async fn config_validate_accepts_default_service_when_allowed_services_empty() {
15708 let mut cfg = Config::default();
15711 cfg.google_workspace.enabled = true;
15712 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15714 service: "drive".into(),
15715 resource: "files".into(),
15716 sub_resource: None,
15717 methods: vec!["list".into()],
15718 }];
15719
15720 assert!(cfg.validate().is_ok());
15721 }
15722
15723 #[test]
15724 async fn config_validate_rejects_unknown_service_when_allowed_services_empty() {
15725 let mut cfg = Config::default();
15729 cfg.google_workspace.enabled = true;
15730 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15732 service: "not_a_real_service".into(),
15733 resource: "files".into(),
15734 sub_resource: None,
15735 methods: vec!["list".into()],
15736 }];
15737
15738 let err = cfg.validate().unwrap_err().to_string();
15739 assert!(
15740 err.contains("not in the effective allowed_services"),
15741 "expected effective-allowed_services error, got: {err}"
15742 );
15743 }
15744
15745 #[tokio::test]
15748 async fn ensure_bootstrap_files_creates_missing_files() {
15749 let tmp = tempfile::TempDir::new().unwrap();
15750 let ws = tmp.path().join("workspace");
15751 let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
15752
15753 ensure_bootstrap_files(&ws).await.unwrap();
15754
15755 let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
15756 let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
15757 .await
15758 .unwrap();
15759 assert!(soul.contains("SOUL.md"));
15760 assert!(identity.contains("IDENTITY.md"));
15761 }
15762
15763 #[tokio::test]
15764 async fn ensure_bootstrap_files_does_not_overwrite_existing() {
15765 let tmp = tempfile::TempDir::new().unwrap();
15766 let ws = tmp.path().join("workspace");
15767 let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
15768
15769 let custom = "# My custom SOUL";
15770 let _: () = tokio::fs::write(ws.join("SOUL.md"), custom).await.unwrap();
15771
15772 ensure_bootstrap_files(&ws).await.unwrap();
15773
15774 let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
15775 assert_eq!(
15776 soul, custom,
15777 "ensure_bootstrap_files must not overwrite existing files"
15778 );
15779
15780 let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
15782 .await
15783 .unwrap();
15784 assert!(identity.contains("IDENTITY.md"));
15785 }
15786
15787 #[test]
15790 async fn pacing_config_serde_defaults_match_manual_default() {
15791 let from_toml: PacingConfig = toml::from_str("").unwrap();
15794 let manual = PacingConfig::default();
15795
15796 assert_eq!(
15797 from_toml.loop_detection_enabled,
15798 manual.loop_detection_enabled
15799 );
15800 assert_eq!(
15801 from_toml.loop_detection_window_size,
15802 manual.loop_detection_window_size
15803 );
15804 assert_eq!(
15805 from_toml.loop_detection_max_repeats,
15806 manual.loop_detection_max_repeats
15807 );
15808
15809 assert!(from_toml.loop_detection_enabled, "default should be true");
15811 assert_eq!(from_toml.loop_detection_window_size, 20);
15812 assert_eq!(from_toml.loop_detection_max_repeats, 3);
15813 }
15814
15815 const DOCKER_CONFIG_TEMPLATE: &str = r#"
15820workspace_dir = "/construct-data/workspace"
15821config_path = "/construct-data/.construct/config.toml"
15822api_key = ""
15823default_provider = "openrouter"
15824default_model = "anthropic/claude-sonnet-4-20250514"
15825default_temperature = 0.7
15826
15827[gateway]
15828port = 42617
15829host = "[::]"
15830allow_public_bind = true
15831
15832[autonomy]
15833level = "supervised"
15834auto_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"]
15835"#;
15836
15837 #[test]
15838 async fn docker_config_template_is_parseable() {
15839 let cfg: Config = toml::from_str(DOCKER_CONFIG_TEMPLATE)
15840 .expect("Docker baked config.toml must be valid TOML that deserialises into Config");
15841
15842 let auto = &cfg.autonomy.auto_approve;
15844 for tool in &[
15845 "file_read",
15846 "file_write",
15847 "file_edit",
15848 "memory_recall",
15849 "memory_store",
15850 "web_search_tool",
15851 "web_fetch",
15852 "calculator",
15853 "glob_search",
15854 "content_search",
15855 "image_info",
15856 "weather",
15857 "git_operations",
15858 ] {
15859 assert!(
15860 auto.iter().any(|t| t == tool),
15861 "Docker config auto_approve missing expected tool: {tool}"
15862 );
15863 }
15864 }
15865
15866 #[test]
15867 async fn cost_enforcement_config_defaults() {
15868 let config = CostEnforcementConfig::default();
15869 assert_eq!(config.mode, "warn");
15870 assert_eq!(config.route_down_model, None);
15871 assert_eq!(config.reserve_percent, 10);
15872 }
15873
15874 #[test]
15875 async fn cost_config_includes_enforcement() {
15876 let config = CostConfig::default();
15877 assert_eq!(config.enforcement.mode, "warn");
15878 assert_eq!(config.enforcement.reserve_percent, 10);
15879 }
15880}