1pub mod v1;
5pub mod v2;
6
7use crate::autonomy::AutonomyLevel;
8use crate::domain_matcher::DomainMatcher;
9use crate::traits::{ChannelConfig, HasPropKind, PropKind};
10use crate::validation_bail;
11use anyhow::{Context, Result};
12use directories::UserDirs;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::{OnceLock, RwLock};
17#[cfg(unix)]
18use tokio::fs::File;
19use tokio::fs::{self, OpenOptions};
20use tokio::io::AsyncWriteExt;
21use zeroclaw_macros::Configurable;
22
23const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
24 "model_provider.anthropic",
25 "model_provider.compatible",
26 "model_provider.copilot",
27 "model_provider.gemini",
28 "model_provider.glm",
29 "model_provider.ollama",
30 "model_provider.openai",
31 "model_provider.openrouter",
32 "channel.dingtalk",
33 "channel.discord",
34 "channel.lark",
35 "channel.matrix",
36 "channel.mattermost",
37 "channel.nextcloud_talk",
38 "channel.qq",
39 "channel.signal",
40 "channel.slack",
41 "channel.telegram",
42 "channel.wati",
43 "channel.wechat",
44 "channel.whatsapp",
45 "tool.browser",
46 "tool.composio",
47 "tool.http_request",
48 "tool.pushover",
49 "tool.web_search",
50 "memory.embeddings",
51 "tunnel.custom",
52 "transcription.groq",
53];
54
55const SUPPORTED_PROXY_SERVICE_SELECTORS: &[&str] = &[
56 "model_provider.*",
57 "channel.*",
58 "tool.*",
59 "memory.*",
60 "tunnel.*",
61 "transcription.*",
62];
63
64static RUNTIME_PROXY_CONFIG: OnceLock<RwLock<ProxyConfig>> = OnceLock::new();
65static RUNTIME_PROXY_CLIENT_CACHE: OnceLock<RwLock<HashMap<String, reqwest::Client>>> =
66 OnceLock::new();
67
68#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
74#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
75pub struct Config {
76 #[serde(skip)]
82 pub data_dir: PathBuf,
83 #[serde(skip)]
85 pub config_path: PathBuf,
86 #[serde(skip)]
92 pub env_overridden_paths: std::collections::HashSet<String>,
93 #[serde(skip)]
99 pub pre_override_snapshots: std::collections::HashMap<String, String>,
100 #[serde(skip)]
103 pub dirty_paths: std::collections::HashSet<String>,
104 #[serde(default = "default_schema_version")]
106 pub schema_version: u32,
107
108 #[serde(default)]
115 #[nested]
116 pub providers: crate::providers::Providers,
117
118 #[serde(default, skip_serializing_if = "Vec::is_empty")]
121 pub model_routes: Vec<ModelRouteConfig>,
122
123 #[serde(default, skip_serializing_if = "Vec::is_empty")]
126 pub embedding_routes: Vec<EmbeddingRouteConfig>,
127
128 #[serde(default)]
130 #[nested]
131 pub observability: ObservabilityConfig,
132
133 #[serde(default)]
135 #[nested]
136 pub trust: crate::scattered_types::TrustConfig,
137
138 #[serde(default)]
140 #[nested]
141 pub security: SecurityConfig,
142
143 #[serde(default)]
145 #[nested]
146 pub backup: BackupConfig,
147
148 #[serde(default)]
150 #[nested]
151 pub data_retention: DataRetentionConfig,
152
153 #[serde(default)]
155 #[nested]
156 pub cloud_ops: CloudOpsConfig,
157
158 #[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")]
165 #[nested]
166 pub conversational_ai: ConversationalAiConfig,
167
168 #[serde(default)]
170 #[nested]
171 pub security_ops: SecurityOpsConfig,
172
173 #[serde(default)]
175 #[nested]
176 pub runtime: RuntimeConfig,
177
178 #[serde(default)]
180 #[nested]
181 pub reliability: ReliabilityConfig,
182
183 #[serde(default)]
185 #[nested]
186 pub scheduler: SchedulerConfig,
187
188 #[serde(default)]
190 #[nested]
191 pub pacing: PacingConfig,
192
193 #[serde(default)]
195 #[nested]
196 pub skills: SkillsConfig,
197
198 #[serde(default)]
200 #[nested]
201 pub pipeline: PipelineConfig,
202
203 #[serde(default)]
205 #[nested]
206 pub query_classification: QueryClassificationConfig,
207
208 #[serde(default)]
210 #[nested]
211 pub heartbeat: HeartbeatConfig,
212
213 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
219 #[nested]
220 pub cron: HashMap<String, CronJobDecl>,
221
222 #[serde(default)]
224 #[nested]
225 pub acp: AcpConfig,
226
227 #[serde(default, alias = "channels_config")]
229 #[nested]
230 pub channels: ChannelsConfig,
231
232 #[serde(default)]
234 #[nested]
235 pub memory: MemoryConfig,
236
237 #[serde(default)]
239 #[nested]
240 pub storage: StorageConfig,
241
242 #[serde(default)]
244 #[nested]
245 pub tunnel: TunnelConfig,
246
247 #[serde(default)]
249 #[nested]
250 pub gateway: GatewayConfig,
251
252 #[serde(default)]
254 #[nested]
255 pub composio: ComposioConfig,
256
257 #[serde(default)]
259 #[nested]
260 pub microsoft365: Microsoft365Config,
261
262 #[serde(default)]
264 #[nested]
265 pub secrets: SecretsConfig,
266
267 #[serde(default)]
269 #[nested]
270 pub browser: BrowserConfig,
271
272 #[serde(default)]
294 #[nested]
295 pub browser_delegate: crate::scattered_types::BrowserDelegateConfig,
296
297 #[serde(default)]
299 #[nested]
300 pub http_request: HttpRequestConfig,
301
302 #[serde(default)]
304 #[nested]
305 pub multimodal: MultimodalConfig,
306
307 #[serde(default)]
309 #[nested]
310 pub media_pipeline: MediaPipelineConfig,
311
312 #[serde(default)]
314 #[nested]
315 pub web_fetch: WebFetchConfig,
316
317 #[serde(default)]
319 #[nested]
320 pub link_enricher: LinkEnricherConfig,
321
322 #[serde(default)]
324 #[nested]
325 pub text_browser: TextBrowserConfig,
326
327 #[serde(default)]
329 #[nested]
330 pub web_search: WebSearchConfig,
331
332 #[serde(default)]
334 #[nested]
335 pub project_intel: ProjectIntelConfig,
336
337 #[serde(default)]
339 #[nested]
340 pub google_workspace: GoogleWorkspaceConfig,
341
342 #[serde(default)]
344 #[nested]
345 pub proxy: ProxyConfig,
346
347 #[serde(default)]
351 #[nested]
352 pub cost: CostConfig,
353
354 #[serde(default)]
356 #[nested]
357 pub peripherals: PeripheralsConfig,
358
359 #[serde(default)]
361 #[nested]
362 pub delegate: DelegateToolConfig,
363
364 #[serde(default)]
370 #[nested]
371 pub agents: HashMap<String, AliasedAgentConfig>,
372
373 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
375 #[nested]
376 pub risk_profiles: HashMap<String, RiskProfileConfig>,
377
378 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
380 #[nested]
381 pub runtime_profiles: HashMap<String, RuntimeProfileConfig>,
382
383 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
385 #[nested]
386 pub skill_bundles: HashMap<String, SkillBundleConfig>,
387
388 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
390 #[nested]
391 pub knowledge_bundles: HashMap<String, KnowledgeBundleConfig>,
392
393 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
395 #[nested]
396 pub mcp_bundles: HashMap<String, McpBundleConfig>,
397
398 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
405 #[nested]
406 pub peer_groups: HashMap<String, crate::multi_agent::PeerGroupConfig>,
407
408 #[serde(default)]
410 #[nested]
411 pub hooks: HooksConfig,
412
413 #[serde(default)]
415 #[nested]
416 pub hardware: HardwareConfig,
417
418 #[serde(default)]
420 #[nested]
421 pub transcription: TranscriptionConfig,
422
423 #[serde(default)]
425 #[nested]
426 pub tts: TtsConfig,
427
428 #[serde(default, alias = "mcpServers")]
430 #[nested]
431 pub mcp: McpConfig,
432
433 #[serde(default)]
435 #[nested]
436 pub nodes: NodesConfig,
437
438 #[serde(default)]
441 #[nested]
442 pub onboard_state: OnboardStateConfig,
443
444 #[serde(default)]
446 #[nested]
447 pub notion: NotionConfig,
448
449 #[serde(default)]
451 #[nested]
452 pub jira: JiraConfig,
453
454 #[serde(default)]
456 #[nested]
457 pub node_transport: NodeTransportConfig,
458
459 #[serde(default)]
461 #[nested]
462 pub knowledge: KnowledgeConfig,
463
464 #[serde(default)]
466 #[nested]
467 pub linkedin: LinkedInConfig,
468
469 #[serde(default)]
471 #[nested]
472 pub image_gen: ImageGenConfig,
473
474 #[serde(default)]
476 #[nested]
477 pub file_upload: FileUploadConfig,
478
479 #[serde(default)]
482 #[nested]
483 pub file_upload_bundle: FileUploadBundleConfig,
484
485 #[serde(default)]
487 #[nested]
488 pub file_download: FileDownloadConfig,
489
490 #[serde(default)]
492 #[nested]
493 pub plugins: PluginsConfig,
494
495 #[serde(default)]
504 pub locale: Option<String>,
505
506 #[serde(default)]
508 #[nested]
509 pub verifiable_intent: VerifiableIntentConfig,
510
511 #[serde(default)]
513 #[nested]
514 pub claude_code: ClaudeCodeConfig,
515
516 #[serde(default)]
518 #[nested]
519 pub claude_code_runner: ClaudeCodeRunnerConfig,
520
521 #[serde(default)]
523 #[nested]
524 pub codex_cli: CodexCliConfig,
525
526 #[serde(default)]
528 #[nested]
529 pub gemini_cli: GeminiCliConfig,
530
531 #[serde(default)]
533 #[nested]
534 pub opencode_cli: OpenCodeCliConfig,
535
536 #[serde(default)]
538 #[nested]
539 pub sop: SopConfig,
540
541 #[serde(default)]
543 #[nested]
544 pub shell_tool: ShellToolConfig,
545
546 #[serde(default)]
548 #[nested]
549 pub escalation: EscalationConfig,
550}
551
552#[allow(clippy::struct_excessive_bools)]
557#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
564#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
565#[prefix = "onboard_state"]
566pub struct OnboardStateConfig {
567 #[serde(default)]
571 pub completed_sections: Vec<String>,
572}
573
574fn is_false(value: &bool) -> bool {
580 !*value
581}
582
583pub trait ModelEndpoint {
594 fn uri(&self) -> &'static str;
595}
596
597pub trait FamilyEndpoint {
603 fn endpoint_uri(&self) -> Option<&'static str> {
604 None
605 }
606}
607
608#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
614#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
615#[serde(rename_all = "snake_case")]
616pub enum WireApi {
617 Responses,
618 ChatCompletions,
619}
620
621impl WireApi {
622 #[must_use]
623 pub fn as_str(self) -> &'static str {
624 match self {
625 Self::Responses => "responses",
626 Self::ChatCompletions => "chat_completions",
627 }
628 }
629}
630
631#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
635#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
636#[serde(rename_all = "snake_case")]
637pub enum AuthMode {
638 #[default]
640 ApiKey,
641 OAuth,
644}
645
646#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
648#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
649#[prefix = "providers.models"]
650pub struct ModelProviderConfig {
651 #[secret]
653 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
654 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub api_key: Option<String>,
656 #[serde(default, skip_serializing_if = "Option::is_none")]
661 pub kind: Option<String>,
662 #[serde(default, skip_serializing_if = "Option::is_none")]
664 pub uri: Option<String>,
665 #[serde(default, skip_serializing_if = "Option::is_none")]
667 pub model: Option<String>,
668 #[serde(default, skip_serializing_if = "Option::is_none")]
672 pub temperature: Option<f64>,
673 #[serde(default, skip_serializing_if = "Option::is_none")]
675 pub timeout_secs: Option<u64>,
676 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
678 pub extra_headers: HashMap<String, String>,
679 #[serde(default, skip_serializing_if = "Option::is_none")]
681 pub wire_api: Option<WireApi>,
682 #[serde(default, skip_serializing_if = "is_false")]
684 pub requires_openai_auth: bool,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub max_tokens: Option<u32>,
688 #[serde(default, skip_serializing_if = "is_false")]
690 pub merge_system_into_user: bool,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
696 pub provider_extra: Option<serde_json::Value>,
697 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
709 pub pricing: HashMap<String, f64>,
710 #[serde(default, skip_serializing_if = "Option::is_none")]
718 pub native_tools: Option<bool>,
719 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub think: Option<bool>,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
731 pub chat_template_kwargs: Option<serde_json::Value>,
732}
733
734#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
755#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
756#[serde(rename_all = "snake_case")]
757pub enum OpenAIEndpoint {
758 #[default]
759 Default,
760}
761
762impl ModelEndpoint for OpenAIEndpoint {
763 fn uri(&self) -> &'static str {
764 match self {
765 Self::Default => "https://api.openai.com/v1",
766 }
767 }
768}
769
770#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
776#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
777#[prefix = "providers.models.openai"]
778pub struct OpenAIModelProviderConfig {
779 #[nested]
780 #[serde(flatten)]
781 pub base: ModelProviderConfig,
782}
783
784#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
790#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
791#[serde(rename_all = "snake_case")]
792pub enum AzureEndpoint {
793 #[default]
794 Default,
795}
796
797impl ModelEndpoint for AzureEndpoint {
798 fn uri(&self) -> &'static str {
799 match self {
800 Self::Default => "https://{resource}.openai.azure.com/openai/deployments/{deployment}",
804 }
805 }
806}
807
808#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
813#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
814#[prefix = "providers.models.azure"]
815pub struct AzureModelProviderConfig {
816 #[nested]
817 #[serde(flatten)]
818 pub base: ModelProviderConfig,
819 #[serde(
821 default,
822 skip_serializing_if = "Option::is_none",
823 alias = "azure_openai_resource"
824 )]
825 pub resource: Option<String>,
826 #[serde(
828 default,
829 skip_serializing_if = "Option::is_none",
830 alias = "azure_openai_deployment"
831 )]
832 pub deployment: Option<String>,
833 #[serde(
835 default,
836 skip_serializing_if = "Option::is_none",
837 alias = "azure_openai_api_version"
838 )]
839 pub api_version: Option<String>,
840}
841
842#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
846#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
847#[serde(rename_all = "snake_case")]
848pub enum AnthropicEndpoint {
849 #[default]
850 Default,
851}
852
853impl ModelEndpoint for AnthropicEndpoint {
854 fn uri(&self) -> &'static str {
855 match self {
856 Self::Default => "https://api.anthropic.com",
857 }
858 }
859}
860
861#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
865#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
866#[prefix = "providers.models.anthropic"]
867pub struct AnthropicModelProviderConfig {
868 #[nested]
869 #[serde(flatten)]
870 pub base: ModelProviderConfig,
871}
872
873#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
879#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
880#[serde(rename_all = "snake_case")]
881pub enum MoonshotEndpoint {
882 Cn,
884 #[default]
886 Intl,
887 Code,
889}
890
891impl ModelEndpoint for MoonshotEndpoint {
892 fn uri(&self) -> &'static str {
893 match self {
894 Self::Cn => "https://api.moonshot.cn/v1",
895 Self::Intl => "https://api.moonshot.ai/v1",
896 Self::Code => "https://api.moonshot.cn/coder/v1",
897 }
898 }
899}
900
901#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
905#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
906#[prefix = "providers.models.moonshot"]
907pub struct MoonshotModelProviderConfig {
908 #[nested]
909 #[serde(flatten)]
910 pub base: ModelProviderConfig,
911 #[serde(default)]
915 pub endpoint: MoonshotEndpoint,
916}
917
918impl FamilyEndpoint for MoonshotModelProviderConfig {
919 fn endpoint_uri(&self) -> Option<&'static str> {
920 Some(self.endpoint.uri())
921 }
922}
923
924#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
928#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
929#[serde(rename_all = "snake_case")]
930pub enum QwenEndpoint {
931 Cn,
933 #[default]
935 Intl,
936 Us,
938 Code,
940}
941
942impl ModelEndpoint for QwenEndpoint {
943 fn uri(&self) -> &'static str {
944 match self {
945 Self::Cn => "https://dashscope.aliyuncs.com/compatible-mode/v1",
946 Self::Intl => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
947 Self::Us => "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
948 Self::Code => {
949 "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
950 }
951 }
952 }
953}
954
955#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
958#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
959#[prefix = "providers.models.qwen"]
960pub struct QwenModelProviderConfig {
961 #[nested]
962 #[serde(flatten)]
963 pub base: ModelProviderConfig,
964 #[serde(default)]
965 pub endpoint: QwenEndpoint,
966 #[serde(default, skip_serializing_if = "Option::is_none")]
969 pub auth_mode: Option<AuthMode>,
970 #[serde(default, skip_serializing_if = "Option::is_none")]
976 #[secret(category = "model_provider")]
977 pub oauth_refresh_token: Option<String>,
978 #[serde(default, skip_serializing_if = "Option::is_none")]
981 pub oauth_client_id: Option<String>,
982 #[serde(default, skip_serializing_if = "Option::is_none")]
987 pub oauth_resource_url: Option<String>,
988}
989
990impl FamilyEndpoint for QwenModelProviderConfig {
991 fn endpoint_uri(&self) -> Option<&'static str> {
992 Some(self.endpoint.uri())
993 }
994}
995
996#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
999#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1000#[serde(rename_all = "snake_case")]
1001pub enum OpenRouterEndpoint {
1002 #[default]
1003 Default,
1004}
1005
1006impl ModelEndpoint for OpenRouterEndpoint {
1007 fn uri(&self) -> &'static str {
1008 match self {
1009 Self::Default => "https://openrouter.ai/api/v1",
1010 }
1011 }
1012}
1013
1014#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1015#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1016#[prefix = "providers.models.openrouter"]
1017pub struct OpenRouterModelProviderConfig {
1018 #[nested]
1019 #[serde(flatten)]
1020 pub base: ModelProviderConfig,
1021}
1022
1023#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1026#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1027#[serde(rename_all = "snake_case")]
1028pub enum OllamaEndpoint {
1029 #[default]
1030 LocalDefault,
1031}
1032
1033impl ModelEndpoint for OllamaEndpoint {
1034 fn uri(&self) -> &'static str {
1035 match self {
1036 Self::LocalDefault => "http://localhost:11434",
1037 }
1038 }
1039}
1040
1041#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1042#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1043#[prefix = "providers.models.ollama"]
1044pub struct OllamaModelProviderConfig {
1045 #[nested]
1046 #[serde(flatten)]
1047 pub base: ModelProviderConfig,
1048 #[serde(default, skip_serializing_if = "Option::is_none")]
1052 pub num_ctx: Option<u32>,
1053 #[serde(default, skip_serializing_if = "Option::is_none")]
1057 pub num_predict: Option<i32>,
1058 #[serde(default, skip_serializing_if = "Option::is_none")]
1064 pub temperature_override: Option<f64>,
1065}
1066
1067#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1070#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1071#[serde(rename_all = "snake_case")]
1072pub enum TogetherEndpoint {
1073 #[default]
1074 Default,
1075}
1076
1077impl ModelEndpoint for TogetherEndpoint {
1078 fn uri(&self) -> &'static str {
1079 match self {
1080 Self::Default => "https://api.together.xyz/v1",
1081 }
1082 }
1083}
1084
1085#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1086#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1087#[prefix = "providers.models.together"]
1088pub struct TogetherModelProviderConfig {
1089 #[nested]
1090 #[serde(flatten)]
1091 pub base: ModelProviderConfig,
1092}
1093
1094#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1097#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1098#[serde(rename_all = "snake_case")]
1099pub enum FireworksEndpoint {
1100 #[default]
1101 Default,
1102}
1103
1104impl ModelEndpoint for FireworksEndpoint {
1105 fn uri(&self) -> &'static str {
1106 match self {
1107 Self::Default => "https://api.fireworks.ai/inference/v1",
1108 }
1109 }
1110}
1111
1112#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1113#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1114#[prefix = "providers.models.fireworks"]
1115pub struct FireworksModelProviderConfig {
1116 #[nested]
1117 #[serde(flatten)]
1118 pub base: ModelProviderConfig,
1119}
1120
1121#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1124#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1125#[serde(rename_all = "snake_case")]
1126pub enum GroqEndpoint {
1127 #[default]
1128 Default,
1129}
1130
1131impl ModelEndpoint for GroqEndpoint {
1132 fn uri(&self) -> &'static str {
1133 match self {
1134 Self::Default => "https://api.groq.com/openai/v1",
1135 }
1136 }
1137}
1138
1139#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1140#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1141#[prefix = "providers.models.groq"]
1142pub struct GroqModelProviderConfig {
1143 #[nested]
1144 #[serde(flatten)]
1145 pub base: ModelProviderConfig,
1146}
1147
1148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1151#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1152#[serde(rename_all = "snake_case")]
1153pub enum MistralEndpoint {
1154 #[default]
1155 Default,
1156}
1157
1158impl ModelEndpoint for MistralEndpoint {
1159 fn uri(&self) -> &'static str {
1160 match self {
1161 Self::Default => "https://api.mistral.ai/v1",
1162 }
1163 }
1164}
1165
1166#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1167#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1168#[prefix = "providers.models.mistral"]
1169pub struct MistralModelProviderConfig {
1170 #[nested]
1171 #[serde(flatten)]
1172 pub base: ModelProviderConfig,
1173}
1174
1175#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1178#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1179#[serde(rename_all = "snake_case")]
1180pub enum AtomicChatEndpoint {
1181 #[default]
1182 Default,
1183}
1184
1185impl ModelEndpoint for AtomicChatEndpoint {
1186 fn uri(&self) -> &'static str {
1187 match self {
1188 Self::Default => "http://127.0.0.1:1337/v1",
1189 }
1190 }
1191}
1192
1193#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1194#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1195#[prefix = "providers.models.atomic_chat"]
1196pub struct AtomicChatModelProviderConfig {
1197 #[nested]
1198 #[serde(flatten)]
1199 pub base: ModelProviderConfig,
1200}
1201
1202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1205#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1206#[serde(rename_all = "snake_case")]
1207pub enum DeepseekEndpoint {
1208 #[default]
1209 Default,
1210}
1211
1212impl ModelEndpoint for DeepseekEndpoint {
1213 fn uri(&self) -> &'static str {
1214 match self {
1215 Self::Default => "https://api.deepseek.com/v1",
1216 }
1217 }
1218}
1219
1220#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1221#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1222#[prefix = "providers.models.deepseek"]
1223pub struct DeepseekModelProviderConfig {
1224 #[nested]
1225 #[serde(flatten)]
1226 pub base: ModelProviderConfig,
1227}
1228
1229#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1232#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1233#[serde(rename_all = "snake_case")]
1234pub enum CohereEndpoint {
1235 #[default]
1236 Default,
1237}
1238
1239impl ModelEndpoint for CohereEndpoint {
1240 fn uri(&self) -> &'static str {
1241 match self {
1242 Self::Default => "https://api.cohere.ai/compatibility/v1",
1243 }
1244 }
1245}
1246
1247#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1248#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1249#[prefix = "providers.models.cohere"]
1250pub struct CohereModelProviderConfig {
1251 #[nested]
1252 #[serde(flatten)]
1253 pub base: ModelProviderConfig,
1254}
1255
1256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1259#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1260#[serde(rename_all = "snake_case")]
1261pub enum PerplexityEndpoint {
1262 #[default]
1263 Default,
1264}
1265
1266impl ModelEndpoint for PerplexityEndpoint {
1267 fn uri(&self) -> &'static str {
1268 match self {
1269 Self::Default => "https://api.perplexity.ai",
1270 }
1271 }
1272}
1273
1274#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1275#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1276#[prefix = "providers.models.perplexity"]
1277pub struct PerplexityModelProviderConfig {
1278 #[nested]
1279 #[serde(flatten)]
1280 pub base: ModelProviderConfig,
1281}
1282
1283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1286#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1287#[serde(rename_all = "snake_case")]
1288pub enum XaiEndpoint {
1289 #[default]
1290 Default,
1291}
1292
1293impl ModelEndpoint for XaiEndpoint {
1294 fn uri(&self) -> &'static str {
1295 match self {
1296 Self::Default => "https://api.x.ai/v1",
1297 }
1298 }
1299}
1300
1301#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1302#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1303#[prefix = "providers.models.xai"]
1304pub struct XaiModelProviderConfig {
1305 #[nested]
1306 #[serde(flatten)]
1307 pub base: ModelProviderConfig,
1308}
1309
1310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1313#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1314#[serde(rename_all = "snake_case")]
1315pub enum CerebrasEndpoint {
1316 #[default]
1317 Default,
1318}
1319
1320impl ModelEndpoint for CerebrasEndpoint {
1321 fn uri(&self) -> &'static str {
1322 match self {
1323 Self::Default => "https://api.cerebras.ai/v1",
1324 }
1325 }
1326}
1327
1328#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1329#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1330#[prefix = "providers.models.cerebras"]
1331pub struct CerebrasModelProviderConfig {
1332 #[nested]
1333 #[serde(flatten)]
1334 pub base: ModelProviderConfig,
1335}
1336
1337#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1340#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1341#[serde(rename_all = "snake_case")]
1342pub enum SambanovaEndpoint {
1343 #[default]
1344 Default,
1345}
1346
1347impl ModelEndpoint for SambanovaEndpoint {
1348 fn uri(&self) -> &'static str {
1349 match self {
1350 Self::Default => "https://api.sambanova.ai/v1",
1351 }
1352 }
1353}
1354
1355#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1356#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1357#[prefix = "providers.models.sambanova"]
1358pub struct SambanovaModelProviderConfig {
1359 #[nested]
1360 #[serde(flatten)]
1361 pub base: ModelProviderConfig,
1362}
1363
1364#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1367#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1368#[serde(rename_all = "snake_case")]
1369pub enum HyperbolicEndpoint {
1370 #[default]
1371 Default,
1372}
1373
1374impl ModelEndpoint for HyperbolicEndpoint {
1375 fn uri(&self) -> &'static str {
1376 match self {
1377 Self::Default => "https://api.hyperbolic.xyz/v1",
1378 }
1379 }
1380}
1381
1382#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1383#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1384#[prefix = "providers.models.hyperbolic"]
1385pub struct HyperbolicModelProviderConfig {
1386 #[nested]
1387 #[serde(flatten)]
1388 pub base: ModelProviderConfig,
1389}
1390
1391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1394#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1395#[serde(rename_all = "snake_case")]
1396pub enum DeepinfraEndpoint {
1397 #[default]
1398 Default,
1399}
1400
1401impl ModelEndpoint for DeepinfraEndpoint {
1402 fn uri(&self) -> &'static str {
1403 match self {
1404 Self::Default => "https://api.deepinfra.com/v1/openai",
1405 }
1406 }
1407}
1408
1409#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1410#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1411#[prefix = "providers.models.deepinfra"]
1412pub struct DeepinfraModelProviderConfig {
1413 #[nested]
1414 #[serde(flatten)]
1415 pub base: ModelProviderConfig,
1416}
1417
1418#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1421#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1422#[serde(rename_all = "snake_case")]
1423pub enum HuggingfaceEndpoint {
1424 #[default]
1425 Default,
1426}
1427
1428impl ModelEndpoint for HuggingfaceEndpoint {
1429 fn uri(&self) -> &'static str {
1430 match self {
1431 Self::Default => "https://router.huggingface.co/v1",
1432 }
1433 }
1434}
1435
1436#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1437#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1438#[prefix = "providers.models.huggingface"]
1439pub struct HuggingfaceModelProviderConfig {
1440 #[nested]
1441 #[serde(flatten)]
1442 pub base: ModelProviderConfig,
1443}
1444
1445#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1448#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1449#[serde(rename_all = "snake_case")]
1450pub enum Ai21Endpoint {
1451 #[default]
1452 Default,
1453}
1454impl ModelEndpoint for Ai21Endpoint {
1455 fn uri(&self) -> &'static str {
1456 match self {
1457 Self::Default => "https://api.ai21.com/studio/v1",
1458 }
1459 }
1460}
1461#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1462#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1463#[prefix = "providers.models.ai21"]
1464pub struct Ai21ModelProviderConfig {
1465 #[nested]
1466 #[serde(flatten)]
1467 pub base: ModelProviderConfig,
1468}
1469
1470#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1473#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1474#[serde(rename_all = "snake_case")]
1475pub enum RekaEndpoint {
1476 #[default]
1477 Default,
1478}
1479impl ModelEndpoint for RekaEndpoint {
1480 fn uri(&self) -> &'static str {
1481 match self {
1482 Self::Default => "https://api.reka.ai/v1",
1483 }
1484 }
1485}
1486#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1487#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1488#[prefix = "providers.models.reka"]
1489pub struct RekaModelProviderConfig {
1490 #[nested]
1491 #[serde(flatten)]
1492 pub base: ModelProviderConfig,
1493}
1494
1495#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1498#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1499#[serde(rename_all = "snake_case")]
1500pub enum BasetenEndpoint {
1501 #[default]
1502 Default,
1503}
1504impl ModelEndpoint for BasetenEndpoint {
1505 fn uri(&self) -> &'static str {
1506 match self {
1507 Self::Default => "https://inference.baseten.co/v1",
1508 }
1509 }
1510}
1511#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1512#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1513#[prefix = "providers.models.baseten"]
1514pub struct BasetenModelProviderConfig {
1515 #[nested]
1516 #[serde(flatten)]
1517 pub base: ModelProviderConfig,
1518}
1519
1520#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1523#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1524#[serde(rename_all = "snake_case")]
1525pub enum NscaleEndpoint {
1526 #[default]
1527 Default,
1528}
1529impl ModelEndpoint for NscaleEndpoint {
1530 fn uri(&self) -> &'static str {
1531 match self {
1532 Self::Default => "https://inference.api.nscale.com/v1",
1533 }
1534 }
1535}
1536#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1537#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1538#[prefix = "providers.models.nscale"]
1539pub struct NscaleModelProviderConfig {
1540 #[nested]
1541 #[serde(flatten)]
1542 pub base: ModelProviderConfig,
1543}
1544
1545#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1548#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1549#[serde(rename_all = "snake_case")]
1550pub enum AnyscaleEndpoint {
1551 #[default]
1552 Default,
1553}
1554impl ModelEndpoint for AnyscaleEndpoint {
1555 fn uri(&self) -> &'static str {
1556 match self {
1557 Self::Default => "https://api.endpoints.anyscale.com/v1",
1558 }
1559 }
1560}
1561#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1562#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1563#[prefix = "providers.models.anyscale"]
1564pub struct AnyscaleModelProviderConfig {
1565 #[nested]
1566 #[serde(flatten)]
1567 pub base: ModelProviderConfig,
1568}
1569
1570#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1573#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1574#[serde(rename_all = "snake_case")]
1575pub enum NebiusEndpoint {
1576 #[default]
1577 Default,
1578}
1579impl ModelEndpoint for NebiusEndpoint {
1580 fn uri(&self) -> &'static str {
1581 match self {
1582 Self::Default => "https://api.studio.nebius.ai/v1",
1583 }
1584 }
1585}
1586#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1587#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1588#[prefix = "providers.models.nebius"]
1589pub struct NebiusModelProviderConfig {
1590 #[nested]
1591 #[serde(flatten)]
1592 pub base: ModelProviderConfig,
1593}
1594
1595#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1598#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1599#[serde(rename_all = "snake_case")]
1600pub enum FriendliEndpoint {
1601 #[default]
1602 Default,
1603}
1604impl ModelEndpoint for FriendliEndpoint {
1605 fn uri(&self) -> &'static str {
1606 match self {
1607 Self::Default => "https://api.friendli.ai/serverless/v1",
1608 }
1609 }
1610}
1611#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1612#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1613#[prefix = "providers.models.friendli"]
1614pub struct FriendliModelProviderConfig {
1615 #[nested]
1616 #[serde(flatten)]
1617 pub base: ModelProviderConfig,
1618}
1619
1620#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1623#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1624#[serde(rename_all = "snake_case")]
1625pub enum StepfunEndpoint {
1626 Cn,
1628 #[default]
1630 Intl,
1631}
1632impl ModelEndpoint for StepfunEndpoint {
1633 fn uri(&self) -> &'static str {
1634 match self {
1635 Self::Cn => "https://api.stepfun.com/v1",
1636 Self::Intl => "https://api.stepfun.ai/v1",
1637 }
1638 }
1639}
1640#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1641#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1642#[prefix = "providers.models.stepfun"]
1643pub struct StepfunModelProviderConfig {
1644 #[nested]
1645 #[serde(flatten)]
1646 pub base: ModelProviderConfig,
1647 #[serde(default)]
1648 pub endpoint: StepfunEndpoint,
1649}
1650
1651impl FamilyEndpoint for StepfunModelProviderConfig {
1652 fn endpoint_uri(&self) -> Option<&'static str> {
1653 Some(self.endpoint.uri())
1654 }
1655}
1656
1657#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1660#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1661#[serde(rename_all = "snake_case")]
1662pub enum AihubmixEndpoint {
1663 #[default]
1664 Default,
1665}
1666impl ModelEndpoint for AihubmixEndpoint {
1667 fn uri(&self) -> &'static str {
1668 match self {
1669 Self::Default => "https://aihubmix.com/v1",
1670 }
1671 }
1672}
1673#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1674#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1675#[prefix = "providers.models.aihubmix"]
1676pub struct AihubmixModelProviderConfig {
1677 #[nested]
1678 #[serde(flatten)]
1679 pub base: ModelProviderConfig,
1680}
1681
1682#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1685#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1686#[serde(rename_all = "snake_case")]
1687pub enum SiliconflowEndpoint {
1688 #[default]
1689 Default,
1690}
1691impl ModelEndpoint for SiliconflowEndpoint {
1692 fn uri(&self) -> &'static str {
1693 match self {
1694 Self::Default => "https://api.siliconflow.com/v1",
1695 }
1696 }
1697}
1698#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1699#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1700#[prefix = "providers.models.siliconflow"]
1701pub struct SiliconflowModelProviderConfig {
1702 #[nested]
1703 #[serde(flatten)]
1704 pub base: ModelProviderConfig,
1705}
1706
1707#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1710#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1711#[serde(rename_all = "snake_case")]
1712pub enum AstraiEndpoint {
1713 #[default]
1714 Default,
1715}
1716impl ModelEndpoint for AstraiEndpoint {
1717 fn uri(&self) -> &'static str {
1718 match self {
1719 Self::Default => "https://as-trai.com/v1",
1720 }
1721 }
1722}
1723#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1724#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1725#[prefix = "providers.models.astrai"]
1726pub struct AstraiModelProviderConfig {
1727 #[nested]
1728 #[serde(flatten)]
1729 pub base: ModelProviderConfig,
1730}
1731
1732#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1735#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1736#[serde(rename_all = "snake_case")]
1737pub enum AvianEndpoint {
1738 #[default]
1739 Default,
1740}
1741impl ModelEndpoint for AvianEndpoint {
1742 fn uri(&self) -> &'static str {
1743 match self {
1744 Self::Default => "https://api.avian.io/v1",
1745 }
1746 }
1747}
1748#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1749#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1750#[prefix = "providers.models.avian"]
1751pub struct AvianModelProviderConfig {
1752 #[nested]
1753 #[serde(flatten)]
1754 pub base: ModelProviderConfig,
1755}
1756
1757#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1760#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1761#[serde(rename_all = "snake_case")]
1762pub enum DeepmystEndpoint {
1763 #[default]
1764 Default,
1765}
1766impl ModelEndpoint for DeepmystEndpoint {
1767 fn uri(&self) -> &'static str {
1768 match self {
1769 Self::Default => "https://api.deepmyst.com/v1",
1770 }
1771 }
1772}
1773#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1774#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1775#[prefix = "providers.models.deepmyst"]
1776pub struct DeepmystModelProviderConfig {
1777 #[nested]
1778 #[serde(flatten)]
1779 pub base: ModelProviderConfig,
1780}
1781
1782#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1785#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1786#[serde(rename_all = "snake_case")]
1787pub enum VeniceEndpoint {
1788 #[default]
1789 Default,
1790}
1791impl ModelEndpoint for VeniceEndpoint {
1792 fn uri(&self) -> &'static str {
1793 match self {
1794 Self::Default => "https://api.venice.ai",
1795 }
1796 }
1797}
1798#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1799#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1800#[prefix = "providers.models.venice"]
1801pub struct VeniceModelProviderConfig {
1802 #[nested]
1803 #[serde(flatten)]
1804 pub base: ModelProviderConfig,
1805}
1806
1807#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1810#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1811#[serde(rename_all = "snake_case")]
1812pub enum NovitaEndpoint {
1813 #[default]
1814 Default,
1815}
1816impl ModelEndpoint for NovitaEndpoint {
1817 fn uri(&self) -> &'static str {
1818 match self {
1819 Self::Default => "https://api.novita.ai/openai",
1820 }
1821 }
1822}
1823#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1824#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1825#[prefix = "providers.models.novita"]
1826pub struct NovitaModelProviderConfig {
1827 #[nested]
1828 #[serde(flatten)]
1829 pub base: ModelProviderConfig,
1830}
1831
1832#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1835#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1836#[serde(rename_all = "snake_case")]
1837pub enum NvidiaEndpoint {
1838 #[default]
1839 Default,
1840}
1841impl ModelEndpoint for NvidiaEndpoint {
1842 fn uri(&self) -> &'static str {
1843 match self {
1844 Self::Default => "https://integrate.api.nvidia.com/v1",
1845 }
1846 }
1847}
1848#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1849#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1850#[prefix = "providers.models.nvidia"]
1851pub struct NvidiaModelProviderConfig {
1852 #[nested]
1853 #[serde(flatten)]
1854 pub base: ModelProviderConfig,
1855}
1856
1857#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1860#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1861#[serde(rename_all = "snake_case")]
1862pub enum TelnyxEndpoint {
1863 #[default]
1864 Default,
1865}
1866impl ModelEndpoint for TelnyxEndpoint {
1867 fn uri(&self) -> &'static str {
1868 match self {
1869 Self::Default => "https://api.telnyx.com/v2",
1870 }
1871 }
1872}
1873#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1874#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1875#[prefix = "providers.models.telnyx"]
1876pub struct TelnyxModelProviderConfig {
1877 #[nested]
1878 #[serde(flatten)]
1879 pub base: ModelProviderConfig,
1880}
1881
1882#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1885#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1886#[serde(rename_all = "snake_case")]
1887pub enum VercelEndpoint {
1888 #[default]
1889 Default,
1890}
1891impl ModelEndpoint for VercelEndpoint {
1892 fn uri(&self) -> &'static str {
1893 match self {
1894 Self::Default => "https://ai-gateway.vercel.sh/v1",
1895 }
1896 }
1897}
1898#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1899#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1900#[prefix = "providers.models.vercel"]
1901pub struct VercelModelProviderConfig {
1902 #[nested]
1903 #[serde(flatten)]
1904 pub base: ModelProviderConfig,
1905}
1906
1907#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1910#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1911#[serde(rename_all = "snake_case")]
1912pub enum CloudflareEndpoint {
1913 #[default]
1914 Default,
1915}
1916impl ModelEndpoint for CloudflareEndpoint {
1917 fn uri(&self) -> &'static str {
1918 match self {
1919 Self::Default => "https://gateway.ai.cloudflare.com/v1",
1920 }
1921 }
1922}
1923#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1924#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1925#[prefix = "providers.models.cloudflare"]
1926pub struct CloudflareModelProviderConfig {
1927 #[nested]
1928 #[serde(flatten)]
1929 pub base: ModelProviderConfig,
1930}
1931
1932#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1935#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1936#[serde(rename_all = "snake_case")]
1937pub enum OvhEndpoint {
1938 #[default]
1939 Default,
1940}
1941impl ModelEndpoint for OvhEndpoint {
1942 fn uri(&self) -> &'static str {
1943 match self {
1944 Self::Default => "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1",
1945 }
1946 }
1947}
1948#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1949#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1950#[prefix = "providers.models.ovh"]
1951pub struct OvhModelProviderConfig {
1952 #[nested]
1953 #[serde(flatten)]
1954 pub base: ModelProviderConfig,
1955}
1956
1957#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1960#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1961#[serde(rename_all = "snake_case")]
1962pub enum CopilotEndpoint {
1963 #[default]
1964 Default,
1965}
1966impl ModelEndpoint for CopilotEndpoint {
1967 fn uri(&self) -> &'static str {
1968 match self {
1969 Self::Default => "https://api.githubcopilot.com",
1970 }
1971 }
1972}
1973#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1974#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1975#[prefix = "providers.models.copilot"]
1976pub struct CopilotModelProviderConfig {
1977 #[nested]
1978 #[serde(flatten)]
1979 pub base: ModelProviderConfig,
1980}
1981
1982#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1985#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1986#[serde(rename_all = "snake_case")]
1987pub enum GlmEndpoint {
1988 Cn,
1989 #[default]
1990 Global,
1991}
1992impl ModelEndpoint for GlmEndpoint {
1993 fn uri(&self) -> &'static str {
1994 match self {
1995 Self::Cn => "https://open.bigmodel.cn/api/paas/v4",
1996 Self::Global => "https://api.z.ai/api/paas/v4",
1997 }
1998 }
1999}
2000#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2001#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2002#[prefix = "providers.models.glm"]
2003pub struct GlmModelProviderConfig {
2004 #[nested]
2005 #[serde(flatten)]
2006 pub base: ModelProviderConfig,
2007 #[serde(default)]
2008 pub endpoint: GlmEndpoint,
2009}
2010
2011impl FamilyEndpoint for GlmModelProviderConfig {
2012 fn endpoint_uri(&self) -> Option<&'static str> {
2013 Some(self.endpoint.uri())
2014 }
2015}
2016
2017#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2020#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2021#[serde(rename_all = "snake_case")]
2022pub enum MinimaxEndpoint {
2023 Cn,
2024 #[default]
2025 Intl,
2026}
2027impl ModelEndpoint for MinimaxEndpoint {
2028 fn uri(&self) -> &'static str {
2029 match self {
2030 Self::Cn => "https://api.minimaxi.com/v1",
2031 Self::Intl => "https://api.minimax.io/v1",
2032 }
2033 }
2034}
2035
2036impl MinimaxEndpoint {
2037 pub fn oauth_token_endpoint(self) -> &'static str {
2041 match self {
2042 Self::Cn => "https://api.minimaxi.com/oauth/token",
2043 Self::Intl => "https://api.minimax.io/oauth/token",
2044 }
2045 }
2046}
2047
2048#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2049#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2050#[prefix = "providers.models.minimax"]
2051pub struct MinimaxModelProviderConfig {
2052 #[nested]
2053 #[serde(flatten)]
2054 pub base: ModelProviderConfig,
2055 #[serde(default)]
2056 pub endpoint: MinimaxEndpoint,
2057 #[serde(default, skip_serializing_if = "Option::is_none")]
2058 pub auth_mode: Option<AuthMode>,
2059 #[serde(default, skip_serializing_if = "Option::is_none")]
2065 #[secret(category = "model_provider")]
2066 pub oauth_refresh_token: Option<String>,
2067 #[serde(default, skip_serializing_if = "Option::is_none")]
2071 pub oauth_client_id: Option<String>,
2072}
2073
2074impl FamilyEndpoint for MinimaxModelProviderConfig {
2075 fn endpoint_uri(&self) -> Option<&'static str> {
2076 Some(self.endpoint.uri())
2077 }
2078}
2079
2080#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2083#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2084#[serde(rename_all = "snake_case")]
2085pub enum ZaiEndpoint {
2086 Cn,
2087 #[default]
2088 Global,
2089}
2090impl ModelEndpoint for ZaiEndpoint {
2091 fn uri(&self) -> &'static str {
2092 match self {
2093 Self::Cn => "https://open.bigmodel.cn/api/coding/paas/v4",
2094 Self::Global => "https://api.z.ai/api/coding/paas/v4",
2095 }
2096 }
2097}
2098#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2099#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2100#[prefix = "providers.models.zai"]
2101pub struct ZaiModelProviderConfig {
2102 #[nested]
2103 #[serde(flatten)]
2104 pub base: ModelProviderConfig,
2105 #[serde(default)]
2106 pub endpoint: ZaiEndpoint,
2107}
2108
2109impl FamilyEndpoint for ZaiModelProviderConfig {
2110 fn endpoint_uri(&self) -> Option<&'static str> {
2111 Some(self.endpoint.uri())
2112 }
2113}
2114
2115#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2118#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2119#[serde(rename_all = "snake_case")]
2120pub enum DoubaoEndpoint {
2121 #[default]
2122 Default,
2123}
2124impl ModelEndpoint for DoubaoEndpoint {
2125 fn uri(&self) -> &'static str {
2126 match self {
2127 Self::Default => "https://ark.cn-beijing.volces.com/api/v3",
2128 }
2129 }
2130}
2131#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2132#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2133#[prefix = "providers.models.doubao"]
2134pub struct DoubaoModelProviderConfig {
2135 #[nested]
2136 #[serde(flatten)]
2137 pub base: ModelProviderConfig,
2138}
2139
2140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2143#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2144#[serde(rename_all = "snake_case")]
2145pub enum YiEndpoint {
2146 #[default]
2147 Default,
2148}
2149impl ModelEndpoint for YiEndpoint {
2150 fn uri(&self) -> &'static str {
2151 match self {
2152 Self::Default => "https://api.lingyiwanwu.com/v1",
2153 }
2154 }
2155}
2156#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2157#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2158#[prefix = "providers.models.yi"]
2159pub struct YiModelProviderConfig {
2160 #[nested]
2161 #[serde(flatten)]
2162 pub base: ModelProviderConfig,
2163}
2164
2165#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2168#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2169#[serde(rename_all = "snake_case")]
2170pub enum HunyuanEndpoint {
2171 #[default]
2172 Default,
2173}
2174impl ModelEndpoint for HunyuanEndpoint {
2175 fn uri(&self) -> &'static str {
2176 match self {
2177 Self::Default => "https://api.hunyuan.cloud.tencent.com/v1",
2178 }
2179 }
2180}
2181#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2182#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2183#[prefix = "providers.models.hunyuan"]
2184pub struct HunyuanModelProviderConfig {
2185 #[nested]
2186 #[serde(flatten)]
2187 pub base: ModelProviderConfig,
2188}
2189
2190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2193#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2194#[serde(rename_all = "snake_case")]
2195pub enum QianfanEndpoint {
2196 #[default]
2197 Default,
2198}
2199impl ModelEndpoint for QianfanEndpoint {
2200 fn uri(&self) -> &'static str {
2201 match self {
2202 Self::Default => "https://qianfan.baidubce.com/v2",
2203 }
2204 }
2205}
2206#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2207#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2208#[prefix = "providers.models.qianfan"]
2209pub struct QianfanModelProviderConfig {
2210 #[nested]
2211 #[serde(flatten)]
2212 pub base: ModelProviderConfig,
2213}
2214
2215#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2218#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2219#[serde(rename_all = "snake_case")]
2220pub enum BaichuanEndpoint {
2221 #[default]
2222 Default,
2223}
2224impl ModelEndpoint for BaichuanEndpoint {
2225 fn uri(&self) -> &'static str {
2226 match self {
2227 Self::Default => "https://api.baichuan-ai.com/v1",
2228 }
2229 }
2230}
2231#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2232#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2233#[prefix = "providers.models.baichuan"]
2234pub struct BaichuanModelProviderConfig {
2235 #[nested]
2236 #[serde(flatten)]
2237 pub base: ModelProviderConfig,
2238}
2239
2240#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2243#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2244#[serde(rename_all = "snake_case")]
2245pub enum GeminiEndpoint {
2246 #[default]
2247 Default,
2248}
2249impl ModelEndpoint for GeminiEndpoint {
2250 fn uri(&self) -> &'static str {
2251 match self {
2252 Self::Default => "https://generativelanguage.googleapis.com/v1beta",
2253 }
2254 }
2255}
2256#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2257#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2258#[prefix = "providers.models.gemini"]
2259pub struct GeminiModelProviderConfig {
2260 #[nested]
2261 #[serde(flatten)]
2262 pub base: ModelProviderConfig,
2263 #[serde(default, skip_serializing_if = "Option::is_none")]
2266 pub auth_mode: Option<AuthMode>,
2267 #[serde(default, skip_serializing_if = "Option::is_none")]
2273 #[secret(category = "model_provider")]
2274 pub oauth_client_id: Option<String>,
2275 #[serde(default, skip_serializing_if = "Option::is_none")]
2277 #[secret(category = "model_provider")]
2278 pub oauth_client_secret: Option<String>,
2279 #[serde(default, skip_serializing_if = "Option::is_none")]
2284 pub oauth_project: Option<String>,
2285}
2286
2287#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2290#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2291#[serde(rename_all = "snake_case")]
2292pub enum GeminiCliEndpoint {
2293 #[default]
2294 LocalSubprocess,
2295}
2296impl ModelEndpoint for GeminiCliEndpoint {
2297 fn uri(&self) -> &'static str {
2298 "subprocess://gemini-cli"
2300 }
2301}
2302#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2303#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2304#[prefix = "providers.models.gemini_cli"]
2305pub struct GeminiCliModelProviderConfig {
2306 #[nested]
2307 #[serde(flatten)]
2308 pub base: ModelProviderConfig,
2309 #[serde(default, skip_serializing_if = "Option::is_none")]
2311 pub binary_path: Option<String>,
2312}
2313
2314#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2317#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2318#[serde(rename_all = "snake_case")]
2319pub enum LmstudioEndpoint {
2320 #[default]
2321 LocalDefault,
2322}
2323impl ModelEndpoint for LmstudioEndpoint {
2324 fn uri(&self) -> &'static str {
2325 match self {
2326 Self::LocalDefault => "http://localhost:1234/v1",
2327 }
2328 }
2329}
2330#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2331#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2332#[prefix = "providers.models.lmstudio"]
2333pub struct LmstudioModelProviderConfig {
2334 #[nested]
2335 #[serde(flatten)]
2336 pub base: ModelProviderConfig,
2337}
2338
2339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2342#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2343#[serde(rename_all = "snake_case")]
2344pub enum LlamacppEndpoint {
2345 #[default]
2346 LocalDefault,
2347}
2348impl ModelEndpoint for LlamacppEndpoint {
2349 fn uri(&self) -> &'static str {
2350 match self {
2351 Self::LocalDefault => "http://localhost:8080/v1",
2352 }
2353 }
2354}
2355#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2356#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2357#[prefix = "providers.models.llamacpp"]
2358pub struct LlamacppModelProviderConfig {
2359 #[nested]
2360 #[serde(flatten)]
2361 pub base: ModelProviderConfig,
2362}
2363
2364#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2367#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2368#[serde(rename_all = "snake_case")]
2369pub enum SglangEndpoint {
2370 #[default]
2371 LocalDefault,
2372}
2373impl ModelEndpoint for SglangEndpoint {
2374 fn uri(&self) -> &'static str {
2375 match self {
2376 Self::LocalDefault => "http://localhost:30000/v1",
2377 }
2378 }
2379}
2380#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2381#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2382#[prefix = "providers.models.sglang"]
2383pub struct SglangModelProviderConfig {
2384 #[nested]
2385 #[serde(flatten)]
2386 pub base: ModelProviderConfig,
2387}
2388
2389#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2392#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2393#[serde(rename_all = "snake_case")]
2394pub enum VllmEndpoint {
2395 #[default]
2396 LocalDefault,
2397}
2398impl ModelEndpoint for VllmEndpoint {
2399 fn uri(&self) -> &'static str {
2400 match self {
2401 Self::LocalDefault => "http://localhost:8000/v1",
2402 }
2403 }
2404}
2405#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2406#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2407#[prefix = "providers.models.vllm"]
2408pub struct VllmModelProviderConfig {
2409 #[nested]
2410 #[serde(flatten)]
2411 pub base: ModelProviderConfig,
2412}
2413
2414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2417#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2418#[serde(rename_all = "snake_case")]
2419pub enum OsaurusEndpoint {
2420 #[default]
2421 LocalDefault,
2422}
2423impl ModelEndpoint for OsaurusEndpoint {
2424 fn uri(&self) -> &'static str {
2425 match self {
2426 Self::LocalDefault => "http://localhost:1337/v1",
2427 }
2428 }
2429}
2430#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2431#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2432#[prefix = "providers.models.osaurus"]
2433pub struct OsaurusModelProviderConfig {
2434 #[nested]
2435 #[serde(flatten)]
2436 pub base: ModelProviderConfig,
2437}
2438
2439#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2442#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2443#[serde(rename_all = "snake_case")]
2444pub enum LitellmEndpoint {
2445 #[default]
2446 LocalDefault,
2447}
2448impl ModelEndpoint for LitellmEndpoint {
2449 fn uri(&self) -> &'static str {
2450 match self {
2451 Self::LocalDefault => "http://localhost:4000/v1",
2452 }
2453 }
2454}
2455#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2456#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2457#[prefix = "providers.models.litellm"]
2458pub struct LitellmModelProviderConfig {
2459 #[nested]
2460 #[serde(flatten)]
2461 pub base: ModelProviderConfig,
2462}
2463
2464#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2467#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2468#[serde(rename_all = "snake_case")]
2469pub enum LeptonEndpoint {
2470 #[default]
2471 Default,
2472}
2473impl ModelEndpoint for LeptonEndpoint {
2474 fn uri(&self) -> &'static str {
2475 match self {
2476 Self::Default => "https://llama3-1-405b.lepton.run/api/v1",
2477 }
2478 }
2479}
2480#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2481#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2482#[prefix = "providers.models.lepton"]
2483pub struct LeptonModelProviderConfig {
2484 #[nested]
2485 #[serde(flatten)]
2486 pub base: ModelProviderConfig,
2487}
2488
2489#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2492#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2493#[serde(rename_all = "snake_case")]
2494pub enum SyntheticEndpoint {
2495 #[default]
2496 Default,
2497}
2498impl ModelEndpoint for SyntheticEndpoint {
2499 fn uri(&self) -> &'static str {
2500 match self {
2501 Self::Default => "https://api.synthetic.new/openai/v1",
2502 }
2503 }
2504}
2505#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2506#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2507#[prefix = "providers.models.synthetic"]
2508pub struct SyntheticModelProviderConfig {
2509 #[nested]
2510 #[serde(flatten)]
2511 pub base: ModelProviderConfig,
2512}
2513
2514#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2517#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2518#[serde(rename_all = "snake_case")]
2519pub enum OpencodeEndpoint {
2520 #[default]
2521 Default,
2522}
2523impl ModelEndpoint for OpencodeEndpoint {
2524 fn uri(&self) -> &'static str {
2525 match self {
2526 Self::Default => "https://opencode.ai/zen/v1",
2527 }
2528 }
2529}
2530#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2531#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2532#[prefix = "providers.models.opencode"]
2533pub struct OpencodeModelProviderConfig {
2534 #[nested]
2535 #[serde(flatten)]
2536 pub base: ModelProviderConfig,
2537}
2538
2539#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2542#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2543#[serde(rename_all = "snake_case")]
2544pub enum KiloCliEndpoint {
2545 #[default]
2546 LocalSubprocess,
2547}
2548impl ModelEndpoint for KiloCliEndpoint {
2549 fn uri(&self) -> &'static str {
2550 match self {
2551 Self::LocalSubprocess => "subprocess://kilocli",
2552 }
2553 }
2554}
2555#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2556#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2557#[prefix = "providers.models.kilocli"]
2558pub struct KiloCliModelProviderConfig {
2559 #[nested]
2560 #[serde(flatten)]
2561 pub base: ModelProviderConfig,
2562 #[serde(default, skip_serializing_if = "Option::is_none")]
2564 pub binary_path: Option<String>,
2565}
2566
2567#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2574#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2575#[serde(rename_all = "snake_case")]
2576pub enum CustomEndpoint {
2577 #[default]
2578 OperatorSupplied,
2579}
2580impl ModelEndpoint for CustomEndpoint {
2581 fn uri(&self) -> &'static str {
2582 match self {
2583 Self::OperatorSupplied => "operator-supplied:set-cfg-uri",
2584 }
2585 }
2586}
2587#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2588#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2589#[prefix = "providers.models.custom"]
2590pub struct CustomModelProviderConfig {
2591 #[nested]
2592 #[serde(flatten)]
2593 pub base: ModelProviderConfig,
2594}
2595
2596#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2601#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2602#[serde(rename_all = "snake_case")]
2603pub enum BedrockEndpoint {
2604 #[default]
2605 Default,
2606}
2607
2608impl ModelEndpoint for BedrockEndpoint {
2609 fn uri(&self) -> &'static str {
2610 match self {
2611 Self::Default => "https://bedrock-runtime.{region}.amazonaws.com",
2614 }
2615 }
2616}
2617
2618#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2623#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2624#[prefix = "providers.models.bedrock"]
2625pub struct BedrockModelProviderConfig {
2626 #[nested]
2627 #[serde(flatten)]
2628 pub base: ModelProviderConfig,
2629 #[serde(default, skip_serializing_if = "Option::is_none")]
2631 pub region: Option<String>,
2632}
2633
2634macro_rules! impl_default_family_endpoint {
2644 ($($t:ty),+ $(,)?) => {
2645 $( impl FamilyEndpoint for $t {} )+
2646 };
2647}
2648
2649impl_default_family_endpoint! {
2650 OpenAIModelProviderConfig,
2651 AzureModelProviderConfig,
2652 AnthropicModelProviderConfig,
2653 AtomicChatModelProviderConfig,
2654 OpenRouterModelProviderConfig,
2655 OllamaModelProviderConfig,
2656 TogetherModelProviderConfig,
2657 FireworksModelProviderConfig,
2658 GroqModelProviderConfig,
2659 MistralModelProviderConfig,
2660 DeepseekModelProviderConfig,
2661 CohereModelProviderConfig,
2662 PerplexityModelProviderConfig,
2663 XaiModelProviderConfig,
2664 CerebrasModelProviderConfig,
2665 SambanovaModelProviderConfig,
2666 HyperbolicModelProviderConfig,
2667 DeepinfraModelProviderConfig,
2668 HuggingfaceModelProviderConfig,
2669 Ai21ModelProviderConfig,
2670 RekaModelProviderConfig,
2671 BasetenModelProviderConfig,
2672 NscaleModelProviderConfig,
2673 AnyscaleModelProviderConfig,
2674 NebiusModelProviderConfig,
2675 FriendliModelProviderConfig,
2676 AihubmixModelProviderConfig,
2677 SiliconflowModelProviderConfig,
2678 AstraiModelProviderConfig,
2679 AvianModelProviderConfig,
2680 DeepmystModelProviderConfig,
2681 VeniceModelProviderConfig,
2682 NovitaModelProviderConfig,
2683 NvidiaModelProviderConfig,
2684 TelnyxModelProviderConfig,
2685 VercelModelProviderConfig,
2686 CloudflareModelProviderConfig,
2687 OvhModelProviderConfig,
2688 CopilotModelProviderConfig,
2689 DoubaoModelProviderConfig,
2690 YiModelProviderConfig,
2691 HunyuanModelProviderConfig,
2692 QianfanModelProviderConfig,
2693 BaichuanModelProviderConfig,
2694 GeminiModelProviderConfig,
2695 GeminiCliModelProviderConfig,
2696 LmstudioModelProviderConfig,
2697 LlamacppModelProviderConfig,
2698 SglangModelProviderConfig,
2699 VllmModelProviderConfig,
2700 OsaurusModelProviderConfig,
2701 LitellmModelProviderConfig,
2702 LeptonModelProviderConfig,
2703 SyntheticModelProviderConfig,
2704 OpencodeModelProviderConfig,
2705 KiloCliModelProviderConfig,
2706 CustomModelProviderConfig,
2707 BedrockModelProviderConfig,
2708}
2709
2710#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
2714#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2715#[prefix = "delegate"]
2716pub struct DelegateToolConfig {
2717 #[serde(default = "default_delegate_timeout_secs")]
2721 pub timeout_secs: u64,
2722 #[serde(default = "default_delegate_agentic_timeout_secs")]
2726 pub agentic_timeout_secs: u64,
2727}
2728
2729impl Default for DelegateToolConfig {
2730 fn default() -> Self {
2731 Self {
2732 timeout_secs: DEFAULT_DELEGATE_TIMEOUT_SECS,
2733 agentic_timeout_secs: DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS,
2734 }
2735 }
2736}
2737
2738#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
2744#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2745#[prefix = "delegate-agent"]
2746pub struct AliasedAgentConfig {
2747 #[serde(default = "default_true")]
2749 pub enabled: bool,
2750 #[serde(default)]
2754 pub channels: Vec<crate::providers::ChannelRef>,
2755 #[serde(default)]
2759 pub model_provider: crate::providers::ModelProviderRef,
2760 #[serde(default)]
2762 pub risk_profile: String,
2763 #[serde(default)]
2765 pub runtime_profile: String,
2766 #[serde(default)]
2770 pub skill_bundles: Vec<String>,
2771 #[serde(default)]
2774 pub knowledge_bundles: Vec<String>,
2775 #[serde(default)]
2779 pub mcp_bundles: Vec<String>,
2780 #[serde(default)]
2784 pub cron_jobs: Vec<String>,
2785 #[serde(default)]
2790 pub tts_provider: crate::providers::TtsProviderRef,
2791 #[serde(default)]
2799 pub transcription_provider: crate::providers::TranscriptionProviderRef,
2800
2801 #[serde(default)]
2826 pub classifier_provider: crate::providers::ModelProviderRef,
2827
2828 #[serde(default = "default_agent_compact_context")]
2834 pub compact_context: bool,
2835 #[serde(default = "default_agent_max_tool_iterations")]
2838 pub max_tool_iterations: usize,
2839 #[serde(default = "default_agent_max_history_messages")]
2841 pub max_history_messages: usize,
2842 #[serde(default = "default_agent_max_context_tokens")]
2846 pub max_context_tokens: usize,
2847 #[serde(default)]
2849 pub parallel_tools: bool,
2850 #[serde(default = "default_agent_tool_dispatcher")]
2852 pub tool_dispatcher: String,
2853 #[serde(default)]
2857 pub strict_tool_parsing: bool,
2858 #[serde(default)]
2860 pub tool_call_dedup_exempt: Vec<String>,
2861 #[serde(default)]
2867 pub tool_filter_groups: Vec<ToolFilterGroup>,
2868 #[serde(default = "default_max_system_prompt_chars")]
2873 pub max_system_prompt_chars: usize,
2874 #[nested]
2877 #[serde(default)]
2878 pub thinking: crate::scattered_types::ThinkingConfig,
2879 #[nested]
2881 #[serde(default)]
2882 pub history_pruning: crate::scattered_types::HistoryPrunerConfig,
2883 #[nested]
2885 #[serde(default)]
2886 pub precheck: crate::scattered_types::ChannelPrecheckConfig,
2887 #[serde(default)]
2889 pub context_aware_tools: bool,
2890 #[nested]
2892 #[serde(default)]
2893 pub eval: crate::scattered_types::EvalConfig,
2894 #[nested]
2896 #[serde(default)]
2897 pub auto_classify: Option<crate::scattered_types::AutoClassifyConfig>,
2898 #[nested]
2900 #[serde(default)]
2901 pub context_compression: crate::scattered_types::ContextCompressionConfig,
2902 #[serde(default = "default_max_tool_result_chars")]
2906 pub max_tool_result_chars: usize,
2907 #[serde(default = "default_keep_tool_context_turns")]
2912 pub keep_tool_context_turns: usize,
2913
2914 #[nested]
2916 #[serde(default)]
2917 pub tool_receipts: ToolReceiptsConfig,
2918
2919 #[serde(default)]
2925 #[nested]
2926 pub workspace: crate::multi_agent::AgentWorkspaceConfig,
2927
2928 #[serde(default)]
2933 #[nested]
2934 pub memory: crate::multi_agent::AgentMemoryConfig,
2935
2936 #[serde(default)]
2942 #[nested]
2943 pub identity: IdentityConfig,
2944}
2945
2946fn default_agent_compact_context() -> bool {
2947 true
2948}
2949
2950impl Default for AliasedAgentConfig {
2951 fn default() -> Self {
2952 Self {
2953 enabled: true,
2954 channels: Vec::new(),
2955 model_provider: crate::providers::ModelProviderRef::default(),
2956 risk_profile: String::new(),
2957 runtime_profile: String::new(),
2958 skill_bundles: Vec::new(),
2959 knowledge_bundles: Vec::new(),
2960 mcp_bundles: Vec::new(),
2961 cron_jobs: Vec::new(),
2962 tts_provider: crate::providers::TtsProviderRef::default(),
2963 transcription_provider: crate::providers::TranscriptionProviderRef::default(),
2964 classifier_provider: crate::providers::ModelProviderRef::default(),
2965 compact_context: default_agent_compact_context(),
2966 max_tool_iterations: default_agent_max_tool_iterations(),
2967 max_history_messages: default_agent_max_history_messages(),
2968 max_context_tokens: default_agent_max_context_tokens(),
2969 parallel_tools: false,
2970 tool_dispatcher: default_agent_tool_dispatcher(),
2971 strict_tool_parsing: false,
2972 tool_call_dedup_exempt: Vec::new(),
2973 tool_filter_groups: Vec::new(),
2974 max_system_prompt_chars: default_max_system_prompt_chars(),
2975 thinking: crate::scattered_types::ThinkingConfig::default(),
2976 history_pruning: crate::scattered_types::HistoryPrunerConfig::default(),
2977 precheck: crate::scattered_types::ChannelPrecheckConfig::default(),
2978 context_aware_tools: false,
2979 eval: crate::scattered_types::EvalConfig::default(),
2980 auto_classify: None,
2981 context_compression: crate::scattered_types::ContextCompressionConfig::default(),
2982 max_tool_result_chars: default_max_tool_result_chars(),
2983 keep_tool_context_turns: default_keep_tool_context_turns(),
2984 tool_receipts: ToolReceiptsConfig::default(),
2985 workspace: crate::multi_agent::AgentWorkspaceConfig::default(),
2986 memory: crate::multi_agent::AgentMemoryConfig::default(),
2987 identity: IdentityConfig::default(),
2988 }
2989 }
2990}
2991
2992impl AliasedAgentConfig {
2993 #[must_use]
2998 pub fn is_dispatchable(&self) -> bool {
2999 self.enabled
3000 && !self.model_provider.is_empty()
3001 && !self.risk_profile.trim().is_empty()
3002 && !self.runtime_profile.trim().is_empty()
3003 }
3004}
3005
3006#[derive(Debug, Clone, Serialize, Deserialize)]
3010#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3011pub struct ChannelAliasInfo {
3012 pub channel_type: String,
3015 pub alias: String,
3017 pub owning_agent: Option<String>,
3020 pub enabled: bool,
3023}
3024
3025impl Config {
3026 #[must_use]
3032 pub fn resolve_default_model(&self) -> Option<String> {
3033 self.providers
3034 .models
3035 .iter_entries()
3036 .filter_map(|(_, _, base)| base.model.as_deref().map(str::trim))
3037 .find(|m| !m.is_empty())
3038 .map(ToString::to_string)
3039 }
3040
3041 #[must_use]
3044 pub fn first_model_provider(&self) -> Option<&ModelProviderConfig> {
3045 self.providers
3046 .models
3047 .iter_entries()
3048 .next()
3049 .map(|(_, _, base)| base)
3050 }
3051
3052 pub fn first_model_provider_mut(&mut self) -> Option<&mut ModelProviderConfig> {
3054 self.providers
3055 .models
3056 .iter_entries_mut()
3057 .next()
3058 .map(|(_, _, base)| base)
3059 }
3060
3061 #[must_use]
3066 pub fn first_model_provider_type(&self) -> Option<&'static str> {
3067 self.providers
3068 .models
3069 .iter_entries()
3070 .next()
3071 .map(|(ty, _, _)| ty)
3072 }
3073
3074 #[must_use]
3079 pub fn first_model_provider_alias(&self) -> Option<String> {
3080 self.providers
3081 .models
3082 .iter_entries()
3083 .next()
3084 .map(|(ty, alias, _)| format!("{ty}.{alias}"))
3085 }
3086
3087 #[must_use]
3096 pub fn risk_profile_for_agent(&self, agent_alias: &str) -> Option<&RiskProfileConfig> {
3097 let agent = self.agents.get(agent_alias)?;
3098 let profile_alias = agent.risk_profile.trim();
3099 if profile_alias.is_empty() {
3100 return None;
3101 }
3102 self.risk_profiles.get(profile_alias)
3103 }
3104
3105 #[must_use]
3111 pub fn runtime_profile_for_agent(&self, agent_alias: &str) -> Option<&RuntimeProfileConfig> {
3112 let agent = self.agents.get(agent_alias)?;
3113 let profile_alias = agent.runtime_profile.trim();
3114 if profile_alias.is_empty() {
3115 return None;
3116 }
3117 self.runtime_profiles.get(profile_alias)
3118 }
3119
3120 #[must_use]
3134 pub fn model_provider_for_agent(&self, agent_alias: &str) -> Option<&ModelProviderConfig> {
3135 let agent = self.agents.get(agent_alias)?;
3136 let (type_key, alias_key) = agent.model_provider.split_once('.')?;
3137 self.providers.models.find(type_key, alias_key)
3138 }
3139
3140 #[must_use]
3148 pub fn resolved_model_provider_for_agent(
3149 &self,
3150 agent_alias: &str,
3151 ) -> Option<(&'static str, &str, &ModelProviderConfig)> {
3152 let agent = self.agents.get(agent_alias)?;
3153 let (type_key, alias_key) = agent.model_provider.split_once('.')?;
3154 self.providers
3155 .models
3156 .iter_entries()
3157 .find(|(ty, al, _)| *ty == type_key && *al == alias_key)
3158 }
3159
3160 #[must_use]
3166 pub fn agent_for_channel(&self, channel_alias: &str) -> Option<&str> {
3167 self.agents
3168 .iter()
3169 .find(|(_, agent)| agent.enabled && agent.channels.iter().any(|c| c == channel_alias))
3170 .map(|(alias, _)| alias.as_str())
3171 }
3172
3173 #[must_use]
3177 pub fn channel_workspace_dir(&self, channel_ref: &str) -> PathBuf {
3178 self.agent_for_channel(channel_ref)
3179 .map_or_else(|| self.data_dir.clone(), |a| self.agent_workspace_dir(a))
3180 }
3181
3182 #[must_use]
3188 pub fn channels_by_alias(&self) -> Vec<ChannelAliasInfo> {
3189 use std::collections::BTreeMap;
3190 let mut seen: BTreeMap<(String, String), bool> = BTreeMap::new();
3191 for field in self.prop_fields() {
3192 let parts: Vec<&str> = field.name.split('.').collect();
3193 if parts.len() < 4 || parts[0] != "channels" {
3194 continue;
3195 }
3196 let key = (parts[1].to_string(), parts[2].to_string());
3197 let entry = seen.entry(key).or_insert(false);
3198 if parts.len() == 4 && parts[3] == "enabled" {
3199 *entry = field.display_value == "true";
3200 }
3201 }
3202 seen.into_iter()
3203 .map(|((channel_type, alias), enabled)| {
3204 let composite = format!("{channel_type}.{alias}");
3205 let owning_agent = self.agent_for_channel(&composite).map(str::to_string);
3206 ChannelAliasInfo {
3207 channel_type,
3208 alias,
3209 owning_agent,
3210 enabled,
3211 }
3212 })
3213 .collect()
3214 }
3215
3216 #[must_use]
3225 pub fn agent_for_cron_job(&self, cron_alias: &str) -> Option<&str> {
3226 self.agents
3227 .iter()
3228 .find(|(_, agent)| agent.enabled && agent.cron_jobs.iter().any(|c| c == cron_alias))
3229 .map(|(alias, _)| alias.as_str())
3230 }
3231
3232 #[must_use]
3249 pub fn agent_workspace_dir(&self, agent_alias: &str) -> std::path::PathBuf {
3250 if let Some(cfg) = self.agents.get(agent_alias)
3251 && let Some(custom) = cfg.workspace.path.as_ref()
3252 {
3253 return custom.clone();
3254 }
3255 self.install_root_dir()
3256 .join("agents")
3257 .join(agent_alias)
3258 .join("workspace")
3259 }
3260
3261 #[must_use]
3267 pub fn shared_workspace_dir(&self) -> std::path::PathBuf {
3268 self.install_root_dir().join("shared")
3269 }
3270
3271 #[must_use]
3276 pub fn install_root_dir(&self) -> std::path::PathBuf {
3277 self.config_path
3278 .parent()
3279 .map(std::path::Path::to_path_buf)
3280 .unwrap_or_else(|| std::path::PathBuf::from("."))
3281 }
3282
3283 #[must_use]
3287 pub fn agent(&self, agent_alias: &str) -> Option<&AliasedAgentConfig> {
3288 self.agents.get(agent_alias)
3289 }
3290
3291 #[must_use]
3304 pub fn resolved_runtime_agent_alias(&self) -> Option<&str> {
3305 self.agents
3306 .keys()
3307 .find(|k| k.as_str() == "default")
3308 .map(String::as_str)
3309 .or_else(|| {
3310 self.agents
3311 .iter()
3312 .filter(|(_, a)| a.enabled)
3313 .map(|(alias, _)| alias.as_str())
3314 .min()
3315 })
3316 }
3317
3318 pub fn resolve_active_storage(&self) -> ActiveStorage<'_> {
3328 let backend = self.memory.backend.trim();
3329 if backend.is_empty() || backend.eq_ignore_ascii_case("none") {
3330 return ActiveStorage::None;
3331 }
3332 let (kind, alias) = backend.split_once('.').unwrap_or((backend, "default"));
3333 match kind {
3334 "sqlite" => self
3335 .storage
3336 .sqlite
3337 .get(alias)
3338 .map(ActiveStorage::Sqlite)
3339 .unwrap_or(ActiveStorage::None),
3340 "postgres" => self
3341 .storage
3342 .postgres
3343 .get(alias)
3344 .map(ActiveStorage::Postgres)
3345 .unwrap_or(ActiveStorage::None),
3346 "qdrant" => self
3347 .storage
3348 .qdrant
3349 .get(alias)
3350 .map(ActiveStorage::Qdrant)
3351 .unwrap_or(ActiveStorage::None),
3352 "markdown" => self
3353 .storage
3354 .markdown
3355 .get(alias)
3356 .map(ActiveStorage::Markdown)
3357 .unwrap_or(ActiveStorage::None),
3358 "lucid" => self
3359 .storage
3360 .lucid
3361 .get(alias)
3362 .map(ActiveStorage::Lucid)
3363 .unwrap_or(ActiveStorage::None),
3364 _ => ActiveStorage::None,
3365 }
3366 }
3367}
3368
3369#[derive(Debug, Clone, Copy)]
3374pub enum ActiveStorage<'a> {
3375 None,
3377 Sqlite(&'a SqliteStorageConfig),
3379 Postgres(&'a PostgresStorageConfig),
3381 Qdrant(&'a QdrantStorageConfig),
3383 Markdown(&'a MarkdownStorageConfig),
3385 Lucid(&'a LucidStorageConfig),
3387}
3388
3389impl ActiveStorage<'_> {
3390 #[must_use]
3392 pub fn kind(&self) -> &'static str {
3393 match self {
3394 ActiveStorage::None => "none",
3395 ActiveStorage::Sqlite(_) => "sqlite",
3396 ActiveStorage::Postgres(_) => "postgres",
3397 ActiveStorage::Qdrant(_) => "qdrant",
3398 ActiveStorage::Markdown(_) => "markdown",
3399 ActiveStorage::Lucid(_) => "lucid",
3400 }
3401 }
3402}
3403
3404fn default_delegate_timeout_secs() -> u64 {
3405 DEFAULT_DELEGATE_TIMEOUT_SECS
3406}
3407
3408fn default_delegate_agentic_timeout_secs() -> u64 {
3409 DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS
3410}
3411
3412pub const TEMPERATURE_RANGE: std::ops::RangeInclusive<f64> = 0.0..=2.0;
3414
3415fn default_schema_version() -> u32 {
3418 0
3419}
3420
3421pub const DEFAULT_DELEGATE_TIMEOUT_SECS: u64 = 120;
3423
3424pub const DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS: u64 = 300;
3426
3427pub fn validate_temperature(value: f64) -> std::result::Result<f64, String> {
3429 if TEMPERATURE_RANGE.contains(&value) {
3430 Ok(value)
3431 } else {
3432 Err(format!(
3433 "temperature {value} is out of range (expected {}..={})",
3434 TEMPERATURE_RANGE.start(),
3435 TEMPERATURE_RANGE.end()
3436 ))
3437 }
3438}
3439
3440fn normalize_reasoning_effort(value: &str) -> std::result::Result<String, String> {
3441 let normalized = value.trim().to_ascii_lowercase();
3442 match normalized.as_str() {
3443 "minimal" | "low" | "medium" | "high" | "xhigh" => Ok(normalized),
3444 _ => Err(format!(
3445 "reasoning_effort {value:?} is invalid (expected one of: minimal, low, medium, high, xhigh)"
3446 )),
3447 }
3448}
3449
3450fn deserialize_reasoning_effort_opt<'de, D>(
3451 deserializer: D,
3452) -> std::result::Result<Option<String>, D::Error>
3453where
3454 D: serde::Deserializer<'de>,
3455{
3456 let value: Option<String> = Option::deserialize(deserializer)?;
3457 value
3458 .map(|raw| normalize_reasoning_effort(&raw).map_err(serde::de::Error::custom))
3459 .transpose()
3460}
3461
3462fn deserialize_optional_email_skip_empty<'de, D>(
3470 deserializer: D,
3471) -> std::result::Result<Option<String>, D::Error>
3472where
3473 D: serde::Deserializer<'de>,
3474{
3475 let value: Option<String> = Option::deserialize(deserializer)?;
3476 Ok(value.filter(|s| !s.trim().is_empty()))
3477}
3478
3479#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
3483#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3484pub enum HardwareTransport {
3485 #[default]
3486 None,
3487 Native,
3488 Serial,
3489 Probe,
3490}
3491
3492impl std::fmt::Display for HardwareTransport {
3493 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3494 match self {
3495 Self::None => write!(f, "none"),
3496 Self::Native => write!(f, "native"),
3497 Self::Serial => write!(f, "serial"),
3498 Self::Probe => write!(f, "probe"),
3499 }
3500 }
3501}
3502
3503#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3505#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3506#[prefix = "hardware"]
3507pub struct HardwareConfig {
3508 #[serde(default)]
3510 pub enabled: bool,
3511 #[serde(default)]
3513 pub transport: HardwareTransport,
3514 #[serde(default)]
3516 pub serial_port: Option<String>,
3517 #[serde(default = "default_baud_rate")]
3519 pub baud_rate: u32,
3520 #[serde(default)]
3522 pub probe_target: Option<String>,
3523 #[serde(default)]
3525 pub workspace_datasheets: bool,
3526}
3527
3528fn default_baud_rate() -> u32 {
3529 115_200
3530}
3531
3532impl HardwareConfig {
3533 pub fn transport_mode(&self) -> HardwareTransport {
3535 self.transport.clone()
3536 }
3537}
3538
3539impl Default for HardwareConfig {
3540 fn default() -> Self {
3541 Self {
3542 enabled: false,
3543 transport: HardwareTransport::None,
3544 serial_port: None,
3545 baud_rate: default_baud_rate(),
3546 probe_target: None,
3547 workspace_datasheets: false,
3548 }
3549 }
3550}
3551
3552fn default_transcription_api_url() -> String {
3555 "https://api.groq.com/openai/v1/audio/transcriptions".into()
3556}
3557
3558fn default_transcription_model() -> String {
3559 "whisper-large-v3-turbo".into()
3560}
3561
3562fn default_transcription_max_duration_secs() -> u64 {
3563 120
3564}
3565
3566fn default_openai_stt_model() -> String {
3567 "whisper-1".into()
3568}
3569
3570fn default_deepgram_stt_model() -> String {
3571 "nova-2".into()
3572}
3573
3574fn default_google_stt_language_code() -> String {
3575 "en-US".into()
3576}
3577
3578#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3583#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3584#[prefix = "transcription"]
3585pub struct TranscriptionConfig {
3586 #[serde(default)]
3588 pub enabled: bool,
3589 #[serde(default)]
3593 #[secret]
3594 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
3595 pub api_key: Option<String>,
3596 #[serde(default = "default_transcription_api_url")]
3598 pub api_url: String,
3599 #[serde(default = "default_transcription_model")]
3601 pub model: String,
3602 #[serde(default)]
3604 pub language: Option<String>,
3605 #[serde(default)]
3609 pub initial_prompt: Option<String>,
3610 #[serde(default = "default_transcription_max_duration_secs")]
3612 pub max_duration_secs: u64,
3613 #[serde(default)]
3615 #[nested]
3616 pub openai: Option<OpenAiSttConfig>,
3617 #[serde(default)]
3619 #[nested]
3620 pub deepgram: Option<DeepgramSttConfig>,
3621 #[serde(default)]
3623 #[nested]
3624 pub assemblyai: Option<AssemblyAiSttConfig>,
3625 #[serde(default)]
3627 #[nested]
3628 pub google: Option<GoogleSttConfig>,
3629 #[serde(default)]
3631 #[nested]
3632 pub local_whisper: Option<LocalWhisperConfig>,
3633 #[serde(default)]
3636 pub transcribe_non_ptt_audio: bool,
3637}
3638
3639impl Default for TranscriptionConfig {
3640 fn default() -> Self {
3641 Self {
3642 enabled: false,
3643 api_key: None,
3644 api_url: default_transcription_api_url(),
3645 model: default_transcription_model(),
3646 language: None,
3647 initial_prompt: None,
3648 max_duration_secs: default_transcription_max_duration_secs(),
3649 openai: None,
3650 deepgram: None,
3651 assemblyai: None,
3652 google: None,
3653 local_whisper: None,
3654 transcribe_non_ptt_audio: false,
3655 }
3656 }
3657}
3658
3659#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
3663#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3664#[serde(rename_all = "lowercase")]
3665pub enum McpTransport {
3666 #[default]
3668 Stdio,
3669 Http,
3671 Sse,
3673}
3674
3675#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
3677#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3678#[prefix = "mcp.servers"]
3679pub struct McpServerConfig {
3680 #[serde(default)]
3685 pub name: String,
3686 #[serde(default)]
3688 pub transport: McpTransport,
3689 #[serde(default)]
3691 pub url: Option<String>,
3692 #[serde(default)]
3694 pub command: String,
3695 #[serde(default)]
3697 pub args: Vec<String>,
3698 #[serde(default)]
3700 pub env: HashMap<String, String>,
3701 #[serde(default)]
3704 #[secret]
3705 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
3706 pub headers: HashMap<String, String>,
3707 #[serde(default)]
3709 pub tool_timeout_secs: Option<u64>,
3710}
3711
3712#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3714#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3715#[prefix = "mcp"]
3716pub struct McpConfig {
3717 #[serde(default)]
3719 pub enabled: bool,
3720 #[serde(default = "default_deferred_loading")]
3725 pub deferred_loading: bool,
3726 #[serde(default, alias = "mcpServers")]
3732 #[nested]
3733 pub servers: Vec<McpServerConfig>,
3734}
3735
3736fn default_deferred_loading() -> bool {
3737 true
3738}
3739
3740impl Default for McpConfig {
3741 fn default() -> Self {
3742 Self {
3743 enabled: false,
3744 deferred_loading: default_deferred_loading(),
3745 servers: Vec::new(),
3746 }
3747 }
3748}
3749
3750#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3752#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3753#[prefix = "verifiable-intent"]
3754pub struct VerifiableIntentConfig {
3755 #[serde(default)]
3757 pub enabled: bool,
3758
3759 #[serde(default = "default_vi_strictness")]
3763 pub strictness: String,
3764}
3765
3766fn default_vi_strictness() -> String {
3767 "strict".to_owned()
3768}
3769
3770impl Default for VerifiableIntentConfig {
3771 fn default() -> Self {
3772 Self {
3773 enabled: false,
3774 strictness: default_vi_strictness(),
3775 }
3776 }
3777}
3778
3779#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3786#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3787#[prefix = "nodes"]
3788pub struct NodesConfig {
3789 #[serde(default)]
3791 pub enabled: bool,
3792 #[serde(default = "default_max_nodes")]
3794 pub max_nodes: usize,
3795 #[serde(default)]
3797 pub auth_token: Option<String>,
3798}
3799
3800fn default_max_nodes() -> usize {
3801 16
3802}
3803
3804impl Default for NodesConfig {
3805 fn default() -> Self {
3806 Self {
3807 enabled: false,
3808 max_nodes: default_max_nodes(),
3809 auth_token: None,
3810 }
3811 }
3812}
3813
3814fn default_tts_voice() -> String {
3817 "alloy".into()
3818}
3819
3820fn default_tts_format() -> String {
3821 "mp3".into()
3822}
3823
3824fn default_tts_max_text_length() -> usize {
3825 4096
3826}
3827
3828#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3834#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3835#[prefix = "tts"]
3836pub struct TtsConfig {
3837 #[serde(default)]
3839 pub enabled: bool,
3840 #[serde(default = "default_tts_voice")]
3842 pub default_voice: String,
3843 #[serde(default = "default_tts_format")]
3845 pub default_format: String,
3846 #[serde(default = "default_tts_max_text_length")]
3848 pub max_text_length: usize,
3849}
3850
3851impl Default for TtsConfig {
3852 fn default() -> Self {
3853 Self {
3854 enabled: false,
3855 default_voice: default_tts_voice(),
3856 default_format: default_tts_format(),
3857 max_text_length: default_tts_max_text_length(),
3858 }
3859 }
3860}
3861
3862#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
3869#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3870#[prefix = "tts-provider"]
3871#[serde(default)]
3872pub struct TtsProviderConfig {
3873 #[secret]
3875 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
3876 pub api_key: Option<String>,
3877 pub model: Option<String>,
3880 pub voice: Option<String>,
3883 pub speed: Option<f64>,
3885 pub stability: Option<f64>,
3887 pub similarity_boost: Option<f64>,
3889 pub language_code: Option<String>,
3891 pub binary_path: Option<String>,
3893 pub response_format: Option<String>,
3898 #[serde(alias = "api_url")]
3903 pub uri: Option<String>,
3904}
3905
3906pub trait TtsEndpoint {
3917 fn uri(&self) -> &'static str;
3918}
3919
3920#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3921#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3922#[serde(rename_all = "snake_case")]
3923pub enum OpenAITtsEndpoint {
3924 #[default]
3925 Default,
3926}
3927impl TtsEndpoint for OpenAITtsEndpoint {
3928 fn uri(&self) -> &'static str {
3929 match self {
3930 Self::Default => "https://api.openai.com/v1/audio/speech",
3931 }
3932 }
3933}
3934
3935#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
3936#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3937#[prefix = "providers.tts.openai"]
3938pub struct OpenAITtsProviderConfig {
3939 #[nested]
3940 #[serde(flatten)]
3941 pub base: TtsProviderConfig,
3942}
3943
3944#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3945#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3946#[serde(rename_all = "snake_case")]
3947pub enum ElevenLabsTtsEndpoint {
3948 #[default]
3949 Default,
3950}
3951impl TtsEndpoint for ElevenLabsTtsEndpoint {
3952 fn uri(&self) -> &'static str {
3953 match self {
3954 Self::Default => "https://api.elevenlabs.io/v1/text-to-speech",
3955 }
3956 }
3957}
3958
3959#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
3960#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3961#[prefix = "providers.tts.elevenlabs"]
3962pub struct ElevenLabsTtsProviderConfig {
3963 #[nested]
3964 #[serde(flatten)]
3965 pub base: TtsProviderConfig,
3966}
3967
3968#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3969#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3970#[serde(rename_all = "snake_case")]
3971pub enum GoogleTtsEndpoint {
3972 #[default]
3973 Default,
3974}
3975impl TtsEndpoint for GoogleTtsEndpoint {
3976 fn uri(&self) -> &'static str {
3977 match self {
3978 Self::Default => "https://texttospeech.googleapis.com/v1/text:synthesize",
3979 }
3980 }
3981}
3982
3983#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
3984#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3985#[prefix = "providers.tts.google"]
3986pub struct GoogleTtsProviderConfig {
3987 #[nested]
3988 #[serde(flatten)]
3989 pub base: TtsProviderConfig,
3990}
3991
3992#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3993#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3994#[serde(rename_all = "snake_case")]
3995pub enum EdgeTtsEndpoint {
3996 #[default]
3998 LocalSubprocess,
3999}
4000impl TtsEndpoint for EdgeTtsEndpoint {
4001 fn uri(&self) -> &'static str {
4002 match self {
4003 Self::LocalSubprocess => "subprocess://edge-tts",
4004 }
4005 }
4006}
4007
4008#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4009#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4010#[prefix = "providers.tts.edge"]
4011pub struct EdgeTtsProviderConfig {
4012 #[nested]
4013 #[serde(flatten)]
4014 pub base: TtsProviderConfig,
4015}
4016
4017#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4018#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4019#[serde(rename_all = "snake_case")]
4020pub enum PiperTtsEndpoint {
4021 #[default]
4022 LocalDefault,
4023}
4024impl TtsEndpoint for PiperTtsEndpoint {
4025 fn uri(&self) -> &'static str {
4026 match self {
4027 Self::LocalDefault => "http://127.0.0.1:5000/v1/audio/speech",
4028 }
4029 }
4030}
4031
4032#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4033#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4034#[prefix = "providers.tts.piper"]
4035pub struct PiperTtsProviderConfig {
4036 #[nested]
4037 #[serde(flatten)]
4038 pub base: TtsProviderConfig,
4039}
4040
4041#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4053#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4054#[prefix = "providers.transcription"]
4055pub struct TranscriptionProviderConfig {
4056 #[serde(default)]
4058 #[secret]
4059 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4060 pub api_key: Option<String>,
4061 #[serde(default)]
4065 pub language: Option<String>,
4066 #[serde(default)]
4070 pub initial_prompt: Option<String>,
4071}
4072
4073pub trait TranscriptionEndpoint {
4076 fn uri(&self) -> &'static str;
4077}
4078
4079#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4080#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4081#[serde(rename_all = "snake_case")]
4082pub enum GroqTranscriptionEndpoint {
4083 #[default]
4084 Default,
4085}
4086impl TranscriptionEndpoint for GroqTranscriptionEndpoint {
4087 fn uri(&self) -> &'static str {
4088 match self {
4089 Self::Default => "https://api.groq.com/openai/v1/audio/transcriptions",
4090 }
4091 }
4092}
4093
4094#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4095#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4096#[prefix = "providers.transcription.groq"]
4097pub struct GroqTranscriptionProviderConfig {
4098 #[nested]
4099 #[serde(flatten)]
4100 pub base: TranscriptionProviderConfig,
4101 #[serde(default)]
4103 pub model: Option<String>,
4104}
4105
4106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4107#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4108#[serde(rename_all = "snake_case")]
4109pub enum OpenAiTranscriptionEndpoint {
4110 #[default]
4111 Default,
4112}
4113impl TranscriptionEndpoint for OpenAiTranscriptionEndpoint {
4114 fn uri(&self) -> &'static str {
4115 match self {
4116 Self::Default => "https://api.openai.com/v1/audio/transcriptions",
4117 }
4118 }
4119}
4120
4121#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4122#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4123#[prefix = "providers.transcription.openai"]
4124pub struct OpenAiTranscriptionProviderConfig {
4125 #[nested]
4126 #[serde(flatten)]
4127 pub base: TranscriptionProviderConfig,
4128 #[serde(default)]
4130 pub model: Option<String>,
4131}
4132
4133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4134#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4135#[serde(rename_all = "snake_case")]
4136pub enum DeepgramTranscriptionEndpoint {
4137 #[default]
4138 Default,
4139}
4140impl TranscriptionEndpoint for DeepgramTranscriptionEndpoint {
4141 fn uri(&self) -> &'static str {
4142 match self {
4143 Self::Default => "https://api.deepgram.com/v1/listen",
4144 }
4145 }
4146}
4147
4148#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4149#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4150#[prefix = "providers.transcription.deepgram"]
4151pub struct DeepgramTranscriptionProviderConfig {
4152 #[nested]
4153 #[serde(flatten)]
4154 pub base: TranscriptionProviderConfig,
4155 #[serde(default)]
4157 pub model: Option<String>,
4158}
4159
4160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4161#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4162#[serde(rename_all = "snake_case")]
4163pub enum AssemblyAiTranscriptionEndpoint {
4164 #[default]
4165 Default,
4166}
4167impl TranscriptionEndpoint for AssemblyAiTranscriptionEndpoint {
4168 fn uri(&self) -> &'static str {
4169 match self {
4170 Self::Default => "https://api.assemblyai.com/v2/transcript",
4171 }
4172 }
4173}
4174
4175#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4176#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4177#[prefix = "providers.transcription.assemblyai"]
4178pub struct AssemblyAiTranscriptionProviderConfig {
4179 #[nested]
4180 #[serde(flatten)]
4181 pub base: TranscriptionProviderConfig,
4182}
4183
4184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4185#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4186#[serde(rename_all = "snake_case")]
4187pub enum GoogleTranscriptionEndpoint {
4188 #[default]
4189 Default,
4190}
4191impl TranscriptionEndpoint for GoogleTranscriptionEndpoint {
4192 fn uri(&self) -> &'static str {
4193 match self {
4194 Self::Default => "https://speech.googleapis.com/v1/speech:recognize",
4195 }
4196 }
4197}
4198
4199#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4200#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4201#[prefix = "providers.transcription.google"]
4202pub struct GoogleTranscriptionProviderConfig {
4203 #[nested]
4204 #[serde(flatten)]
4205 pub base: TranscriptionProviderConfig,
4206}
4207
4208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4209#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4210#[serde(rename_all = "snake_case")]
4211pub enum LocalWhisperTranscriptionEndpoint {
4212 #[default]
4215 SelfHosted,
4216}
4217impl TranscriptionEndpoint for LocalWhisperTranscriptionEndpoint {
4218 fn uri(&self) -> &'static str {
4219 match self {
4220 Self::SelfHosted => "self-hosted",
4221 }
4222 }
4223}
4224
4225#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4229#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4230#[prefix = "providers.transcription.local_whisper"]
4231pub struct LocalWhisperTranscriptionProviderConfig {
4232 pub uri: String,
4234 #[serde(default)]
4237 #[secret]
4238 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4239 pub bearer_token: Option<String>,
4240 #[serde(default)]
4242 pub language: Option<String>,
4243 #[serde(default = "default_local_whisper_max_audio_bytes")]
4246 pub max_audio_bytes: usize,
4247 #[serde(default = "default_local_whisper_timeout_secs")]
4249 pub timeout_secs: u64,
4250}
4251
4252#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
4254#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4255#[serde(rename_all = "snake_case")]
4256pub enum ToolFilterGroupMode {
4257 Always,
4259 #[default]
4262 Dynamic,
4263}
4264
4265#[derive(Debug, Clone, Serialize, Deserialize)]
4285#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4286pub struct ToolFilterGroup {
4287 #[serde(default)]
4289 pub mode: ToolFilterGroupMode,
4290 #[serde(default)]
4292 pub tools: Vec<String>,
4293 #[serde(default)]
4296 pub keywords: Vec<String>,
4297 #[serde(default)]
4299 pub filter_builtins: bool,
4300}
4301
4302#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4304#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4305#[prefix = "transcription.openai"]
4306pub struct OpenAiSttConfig {
4307 #[serde(default)]
4309 #[secret]
4310 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4311 pub api_key: Option<String>,
4312 #[serde(default = "default_openai_stt_model")]
4314 pub model: String,
4315}
4316
4317#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4319#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4320#[prefix = "transcription.deepgram"]
4321pub struct DeepgramSttConfig {
4322 #[serde(default)]
4324 #[secret]
4325 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4326 pub api_key: Option<String>,
4327 #[serde(default = "default_deepgram_stt_model")]
4329 pub model: String,
4330}
4331
4332#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4334#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4335#[prefix = "transcription.assemblyai"]
4336pub struct AssemblyAiSttConfig {
4337 #[serde(default)]
4339 #[secret]
4340 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4341 pub api_key: Option<String>,
4342}
4343
4344#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4346#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4347#[prefix = "transcription.google"]
4348pub struct GoogleSttConfig {
4349 #[serde(default)]
4351 #[secret]
4352 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4353 pub api_key: Option<String>,
4354 #[serde(default = "default_google_stt_language_code")]
4356 pub language_code: String,
4357}
4358
4359#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4363#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4364#[prefix = "transcription.local-whisper"]
4365pub struct LocalWhisperConfig {
4366 pub url: String,
4368 #[serde(default)]
4371 #[secret]
4372 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4373 pub bearer_token: Option<String>,
4374 #[serde(default = "default_local_whisper_max_audio_bytes")]
4380 pub max_audio_bytes: usize,
4381 #[serde(default = "default_local_whisper_timeout_secs")]
4383 pub timeout_secs: u64,
4384}
4385
4386fn default_local_whisper_max_audio_bytes() -> usize {
4387 25 * 1024 * 1024
4388}
4389
4390fn default_local_whisper_timeout_secs() -> u64 {
4391 300
4392}
4393
4394#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4401#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4402#[prefix = "delegate-agent.tool_receipts"]
4403pub struct ToolReceiptsConfig {
4404 #[serde(default)]
4408 pub enabled: bool,
4409 #[serde(default)]
4413 pub show_in_response: bool,
4414 #[serde(default = "default_inject_system_prompt")]
4418 pub inject_system_prompt: bool,
4419}
4420
4421fn default_inject_system_prompt() -> bool {
4422 true
4423}
4424
4425impl Default for ToolReceiptsConfig {
4426 fn default() -> Self {
4427 Self {
4428 enabled: false,
4429 show_in_response: false,
4430 inject_system_prompt: default_inject_system_prompt(),
4431 }
4432 }
4433}
4434
4435fn default_max_tool_result_chars() -> usize {
4436 50_000
4437}
4438
4439fn default_keep_tool_context_turns() -> usize {
4440 2
4441}
4442
4443fn default_agent_max_tool_iterations() -> usize {
4444 10
4445}
4446
4447fn default_agent_max_history_messages() -> usize {
4448 50
4449}
4450
4451fn default_agent_max_context_tokens() -> usize {
4452 32_000
4453}
4454
4455fn default_agent_tool_dispatcher() -> String {
4456 "auto".into()
4457}
4458
4459fn default_max_system_prompt_chars() -> usize {
4460 0
4461}
4462
4463#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4471#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4472#[prefix = "pacing"]
4473pub struct PacingConfig {
4474 #[serde(default)]
4478 pub step_timeout_secs: Option<u64>,
4479
4480 #[serde(default)]
4485 pub loop_detection_min_elapsed_secs: Option<u64>,
4486
4487 #[serde(default)]
4491 pub loop_ignore_tools: Vec<String>,
4492
4493 #[serde(default)]
4499 pub message_timeout_scale_max: Option<u64>,
4500
4501 #[serde(default = "default_loop_detection_enabled")]
4504 pub loop_detection_enabled: bool,
4505
4506 #[serde(default = "default_loop_detection_window_size")]
4509 pub loop_detection_window_size: usize,
4510
4511 #[serde(default = "default_loop_detection_max_repeats")]
4514 pub loop_detection_max_repeats: usize,
4515}
4516
4517fn default_loop_detection_enabled() -> bool {
4518 true
4519}
4520
4521fn default_loop_detection_window_size() -> usize {
4522 20
4523}
4524
4525fn default_loop_detection_max_repeats() -> usize {
4526 3
4527}
4528
4529impl Default for PacingConfig {
4530 fn default() -> Self {
4531 Self {
4532 step_timeout_secs: None,
4533 loop_detection_min_elapsed_secs: None,
4534 loop_ignore_tools: Vec::new(),
4535 message_timeout_scale_max: None,
4536 loop_detection_enabled: default_loop_detection_enabled(),
4537 loop_detection_window_size: default_loop_detection_window_size(),
4538 loop_detection_max_repeats: default_loop_detection_max_repeats(),
4539 }
4540 }
4541}
4542
4543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4545#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4546#[serde(rename_all = "snake_case")]
4547pub enum SkillsPromptInjectionMode {
4548 #[default]
4550 Full,
4551 Compact,
4553}
4554
4555#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
4557#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4558#[prefix = "skills"]
4559pub struct SkillsConfig {
4560 #[serde(default)]
4563 pub open_skills_enabled: bool,
4564 #[serde(default)]
4567 pub open_skills_dir: Option<String>,
4568 #[serde(default)]
4571 pub allow_scripts: bool,
4572 #[serde(default)]
4575 pub registry_url: Option<String>,
4576 #[serde(default)]
4579 pub prompt_injection_mode: SkillsPromptInjectionMode,
4580 #[serde(default)]
4582 #[nested]
4583 pub skill_creation: SkillCreationConfig,
4584 #[serde(default, alias = "install-suggestions")]
4586 #[nested]
4587 pub install_suggestions: SkillInstallSuggestionsConfig,
4588 #[serde(default)]
4590 #[nested]
4591 pub skill_improvement: SkillImprovementConfig,
4592}
4593
4594#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4596#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4597#[prefix = "skills.skill-creation"]
4598#[serde(default)]
4599pub struct SkillCreationConfig {
4600 pub enabled: bool,
4603 pub max_skills: usize,
4606 pub similarity_threshold: f64,
4609}
4610
4611impl Default for SkillCreationConfig {
4612 fn default() -> Self {
4613 Self {
4614 enabled: false,
4615 max_skills: 500,
4616 similarity_threshold: 0.85,
4617 }
4618 }
4619}
4620
4621#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
4623#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4624#[prefix = "skills.install-suggestions"]
4625#[serde(default)]
4626pub struct SkillInstallSuggestionsConfig {
4627 pub enabled: bool,
4630}
4631
4632#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4634#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4635#[prefix = "skills.skill-improvement"]
4636pub struct SkillImprovementConfig {
4637 #[serde(default = "default_true")]
4640 pub enabled: bool,
4641 #[serde(default = "default_skill_improvement_cooldown")]
4644 pub cooldown_secs: u64,
4645}
4646
4647fn default_skill_improvement_cooldown() -> u64 {
4648 3600
4649}
4650
4651impl Default for SkillImprovementConfig {
4652 fn default() -> Self {
4653 Self {
4654 enabled: true,
4655 cooldown_secs: 3600,
4656 }
4657 }
4658}
4659
4660#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4662#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4663#[prefix = "pipeline"]
4664pub struct PipelineConfig {
4665 #[serde(default)]
4668 pub enabled: bool,
4669 #[serde(default = "default_pipeline_max_steps")]
4672 pub max_steps: usize,
4673 #[serde(default)]
4676 pub allowed_tools: Vec<String>,
4677}
4678
4679fn default_pipeline_max_steps() -> usize {
4680 20
4681}
4682
4683impl Default for PipelineConfig {
4684 fn default() -> Self {
4685 Self {
4686 enabled: false,
4687 max_steps: 20,
4688 allowed_tools: Vec::new(),
4689 }
4690 }
4691}
4692
4693#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4708#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4709#[prefix = "multimodal"]
4710pub struct MultimodalConfig {
4711 #[serde(default = "default_multimodal_max_images")]
4719 pub max_images: usize,
4720 #[serde(default = "default_multimodal_max_image_size_mb")]
4722 pub max_image_size_mb: usize,
4723 #[serde(default)]
4725 pub allow_remote_fetch: bool,
4726 #[serde(default)]
4730 pub vision_model_provider: Option<String>,
4731 #[serde(default)]
4734 pub vision_model: Option<String>,
4735}
4736
4737fn default_multimodal_max_images() -> usize {
4738 4
4739}
4740
4741fn default_multimodal_max_image_size_mb() -> usize {
4742 5
4743}
4744
4745impl MultimodalConfig {
4746 pub fn effective_limits(&self) -> (usize, usize) {
4748 let max_images = self.max_images.clamp(1, 16);
4749 let max_image_size_mb = self.max_image_size_mb.clamp(1, 20);
4750 (max_images, max_image_size_mb)
4751 }
4752}
4753
4754impl Default for MultimodalConfig {
4755 fn default() -> Self {
4756 Self {
4757 max_images: default_multimodal_max_images(),
4758 max_image_size_mb: default_multimodal_max_image_size_mb(),
4759 allow_remote_fetch: false,
4760 vision_model_provider: None,
4761 vision_model: None,
4762 }
4763 }
4764}
4765
4766#[allow(clippy::struct_excessive_bools)]
4774#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4775#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4776#[prefix = "media-pipeline"]
4777pub struct MediaPipelineConfig {
4778 #[serde(default)]
4780 pub enabled: bool,
4781
4782 #[serde(default = "default_true")]
4784 pub transcribe_audio: bool,
4785
4786 #[serde(default = "default_true")]
4788 pub describe_images: bool,
4789
4790 #[serde(default = "default_true")]
4792 pub summarize_video: bool,
4793}
4794
4795impl Default for MediaPipelineConfig {
4796 fn default() -> Self {
4797 Self {
4798 enabled: false,
4799 transcribe_audio: true,
4800 describe_images: true,
4801 summarize_video: true,
4802 }
4803 }
4804}
4805
4806#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4812#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4813#[prefix = "identity"]
4814pub struct IdentityConfig {
4815 #[serde(default = "default_identity_format")]
4817 pub format: String,
4818 #[serde(default)]
4820 pub aieos_path: Option<String>,
4821 #[serde(default)]
4823 pub aieos_inline: Option<String>,
4824}
4825
4826fn default_identity_format() -> String {
4827 "openclaw".into()
4828}
4829
4830impl Default for IdentityConfig {
4831 fn default() -> Self {
4832 Self {
4833 format: default_identity_format(),
4834 aieos_path: None,
4835 aieos_inline: None,
4836 }
4837 }
4838}
4839
4840#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4844#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4845#[prefix = "cost"]
4846pub struct CostConfig {
4847 #[serde(default = "default_cost_enabled")]
4849 pub enabled: bool,
4850
4851 #[serde(default = "default_daily_limit")]
4853 pub daily_limit_usd: f64,
4854
4855 #[serde(default = "default_monthly_limit")]
4857 pub monthly_limit_usd: f64,
4858
4859 #[serde(default = "default_warn_percent")]
4861 pub warn_at_percent: u8,
4862
4863 #[serde(default)]
4865 pub allow_override: bool,
4866
4867 #[serde(default)]
4869 #[nested]
4870 pub enforcement: CostEnforcementConfig,
4871
4872 #[serde(default = "default_track_per_agent")]
4877 pub track_per_agent: bool,
4878
4879 #[serde(default)]
4900 #[nested]
4901 pub rates: CostRatesConfig,
4902}
4903
4904#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4906#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4907#[prefix = "cost.enforcement"]
4908pub struct CostEnforcementConfig {
4909 #[serde(default = "default_cost_enforcement_mode")]
4911 pub mode: String,
4912 #[serde(default)]
4914 pub route_down_model: Option<String>,
4915 #[serde(default = "default_reserve_percent")]
4917 pub reserve_percent: u8,
4918}
4919
4920fn default_cost_enforcement_mode() -> String {
4921 "warn".to_string()
4922}
4923
4924fn default_reserve_percent() -> u8 {
4925 10
4926}
4927
4928impl Default for CostEnforcementConfig {
4929 fn default() -> Self {
4930 Self {
4931 mode: default_cost_enforcement_mode(),
4932 route_down_model: None,
4933 reserve_percent: default_reserve_percent(),
4934 }
4935 }
4936}
4937
4938fn default_daily_limit() -> f64 {
4939 10.0
4940}
4941
4942fn default_monthly_limit() -> f64 {
4943 100.0
4944}
4945
4946fn default_warn_percent() -> u8 {
4947 80
4948}
4949
4950fn default_cost_enabled() -> bool {
4951 true
4952}
4953
4954fn default_track_per_agent() -> bool {
4955 true
4956}
4957
4958#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4962#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4963#[prefix = "cost.rates"]
4964pub struct CostRatesConfig {
4965 #[serde(default)]
4968 #[nested]
4969 pub providers: ProviderCostRates,
4970
4971 #[serde(default)]
4974 #[nested]
4975 #[resource_key]
4976 pub tools: std::collections::HashMap<String, ToolCostRates>,
4977}
4978
4979impl CostRatesConfig {
4980 #[must_use]
4983 pub fn model_rates(&self, provider_type: &str, model: &str) -> Option<&ModelCostRates> {
4984 self.providers.models.get(provider_type, model)
4985 }
4986
4987 #[must_use]
4989 pub fn tts_rates(&self, provider_type: &str, voice: &str) -> Option<&TtsCostRates> {
4990 self.providers.tts.get(provider_type, voice)
4991 }
4992
4993 #[must_use]
4995 pub fn transcription_rates(
4996 &self,
4997 provider_type: &str,
4998 model: &str,
4999 ) -> Option<&TranscriptionCostRates> {
5000 self.providers.transcription.get(provider_type, model)
5001 }
5002
5003 #[must_use]
5005 pub fn tool_rates(&self, tool_name: &str) -> Option<&ToolCostRates> {
5006 self.tools.get(tool_name)
5007 }
5008}
5009
5010#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5018#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5019#[prefix = "cost.rates.providers"]
5020pub struct ProviderCostRates {
5021 #[serde(default)]
5023 #[nested]
5024 pub models: crate::providers::ModelCostRatesByProvider,
5025 #[serde(default)]
5027 #[nested]
5028 pub tts: crate::providers::TtsCostRatesByProvider,
5029 #[serde(default)]
5031 #[nested]
5032 pub transcription: crate::providers::TranscriptionCostRatesByProvider,
5033}
5034
5035#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5039#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5040#[prefix = "cost.rates.providers.models"]
5041pub struct ModelCostRates {
5042 #[serde(default, skip_serializing_if = "Option::is_none")]
5044 pub input_per_mtok: Option<f64>,
5045 #[serde(default, skip_serializing_if = "Option::is_none")]
5047 pub output_per_mtok: Option<f64>,
5048 #[serde(default, skip_serializing_if = "Option::is_none")]
5051 pub cached_input_per_mtok: Option<f64>,
5052}
5053
5054#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5056#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5057#[prefix = "cost.rates.providers.tts"]
5058pub struct TtsCostRates {
5059 #[serde(default, skip_serializing_if = "Option::is_none")]
5061 pub per_mchar: Option<f64>,
5062}
5063
5064#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5066#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5067#[prefix = "cost.rates.providers.transcription"]
5068pub struct TranscriptionCostRates {
5069 #[serde(default, skip_serializing_if = "Option::is_none")]
5071 pub per_minute: Option<f64>,
5072}
5073
5074#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5077#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5078#[prefix = "cost.rates.tools"]
5079pub struct ToolCostRates {
5080 #[serde(default, skip_serializing_if = "Option::is_none")]
5082 pub per_call: Option<f64>,
5083}
5084
5085impl Default for CostConfig {
5086 fn default() -> Self {
5087 Self {
5088 enabled: true,
5089 daily_limit_usd: default_daily_limit(),
5090 monthly_limit_usd: default_monthly_limit(),
5091 warn_at_percent: default_warn_percent(),
5092 allow_override: false,
5093 enforcement: CostEnforcementConfig::default(),
5094 track_per_agent: default_track_per_agent(),
5095 rates: CostRatesConfig::default(),
5096 }
5097 }
5098}
5099
5100#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
5106#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5107#[prefix = "peripherals"]
5108pub struct PeripheralsConfig {
5109 #[serde(default)]
5111 pub enabled: bool,
5112 #[serde(default)]
5114 pub boards: Vec<PeripheralBoardConfig>,
5115 #[serde(default)]
5118 pub datasheet_dir: Option<String>,
5119}
5120
5121#[derive(Debug, Clone, Serialize, Deserialize)]
5123#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5124pub struct PeripheralBoardConfig {
5125 pub board: String,
5127 #[serde(default = "default_peripheral_transport")]
5129 pub transport: String,
5130 #[serde(default)]
5132 pub path: Option<String>,
5133 #[serde(default = "default_peripheral_baud")]
5135 pub baud: u32,
5136}
5137
5138fn default_peripheral_transport() -> String {
5139 "serial".into()
5140}
5141
5142fn default_peripheral_baud() -> u32 {
5143 115_200
5144}
5145
5146impl Default for PeripheralBoardConfig {
5147 fn default() -> Self {
5148 Self {
5149 board: String::new(),
5150 transport: default_peripheral_transport(),
5151 path: None,
5152 baud: default_peripheral_baud(),
5153 }
5154 }
5155}
5156
5157#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5163#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5164#[prefix = "gateway"]
5165#[allow(clippy::struct_excessive_bools)]
5166pub struct GatewayConfig {
5167 #[serde(default = "default_gateway_port")]
5169 pub port: u16,
5170 #[serde(default = "default_gateway_host")]
5172 pub host: String,
5173 #[serde(default = "default_true")]
5175 pub require_pairing: bool,
5176 #[serde(default)]
5178 pub allow_public_bind: bool,
5179 #[serde(default)]
5181 #[secret]
5182 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5183 pub paired_tokens: Vec<String>,
5184
5185 #[serde(default = "default_pair_rate_limit")]
5187 pub pair_rate_limit_per_minute: u32,
5188
5189 #[serde(default = "default_webhook_rate_limit")]
5191 pub webhook_rate_limit_per_minute: u32,
5192
5193 #[serde(default)]
5196 pub trust_forwarded_headers: bool,
5197
5198 #[serde(default)]
5202 pub path_prefix: Option<String>,
5203
5204 #[serde(default = "default_gateway_rate_limit_max_keys")]
5206 pub rate_limit_max_keys: usize,
5207
5208 #[serde(default = "default_idempotency_ttl_secs")]
5210 pub idempotency_ttl_secs: u64,
5211
5212 #[serde(default = "default_gateway_idempotency_max_keys")]
5214 pub idempotency_max_keys: usize,
5215
5216 #[serde(default = "default_true")]
5218 pub session_persistence: bool,
5219
5220 #[serde(default)]
5222 pub session_ttl_hours: u32,
5223
5224 #[serde(default)]
5226 #[nested]
5227 pub pairing_dashboard: PairingDashboardConfig,
5228
5229 #[serde(default)]
5235 pub web_dist_dir: Option<String>,
5236
5237 #[serde(default)]
5239 #[nested]
5240 pub tls: Option<GatewayTlsConfig>,
5241
5242 #[serde(default = "default_gateway_request_timeout_secs")]
5245 pub request_timeout_secs: u64,
5246
5247 #[serde(default = "default_gateway_long_running_request_timeout_secs")]
5251 pub long_running_request_timeout_secs: u64,
5252}
5253
5254fn default_gateway_port() -> u16 {
5255 42617
5256}
5257
5258fn default_gateway_request_timeout_secs() -> u64 {
5259 30
5260}
5261
5262fn default_gateway_long_running_request_timeout_secs() -> u64 {
5263 600
5264}
5265
5266fn default_gateway_host() -> String {
5267 "127.0.0.1".into()
5268}
5269
5270fn default_pair_rate_limit() -> u32 {
5271 10
5272}
5273
5274fn default_webhook_rate_limit() -> u32 {
5275 60
5276}
5277
5278fn default_idempotency_ttl_secs() -> u64 {
5279 300
5280}
5281
5282fn default_gateway_rate_limit_max_keys() -> usize {
5283 10_000
5284}
5285
5286fn default_gateway_idempotency_max_keys() -> usize {
5287 10_000
5288}
5289
5290fn default_true() -> bool {
5291 true
5292}
5293
5294fn default_false() -> bool {
5295 false
5296}
5297
5298impl Default for GatewayConfig {
5299 fn default() -> Self {
5300 Self {
5301 port: default_gateway_port(),
5302 host: default_gateway_host(),
5303 require_pairing: true,
5304 allow_public_bind: false,
5305 paired_tokens: Vec::new(),
5306 pair_rate_limit_per_minute: default_pair_rate_limit(),
5307 webhook_rate_limit_per_minute: default_webhook_rate_limit(),
5308 trust_forwarded_headers: false,
5309 path_prefix: None,
5310 rate_limit_max_keys: default_gateway_rate_limit_max_keys(),
5311 idempotency_ttl_secs: default_idempotency_ttl_secs(),
5312 idempotency_max_keys: default_gateway_idempotency_max_keys(),
5313 session_persistence: true,
5314 session_ttl_hours: 0,
5315 pairing_dashboard: PairingDashboardConfig::default(),
5316 web_dist_dir: None,
5317 tls: None,
5318 request_timeout_secs: default_gateway_request_timeout_secs(),
5319 long_running_request_timeout_secs: default_gateway_long_running_request_timeout_secs(),
5320 }
5321 }
5322}
5323
5324#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5326#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5327#[prefix = "gateway.pairing-dashboard"]
5328pub struct PairingDashboardConfig {
5329 #[serde(default = "default_pairing_code_length")]
5331 pub code_length: usize,
5332 #[serde(default = "default_pairing_ttl")]
5334 pub code_ttl_secs: u64,
5335 #[serde(default = "default_max_pending_codes")]
5337 pub max_pending_codes: usize,
5338 #[serde(default = "default_max_failed_attempts")]
5340 pub max_failed_attempts: u32,
5341 #[serde(default = "default_pairing_lockout_secs")]
5343 pub lockout_secs: u64,
5344}
5345
5346fn default_pairing_code_length() -> usize {
5347 8
5348}
5349fn default_pairing_ttl() -> u64 {
5350 3600
5351}
5352fn default_max_pending_codes() -> usize {
5353 3
5354}
5355fn default_max_failed_attempts() -> u32 {
5356 5
5357}
5358fn default_pairing_lockout_secs() -> u64 {
5359 300
5360}
5361
5362impl Default for PairingDashboardConfig {
5363 fn default() -> Self {
5364 Self {
5365 code_length: default_pairing_code_length(),
5366 code_ttl_secs: default_pairing_ttl(),
5367 max_pending_codes: default_max_pending_codes(),
5368 max_failed_attempts: default_max_failed_attempts(),
5369 lockout_secs: default_pairing_lockout_secs(),
5370 }
5371 }
5372}
5373
5374#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5376#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5377#[prefix = "gateway.tls"]
5378pub struct GatewayTlsConfig {
5379 #[serde(default)]
5381 pub enabled: bool,
5382 pub cert_path: String,
5384 pub key_path: String,
5386 #[serde(default)]
5388 #[nested]
5389 pub client_auth: Option<GatewayClientAuthConfig>,
5390}
5391
5392#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5394#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5395#[prefix = "gateway.tls.client-auth"]
5396pub struct GatewayClientAuthConfig {
5397 #[serde(default)]
5399 pub enabled: bool,
5400 #[serde(default)]
5402 pub ca_cert_path: String,
5403 #[serde(default = "default_true")]
5405 pub require_client_cert: bool,
5406 #[serde(default)]
5409 pub pinned_certs: Vec<String>,
5410}
5411
5412impl Default for GatewayClientAuthConfig {
5413 fn default() -> Self {
5414 Self {
5415 enabled: false,
5416 ca_cert_path: String::new(),
5417 require_client_cert: default_true(),
5418 pinned_certs: Vec::new(),
5419 }
5420 }
5421}
5422
5423#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5425#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5426#[prefix = "node-transport"]
5427pub struct NodeTransportConfig {
5428 #[serde(default = "default_node_transport_enabled")]
5430 pub enabled: bool,
5431 #[serde(default)]
5433 pub shared_secret: String,
5434 #[serde(default = "default_max_request_age")]
5436 pub max_request_age_secs: i64,
5437 #[serde(default = "default_require_https")]
5439 pub require_https: bool,
5440 #[serde(default)]
5442 pub allowed_peers: Vec<String>,
5443 #[serde(default)]
5445 pub tls_cert_path: Option<String>,
5446 #[serde(default)]
5448 pub tls_key_path: Option<String>,
5449 #[serde(default)]
5451 pub mutual_tls: bool,
5452 #[serde(default = "default_connection_pool_size")]
5454 pub connection_pool_size: usize,
5455}
5456
5457fn default_node_transport_enabled() -> bool {
5458 true
5459}
5460fn default_max_request_age() -> i64 {
5461 300
5462}
5463fn default_require_https() -> bool {
5464 true
5465}
5466fn default_connection_pool_size() -> usize {
5467 4
5468}
5469
5470impl Default for NodeTransportConfig {
5471 fn default() -> Self {
5472 Self {
5473 enabled: default_node_transport_enabled(),
5474 shared_secret: String::new(),
5475 max_request_age_secs: default_max_request_age(),
5476 require_https: default_require_https(),
5477 allowed_peers: Vec::new(),
5478 tls_cert_path: None,
5479 tls_key_path: None,
5480 mutual_tls: false,
5481 connection_pool_size: default_connection_pool_size(),
5482 }
5483 }
5484}
5485
5486#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5492#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5493#[prefix = "composio"]
5494pub struct ComposioConfig {
5495 #[serde(default, alias = "enable")]
5497 pub enabled: bool,
5498 #[serde(default)]
5500 #[secret]
5501 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5502 pub api_key: Option<String>,
5503 #[serde(default = "default_entity_id")]
5505 pub entity_id: String,
5506}
5507
5508fn default_entity_id() -> String {
5509 "default".into()
5510}
5511
5512impl Default for ComposioConfig {
5513 fn default() -> Self {
5514 Self {
5515 enabled: false,
5516 api_key: None,
5517 entity_id: default_entity_id(),
5518 }
5519 }
5520}
5521
5522#[derive(Clone, Serialize, Deserialize, Configurable)]
5529#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5530#[prefix = "ms365"]
5531pub struct Microsoft365Config {
5532 #[serde(default, alias = "enable")]
5534 pub enabled: bool,
5535 #[serde(default)]
5537 pub tenant_id: Option<String>,
5538 #[serde(default)]
5540 pub client_id: Option<String>,
5541 #[serde(default)]
5543 #[secret]
5544 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5545 pub client_secret: Option<String>,
5546 #[serde(default = "default_ms365_auth_flow")]
5548 pub auth_flow: String,
5549 #[serde(default = "default_ms365_scopes")]
5551 pub scopes: Vec<String>,
5552 #[serde(default = "default_true")]
5554 pub token_cache_encrypted: bool,
5555 #[serde(default)]
5557 pub user_id: Option<String>,
5558}
5559
5560fn default_ms365_auth_flow() -> String {
5561 "client_credentials".to_string()
5562}
5563
5564fn default_ms365_scopes() -> Vec<String> {
5565 vec!["https://graph.microsoft.com/.default".to_string()]
5566}
5567
5568impl std::fmt::Debug for Microsoft365Config {
5569 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5570 f.debug_struct("Microsoft365Config")
5571 .field("enabled", &self.enabled)
5572 .field("tenant_id", &self.tenant_id)
5573 .field("client_id", &self.client_id)
5574 .field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
5575 .field("auth_flow", &self.auth_flow)
5576 .field("scopes", &self.scopes)
5577 .field("token_cache_encrypted", &self.token_cache_encrypted)
5578 .field("user_id", &self.user_id)
5579 .finish()
5580 }
5581}
5582
5583impl Default for Microsoft365Config {
5584 fn default() -> Self {
5585 Self {
5586 enabled: false,
5587 tenant_id: None,
5588 client_id: None,
5589 client_secret: None,
5590 auth_flow: default_ms365_auth_flow(),
5591 scopes: default_ms365_scopes(),
5592 token_cache_encrypted: true,
5593 user_id: None,
5594 }
5595 }
5596}
5597
5598#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5602#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5603#[prefix = "secrets"]
5604pub struct SecretsConfig {
5605 #[serde(default = "default_true")]
5607 pub encrypt: bool,
5608}
5609
5610impl Default for SecretsConfig {
5611 fn default() -> Self {
5612 Self { encrypt: true }
5613 }
5614}
5615
5616#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5622#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5623#[prefix = "browser.computer-use"]
5624pub struct BrowserComputerUseConfig {
5625 #[serde(default = "default_browser_computer_use_endpoint")]
5627 pub endpoint: String,
5628 #[serde(default)]
5630 #[secret]
5631 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5632 pub api_key: Option<String>,
5633 #[serde(default = "default_browser_computer_use_timeout_ms")]
5635 pub timeout_ms: u64,
5636 #[serde(default)]
5638 pub allow_remote_endpoint: bool,
5639 #[serde(default)]
5641 pub window_allowlist: Vec<String>,
5642 #[serde(default)]
5644 pub max_coordinate_x: Option<i64>,
5645 #[serde(default)]
5647 pub max_coordinate_y: Option<i64>,
5648}
5649
5650fn default_browser_computer_use_endpoint() -> String {
5651 "http://127.0.0.1:8787/v1/actions".into()
5652}
5653
5654fn default_browser_computer_use_timeout_ms() -> u64 {
5655 15_000
5656}
5657
5658impl Default for BrowserComputerUseConfig {
5659 fn default() -> Self {
5660 Self {
5661 endpoint: default_browser_computer_use_endpoint(),
5662 api_key: None,
5663 timeout_ms: default_browser_computer_use_timeout_ms(),
5664 allow_remote_endpoint: false,
5665 window_allowlist: Vec::new(),
5666 max_coordinate_x: None,
5667 max_coordinate_y: None,
5668 }
5669 }
5670}
5671
5672#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5676#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5677#[prefix = "browser"]
5678#[integration(
5679 category = "ToolsAutomation",
5680 display_name = "Browser",
5681 description = "Chrome/Chromium control",
5682 status_field = "enabled"
5683)]
5684pub struct BrowserConfig {
5685 #[serde(default = "default_true")]
5687 pub enabled: bool,
5688 #[serde(default = "default_browser_allowed_domains")]
5690 pub allowed_domains: Vec<String>,
5691 #[serde(default)]
5693 pub session_name: Option<String>,
5694 #[serde(default = "default_browser_backend")]
5696 pub backend: String,
5697 #[serde(default)]
5699 pub headed: Option<bool>,
5700 #[serde(default = "default_true")]
5702 pub native_headless: bool,
5703 #[serde(default = "default_browser_webdriver_url")]
5705 pub native_webdriver_url: String,
5706 #[serde(default)]
5708 pub native_chrome_path: Option<String>,
5709 #[serde(default)]
5711 #[nested]
5712 pub computer_use: BrowserComputerUseConfig,
5713}
5714
5715fn default_browser_allowed_domains() -> Vec<String> {
5716 vec!["*".into()]
5717}
5718
5719fn default_browser_backend() -> String {
5720 "agent_browser".into()
5721}
5722
5723fn default_browser_webdriver_url() -> String {
5724 "http://127.0.0.1:9515".into()
5725}
5726
5727impl Default for BrowserConfig {
5728 fn default() -> Self {
5729 Self {
5730 enabled: true,
5731 allowed_domains: vec!["*".into()],
5732 session_name: None,
5733 backend: default_browser_backend(),
5734 headed: None,
5735 native_headless: default_true(),
5736 native_webdriver_url: default_browser_webdriver_url(),
5737 native_chrome_path: None,
5738 computer_use: BrowserComputerUseConfig::default(),
5739 }
5740 }
5741}
5742
5743#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5751#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5752#[prefix = "http-request"]
5753pub struct HttpRequestConfig {
5754 #[serde(default)]
5756 pub enabled: bool,
5757 #[serde(default)]
5759 pub allowed_domains: Vec<String>,
5760 #[serde(default = "default_http_max_response_size")]
5762 pub max_response_size: usize,
5763 #[serde(default = "default_http_timeout_secs")]
5765 pub timeout_secs: u64,
5766 #[serde(default)]
5769 pub allow_private_hosts: bool,
5770}
5771
5772impl Default for HttpRequestConfig {
5773 fn default() -> Self {
5774 Self {
5775 enabled: true,
5776 allowed_domains: vec!["*".into()],
5777 max_response_size: default_http_max_response_size(),
5778 timeout_secs: default_http_timeout_secs(),
5779 allow_private_hosts: false,
5780 }
5781 }
5782}
5783
5784fn default_http_max_response_size() -> usize {
5785 1_000_000 }
5787
5788fn default_http_timeout_secs() -> u64 {
5789 30
5790}
5791
5792#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5801#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5802#[prefix = "web-fetch"]
5803pub struct WebFetchConfig {
5804 #[serde(default)]
5806 pub enabled: bool,
5807 #[serde(default = "default_web_fetch_allowed_domains")]
5809 pub allowed_domains: Vec<String>,
5810 #[serde(default)]
5812 pub blocked_domains: Vec<String>,
5813 #[serde(default)]
5815 pub allowed_private_hosts: Vec<String>,
5816 #[serde(default = "default_web_fetch_max_response_size")]
5818 pub max_response_size: usize,
5819 #[serde(default = "default_web_fetch_timeout_secs")]
5821 pub timeout_secs: u64,
5822 #[serde(default)]
5824 #[nested]
5825 pub firecrawl: FirecrawlConfig,
5826}
5827
5828#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
5830#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5831#[serde(rename_all = "lowercase")]
5832pub enum FirecrawlMode {
5833 #[default]
5834 Scrape,
5835 Crawl,
5839}
5840
5841#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5847#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5848#[prefix = "web-fetch.firecrawl"]
5849pub struct FirecrawlConfig {
5850 #[serde(default)]
5852 pub enabled: bool,
5853 #[serde(default = "default_firecrawl_api_key_env")]
5855 pub api_key_env: String,
5856 #[serde(default = "default_firecrawl_api_url")]
5858 pub api_url: String,
5859 #[serde(default)]
5861 pub mode: FirecrawlMode,
5862}
5863
5864fn default_firecrawl_api_key_env() -> String {
5865 "FIRECRAWL_API_KEY".into()
5866}
5867
5868fn default_firecrawl_api_url() -> String {
5869 "https://api.firecrawl.dev/v1".into()
5870}
5871
5872impl Default for FirecrawlConfig {
5873 fn default() -> Self {
5874 Self {
5875 enabled: false,
5876 api_key_env: default_firecrawl_api_key_env(),
5877 api_url: default_firecrawl_api_url(),
5878 mode: FirecrawlMode::default(),
5879 }
5880 }
5881}
5882
5883fn default_web_fetch_max_response_size() -> usize {
5884 500_000 }
5886
5887fn default_web_fetch_timeout_secs() -> u64 {
5888 30
5889}
5890
5891fn default_web_fetch_allowed_domains() -> Vec<String> {
5892 vec!["*".into()]
5893}
5894
5895impl Default for WebFetchConfig {
5896 fn default() -> Self {
5897 Self {
5898 enabled: true,
5899 allowed_domains: vec!["*".into()],
5900 blocked_domains: vec![],
5901 allowed_private_hosts: vec![],
5902 max_response_size: default_web_fetch_max_response_size(),
5903 timeout_secs: default_web_fetch_timeout_secs(),
5904 firecrawl: FirecrawlConfig::default(),
5905 }
5906 }
5907}
5908
5909#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5918#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5919#[prefix = "link-enricher"]
5920pub struct LinkEnricherConfig {
5921 #[serde(default)]
5923 pub enabled: bool,
5924 #[serde(default = "default_link_enricher_max_links")]
5926 pub max_links: usize,
5927 #[serde(default = "default_link_enricher_timeout_secs")]
5929 pub timeout_secs: u64,
5930}
5931
5932fn default_link_enricher_max_links() -> usize {
5933 3
5934}
5935
5936fn default_link_enricher_timeout_secs() -> u64 {
5937 10
5938}
5939
5940impl Default for LinkEnricherConfig {
5941 fn default() -> Self {
5942 Self {
5943 enabled: false,
5944 max_links: default_link_enricher_max_links(),
5945 timeout_secs: default_link_enricher_timeout_secs(),
5946 }
5947 }
5948}
5949
5950#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5957#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5958#[prefix = "text-browser"]
5959pub struct TextBrowserConfig {
5960 #[serde(default)]
5962 pub enabled: bool,
5963 #[serde(default)]
5965 pub preferred_browser: Option<String>,
5966 #[serde(default = "default_text_browser_timeout_secs")]
5968 pub timeout_secs: u64,
5969}
5970
5971fn default_text_browser_timeout_secs() -> u64 {
5972 30
5973}
5974
5975impl Default for TextBrowserConfig {
5976 fn default() -> Self {
5977 Self {
5978 enabled: false,
5979 preferred_browser: None,
5980 timeout_secs: default_text_browser_timeout_secs(),
5981 }
5982 }
5983}
5984
5985#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5993#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5994#[prefix = "shell-tool"]
5995pub struct ShellToolConfig {
5996 #[serde(default = "default_shell_tool_timeout_secs")]
5998 pub timeout_secs: u64,
5999}
6000
6001fn default_shell_tool_timeout_secs() -> u64 {
6002 60
6003}
6004
6005impl Default for ShellToolConfig {
6006 fn default() -> Self {
6007 Self {
6008 timeout_secs: default_shell_tool_timeout_secs(),
6009 }
6010 }
6011}
6012
6013#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
6022#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6023#[prefix = "escalation"]
6024pub struct EscalationConfig {
6025 #[serde(default)]
6030 pub alert_channels: Vec<String>,
6031}
6032
6033#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6037#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6038#[prefix = "web-search"]
6039pub struct WebSearchConfig {
6040 #[serde(default)]
6042 pub enabled: bool,
6043 #[serde(default = "default_web_search_provider")]
6045 pub search_provider: String,
6046 #[serde(default)]
6048 #[secret]
6049 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6050 pub brave_api_key: Option<String>,
6051 #[serde(default)]
6053 #[secret]
6054 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6055 pub tavily_api_key: Option<String>,
6056 #[serde(default)]
6058 pub searxng_instance_url: Option<String>,
6059 #[serde(default = "default_web_search_max_results")]
6061 pub max_results: usize,
6062 #[serde(default = "default_web_search_timeout_secs")]
6064 pub timeout_secs: u64,
6065}
6066
6067fn default_web_search_provider() -> String {
6068 "duckduckgo".into()
6069}
6070
6071fn default_web_search_max_results() -> usize {
6072 5
6073}
6074
6075fn default_web_search_timeout_secs() -> u64 {
6076 15
6077}
6078
6079impl Default for WebSearchConfig {
6080 fn default() -> Self {
6081 Self {
6082 enabled: true,
6083 search_provider: default_web_search_provider(),
6084 brave_api_key: None,
6085 tavily_api_key: None,
6086 searxng_instance_url: None,
6087 max_results: default_web_search_max_results(),
6088 timeout_secs: default_web_search_timeout_secs(),
6089 }
6090 }
6091}
6092
6093#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6097#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6098#[prefix = "project-intel"]
6099pub struct ProjectIntelConfig {
6100 #[serde(default)]
6102 pub enabled: bool,
6103 #[serde(default = "default_project_intel_language")]
6105 pub default_language: String,
6106 #[serde(default = "default_project_intel_report_dir")]
6108 pub report_output_dir: String,
6109 #[serde(default)]
6111 pub templates_dir: Option<String>,
6112 #[serde(default = "default_project_intel_risk_sensitivity")]
6114 pub risk_sensitivity: String,
6115 #[serde(default = "default_true")]
6117 pub include_git_data: bool,
6118 #[serde(default)]
6120 pub include_jira_data: bool,
6121 #[serde(default)]
6123 pub jira_base_url: Option<String>,
6124}
6125
6126fn default_project_intel_language() -> String {
6127 "en".into()
6128}
6129
6130fn default_project_intel_report_dir() -> String {
6131 default_path_under_config_dir("project-reports")
6132}
6133
6134fn default_project_intel_risk_sensitivity() -> String {
6135 "medium".into()
6136}
6137
6138impl Default for ProjectIntelConfig {
6139 fn default() -> Self {
6140 Self {
6141 enabled: false,
6142 default_language: default_project_intel_language(),
6143 report_output_dir: default_project_intel_report_dir(),
6144 templates_dir: None,
6145 risk_sensitivity: default_project_intel_risk_sensitivity(),
6146 include_git_data: true,
6147 include_jira_data: false,
6148 jira_base_url: None,
6149 }
6150 }
6151}
6152
6153#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6157#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6158#[prefix = "backup"]
6159pub struct BackupConfig {
6160 #[serde(default = "default_true")]
6162 pub enabled: bool,
6163 #[serde(default = "default_backup_max_keep")]
6165 pub max_keep: usize,
6166 #[serde(default = "default_backup_include_dirs")]
6168 pub include_dirs: Vec<String>,
6169 #[serde(default = "default_backup_destination_dir")]
6171 pub destination_dir: String,
6172 #[serde(default)]
6174 pub schedule_cron: Option<String>,
6175 #[serde(default)]
6177 pub schedule_timezone: Option<String>,
6178 #[serde(default = "default_true")]
6180 pub compress: bool,
6181 #[serde(default)]
6183 pub encrypt: bool,
6184}
6185
6186fn default_backup_max_keep() -> usize {
6187 10
6188}
6189
6190fn default_backup_include_dirs() -> Vec<String> {
6191 vec![
6192 "config".into(),
6193 "memory".into(),
6194 "audit".into(),
6195 "knowledge".into(),
6196 ]
6197}
6198
6199fn default_backup_destination_dir() -> String {
6200 "state/backups".into()
6201}
6202
6203impl Default for BackupConfig {
6204 fn default() -> Self {
6205 Self {
6206 enabled: true,
6207 max_keep: default_backup_max_keep(),
6208 include_dirs: default_backup_include_dirs(),
6209 destination_dir: default_backup_destination_dir(),
6210 schedule_cron: None,
6211 schedule_timezone: None,
6212 compress: true,
6213 encrypt: false,
6214 }
6215 }
6216}
6217
6218#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6222#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6223#[prefix = "data-retention"]
6224pub struct DataRetentionConfig {
6225 #[serde(default)]
6227 pub enabled: bool,
6228 #[serde(default = "default_retention_days")]
6230 pub retention_days: u64,
6231 #[serde(default)]
6233 pub dry_run: bool,
6234 #[serde(default)]
6236 pub categories: Vec<String>,
6237}
6238
6239fn default_retention_days() -> u64 {
6240 90
6241}
6242
6243impl Default for DataRetentionConfig {
6244 fn default() -> Self {
6245 Self {
6246 enabled: false,
6247 retention_days: default_retention_days(),
6248 dry_run: false,
6249 categories: Vec::new(),
6250 }
6251 }
6252}
6253
6254pub const DEFAULT_GWS_SERVICES: &[&str] = &[
6263 "drive",
6264 "sheets",
6265 "gmail",
6266 "calendar",
6267 "docs",
6268 "slides",
6269 "tasks",
6270 "people",
6271 "chat",
6272 "classroom",
6273 "forms",
6274 "keep",
6275 "meet",
6276 "events",
6277];
6278
6279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6307#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6308pub struct GoogleWorkspaceAllowedOperation {
6309 pub service: String,
6311 pub resource: String,
6313 #[serde(default)]
6318 pub sub_resource: Option<String>,
6319 #[serde(default)]
6321 pub methods: Vec<String>,
6322}
6323
6324#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6349#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6350#[prefix = "google-workspace"]
6351#[integration(
6352 category = "ToolsAutomation",
6353 display_name = "Google Workspace",
6354 description = "Drive, Gmail, Calendar, Sheets, Docs via gws CLI",
6355 status_field = "enabled"
6356)]
6357pub struct GoogleWorkspaceConfig {
6358 #[serde(default)]
6360 pub enabled: bool,
6361 #[serde(default)]
6368 pub allowed_services: Vec<String>,
6369 #[serde(default)]
6382 pub allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
6383 #[serde(default)]
6389 pub credentials_path: Option<String>,
6390 #[serde(default)]
6394 pub default_account: Option<String>,
6395 #[serde(default = "default_gws_rate_limit")]
6397 pub rate_limit_per_minute: u32,
6398 #[serde(default = "default_gws_timeout_secs")]
6400 pub timeout_secs: u64,
6401 #[serde(default)]
6404 pub audit_log: bool,
6405}
6406
6407fn default_gws_rate_limit() -> u32 {
6408 60
6409}
6410
6411fn default_gws_timeout_secs() -> u64 {
6412 30
6413}
6414
6415impl Default for GoogleWorkspaceConfig {
6416 fn default() -> Self {
6417 Self {
6418 enabled: false,
6419 allowed_services: Vec::new(),
6420 allowed_operations: Vec::new(),
6421 credentials_path: None,
6422 default_account: None,
6423 rate_limit_per_minute: default_gws_rate_limit(),
6424 timeout_secs: default_gws_timeout_secs(),
6425 audit_log: false,
6426 }
6427 }
6428}
6429
6430#[allow(clippy::struct_excessive_bools)]
6434#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6435#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6436#[prefix = "knowledge"]
6437pub struct KnowledgeConfig {
6438 #[serde(default)]
6440 pub enabled: bool,
6441 #[serde(default = "default_knowledge_db_path")]
6443 pub db_path: String,
6444 #[serde(default = "default_knowledge_max_nodes")]
6446 pub max_nodes: usize,
6447 #[serde(default)]
6449 pub auto_capture: bool,
6450 #[serde(default = "default_true")]
6452 pub suggest_on_query: bool,
6453}
6454
6455fn default_knowledge_db_path() -> String {
6456 default_path_under_config_dir("knowledge.db")
6457}
6458
6459fn default_knowledge_max_nodes() -> usize {
6460 100_000
6461}
6462
6463impl Default for KnowledgeConfig {
6464 fn default() -> Self {
6465 Self {
6466 enabled: false,
6467 db_path: default_knowledge_db_path(),
6468 max_nodes: default_knowledge_max_nodes(),
6469 auto_capture: false,
6470 suggest_on_query: true,
6471 }
6472 }
6473}
6474
6475#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6482#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6483#[prefix = "linkedin"]
6484pub struct LinkedInConfig {
6485 #[serde(default)]
6487 pub enabled: bool,
6488
6489 #[serde(default = "default_linkedin_api_version")]
6491 pub api_version: String,
6492
6493 #[serde(default)]
6495 #[nested]
6496 pub content: LinkedInContentConfig,
6497
6498 #[serde(default)]
6500 #[nested]
6501 pub image: LinkedInImageConfig,
6502}
6503
6504impl Default for LinkedInConfig {
6505 fn default() -> Self {
6506 Self {
6507 enabled: false,
6508 api_version: default_linkedin_api_version(),
6509 content: LinkedInContentConfig::default(),
6510 image: LinkedInImageConfig::default(),
6511 }
6512 }
6513}
6514
6515fn default_linkedin_api_version() -> String {
6516 "202602".to_string()
6517}
6518
6519#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6521#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6522#[prefix = "plugins"]
6523pub struct PluginsConfig {
6524 #[serde(default)]
6526 pub enabled: bool,
6527 #[serde(default = "default_plugins_dir")]
6529 pub plugins_dir: String,
6530 #[serde(default)]
6532 pub auto_discover: bool,
6533 #[serde(default = "default_max_plugins")]
6535 pub max_plugins: usize,
6536 #[serde(default)]
6538 #[nested]
6539 pub security: PluginSecurityConfig,
6540}
6541
6542#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6549#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6550#[prefix = "plugins.security"]
6551pub struct PluginSecurityConfig {
6552 #[serde(default = "default_signature_mode")]
6554 pub signature_mode: String,
6555 #[serde(default)]
6557 pub trusted_publisher_keys: Vec<String>,
6558}
6559
6560fn default_signature_mode() -> String {
6561 "disabled".to_string()
6562}
6563
6564impl Default for PluginSecurityConfig {
6565 fn default() -> Self {
6566 Self {
6567 signature_mode: default_signature_mode(),
6568 trusted_publisher_keys: Vec::new(),
6569 }
6570 }
6571}
6572
6573fn default_plugins_dir() -> String {
6574 default_path_under_config_dir("plugins")
6575}
6576
6577fn default_max_plugins() -> usize {
6578 50
6579}
6580
6581impl Default for PluginsConfig {
6582 fn default() -> Self {
6583 Self {
6584 enabled: false,
6585 plugins_dir: default_plugins_dir(),
6586 auto_discover: false,
6587 max_plugins: default_max_plugins(),
6588 security: PluginSecurityConfig::default(),
6589 }
6590 }
6591}
6592
6593#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
6598#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6599#[prefix = "linkedin.content"]
6600pub struct LinkedInContentConfig {
6601 #[serde(default)]
6603 pub rss_feeds: Vec<String>,
6604
6605 #[serde(default)]
6607 pub github_users: Vec<String>,
6608
6609 #[serde(default)]
6611 pub github_repos: Vec<String>,
6612
6613 #[serde(default)]
6615 pub topics: Vec<String>,
6616
6617 #[serde(default)]
6619 pub persona: String,
6620
6621 #[serde(default)]
6623 pub instructions: String,
6624}
6625
6626#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6628#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6629#[prefix = "linkedin.image"]
6630pub struct LinkedInImageConfig {
6631 #[serde(default)]
6633 pub enabled: bool,
6634
6635 #[serde(default = "default_image_providers")]
6637 pub providers: Vec<String>,
6638
6639 #[serde(default = "default_true")]
6641 pub fallback_card: bool,
6642
6643 #[serde(default = "default_card_accent_color")]
6645 pub card_accent_color: String,
6646
6647 #[serde(default = "default_image_temp_dir")]
6649 pub temp_dir: String,
6650
6651 #[serde(default)]
6653 #[nested]
6654 pub stability: ImageProviderStabilityConfig,
6655
6656 #[serde(default)]
6658 #[nested]
6659 pub imagen: ImageProviderImagenConfig,
6660
6661 #[serde(default)]
6663 #[nested]
6664 pub dalle: ImageProviderDalleConfig,
6665
6666 #[serde(default)]
6668 #[nested]
6669 pub flux: ImageProviderFluxConfig,
6670}
6671
6672fn default_image_providers() -> Vec<String> {
6673 vec![
6674 "stability".into(),
6675 "imagen".into(),
6676 "dalle".into(),
6677 "flux".into(),
6678 ]
6679}
6680
6681fn default_card_accent_color() -> String {
6682 "#0A66C2".into()
6683}
6684
6685fn default_image_temp_dir() -> String {
6686 "linkedin/images".into()
6687}
6688
6689impl Default for LinkedInImageConfig {
6690 fn default() -> Self {
6691 Self {
6692 enabled: false,
6693 providers: default_image_providers(),
6694 fallback_card: true,
6695 card_accent_color: default_card_accent_color(),
6696 temp_dir: default_image_temp_dir(),
6697 stability: ImageProviderStabilityConfig::default(),
6698 imagen: ImageProviderImagenConfig::default(),
6699 dalle: ImageProviderDalleConfig::default(),
6700 flux: ImageProviderFluxConfig::default(),
6701 }
6702 }
6703}
6704
6705#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6707#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6708#[prefix = "linkedin.image.stability"]
6709pub struct ImageProviderStabilityConfig {
6710 #[serde(default = "default_stability_api_key_env")]
6712 pub api_key_env: String,
6713 #[serde(default = "default_stability_model")]
6715 pub model: String,
6716}
6717
6718fn default_stability_api_key_env() -> String {
6719 "STABILITY_API_KEY".into()
6720}
6721fn default_stability_model() -> String {
6722 "stable-diffusion-xl-1024-v1-0".into()
6723}
6724
6725impl Default for ImageProviderStabilityConfig {
6726 fn default() -> Self {
6727 Self {
6728 api_key_env: default_stability_api_key_env(),
6729 model: default_stability_model(),
6730 }
6731 }
6732}
6733
6734#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6736#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6737#[prefix = "linkedin.image.imagen"]
6738pub struct ImageProviderImagenConfig {
6739 #[serde(default = "default_imagen_api_key_env")]
6741 pub api_key_env: String,
6742 #[serde(default = "default_imagen_project_id_env")]
6744 pub project_id_env: String,
6745 #[serde(default = "default_imagen_region")]
6747 pub region: String,
6748}
6749
6750fn default_imagen_api_key_env() -> String {
6751 "GOOGLE_VERTEX_API_KEY".into()
6752}
6753fn default_imagen_project_id_env() -> String {
6754 "GOOGLE_CLOUD_PROJECT".into()
6755}
6756fn default_imagen_region() -> String {
6757 "us-central1".into()
6758}
6759
6760impl Default for ImageProviderImagenConfig {
6761 fn default() -> Self {
6762 Self {
6763 api_key_env: default_imagen_api_key_env(),
6764 project_id_env: default_imagen_project_id_env(),
6765 region: default_imagen_region(),
6766 }
6767 }
6768}
6769
6770#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6772#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6773#[prefix = "linkedin.image.dalle"]
6774pub struct ImageProviderDalleConfig {
6775 #[serde(default = "default_dalle_api_key_env")]
6777 pub api_key_env: String,
6778 #[serde(default = "default_dalle_model")]
6780 pub model: String,
6781 #[serde(default = "default_dalle_size")]
6783 pub size: String,
6784}
6785
6786fn default_dalle_api_key_env() -> String {
6787 "OPENAI_API_KEY".into()
6788}
6789fn default_dalle_model() -> String {
6790 "dall-e-3".into()
6791}
6792fn default_dalle_size() -> String {
6793 "1024x1024".into()
6794}
6795
6796impl Default for ImageProviderDalleConfig {
6797 fn default() -> Self {
6798 Self {
6799 api_key_env: default_dalle_api_key_env(),
6800 model: default_dalle_model(),
6801 size: default_dalle_size(),
6802 }
6803 }
6804}
6805
6806#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6808#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6809#[prefix = "linkedin.image.flux"]
6810pub struct ImageProviderFluxConfig {
6811 #[serde(default = "default_flux_api_key_env")]
6813 pub api_key_env: String,
6814 #[serde(default = "default_flux_model")]
6816 pub model: String,
6817}
6818
6819fn default_flux_api_key_env() -> String {
6820 "FAL_API_KEY".into()
6821}
6822fn default_flux_model() -> String {
6823 "fal-ai/flux/schnell".into()
6824}
6825
6826impl Default for ImageProviderFluxConfig {
6827 fn default() -> Self {
6828 Self {
6829 api_key_env: default_flux_api_key_env(),
6830 model: default_flux_model(),
6831 }
6832 }
6833}
6834
6835#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6843#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6844#[prefix = "image-gen"]
6845pub struct ImageGenConfig {
6846 #[serde(default)]
6848 pub enabled: bool,
6849
6850 #[serde(default = "default_image_gen_model")]
6852 pub default_model: String,
6853
6854 #[serde(default = "default_image_gen_api_key_env")]
6856 pub api_key_env: String,
6857}
6858
6859fn default_image_gen_model() -> String {
6860 "fal-ai/flux/schnell".into()
6861}
6862
6863fn default_image_gen_api_key_env() -> String {
6864 "FAL_API_KEY".into()
6865}
6866
6867impl Default for ImageGenConfig {
6868 fn default() -> Self {
6869 Self {
6870 enabled: false,
6871 default_model: default_image_gen_model(),
6872 api_key_env: default_image_gen_api_key_env(),
6873 }
6874 }
6875}
6876
6877#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6889#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6890#[prefix = "file-upload"]
6891pub struct FileUploadConfig {
6892 #[serde(default)]
6894 pub url: Option<String>,
6895
6896 #[serde(default = "default_file_upload_method")]
6898 pub method: String,
6899
6900 #[serde(default = "default_file_upload_field_name")]
6902 pub field_name: String,
6903
6904 #[serde(default = "default_file_upload_max_size_bytes")]
6907 pub max_file_size_bytes: u64,
6908
6909 #[serde(default = "default_file_upload_timeout_secs")]
6911 pub timeout_secs: u64,
6912
6913 #[serde(default)]
6916 pub headers: HashMap<String, String>,
6917}
6918
6919fn default_file_upload_method() -> String {
6920 "POST".into()
6921}
6922
6923fn default_file_upload_field_name() -> String {
6924 "file".into()
6925}
6926
6927fn default_file_upload_max_size_bytes() -> u64 {
6928 25 * 1024 * 1024
6929}
6930
6931fn default_file_upload_timeout_secs() -> u64 {
6932 60
6933}
6934
6935impl Default for FileUploadConfig {
6936 fn default() -> Self {
6937 Self {
6938 url: None,
6939 method: default_file_upload_method(),
6940 field_name: default_file_upload_field_name(),
6941 max_file_size_bytes: default_file_upload_max_size_bytes(),
6942 timeout_secs: default_file_upload_timeout_secs(),
6943 headers: HashMap::new(),
6944 }
6945 }
6946}
6947
6948#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6960#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6961#[prefix = "file-upload-bundle"]
6962pub struct FileUploadBundleConfig {
6963 #[serde(default)]
6965 pub url: Option<String>,
6966
6967 #[serde(default = "default_file_upload_bundle_method")]
6969 pub method: String,
6970
6971 #[serde(default = "default_file_upload_bundle_field_name")]
6973 pub field_name: String,
6974
6975 #[serde(default = "default_file_upload_bundle_max_file_size_bytes")]
6977 pub max_file_size_bytes: u64,
6978
6979 #[serde(default = "default_file_upload_bundle_max_total_size_bytes")]
6981 pub max_total_size_bytes: u64,
6982
6983 #[serde(default = "default_file_upload_bundle_max_files")]
6985 pub max_files: u32,
6986
6987 #[serde(default = "default_file_upload_bundle_timeout_secs")]
6989 pub timeout_secs: u64,
6990
6991 #[serde(default = "default_file_upload_bundle_max_response_body_bytes")]
6995 pub max_response_body_bytes: usize,
6996
6997 #[serde(default)]
6999 pub headers: HashMap<String, String>,
7000}
7001
7002fn default_file_upload_bundle_method() -> String {
7003 "POST".into()
7004}
7005
7006fn default_file_upload_bundle_field_name() -> String {
7007 "file".into()
7008}
7009
7010fn default_file_upload_bundle_max_file_size_bytes() -> u64 {
7011 10 * 1024 * 1024
7012}
7013
7014fn default_file_upload_bundle_max_total_size_bytes() -> u64 {
7015 32 * 1024 * 1024
7016}
7017
7018fn default_file_upload_bundle_max_files() -> u32 {
7019 16
7020}
7021
7022fn default_file_upload_bundle_timeout_secs() -> u64 {
7023 120
7024}
7025
7026fn default_file_upload_bundle_max_response_body_bytes() -> usize {
7027 4 * 1024
7028}
7029
7030impl Default for FileUploadBundleConfig {
7031 fn default() -> Self {
7032 Self {
7033 url: None,
7034 method: default_file_upload_bundle_method(),
7035 field_name: default_file_upload_bundle_field_name(),
7036 max_file_size_bytes: default_file_upload_bundle_max_file_size_bytes(),
7037 max_total_size_bytes: default_file_upload_bundle_max_total_size_bytes(),
7038 max_files: default_file_upload_bundle_max_files(),
7039 timeout_secs: default_file_upload_bundle_timeout_secs(),
7040 max_response_body_bytes: default_file_upload_bundle_max_response_body_bytes(),
7041 headers: HashMap::new(),
7042 }
7043 }
7044}
7045
7046#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7059#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7060#[prefix = "file-download"]
7061pub struct FileDownloadConfig {
7062 #[serde(default)]
7065 pub url: Option<String>,
7066
7067 #[serde(default = "default_file_download_max_size_bytes")]
7072 pub max_file_size_bytes: u64,
7073
7074 #[serde(default = "default_file_download_timeout_secs")]
7076 pub timeout_secs: u64,
7077
7078 #[serde(default)]
7082 pub headers: HashMap<String, String>,
7083}
7084
7085fn default_file_download_max_size_bytes() -> u64 {
7086 25 * 1024 * 1024
7087}
7088
7089fn default_file_download_timeout_secs() -> u64 {
7090 120
7091}
7092
7093impl Default for FileDownloadConfig {
7094 fn default() -> Self {
7095 Self {
7096 url: None,
7097 max_file_size_bytes: default_file_download_max_size_bytes(),
7098 timeout_secs: default_file_download_timeout_secs(),
7099 headers: HashMap::new(),
7100 }
7101 }
7102}
7103
7104#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7112#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7113#[prefix = "claude-code"]
7114pub struct ClaudeCodeConfig {
7115 #[serde(default)]
7117 pub enabled: bool,
7118 #[serde(default = "default_claude_code_timeout_secs")]
7120 pub timeout_secs: u64,
7121 #[serde(default = "default_claude_code_allowed_tools")]
7123 pub allowed_tools: Vec<String>,
7124 #[serde(default)]
7126 pub system_prompt: Option<String>,
7127 #[serde(default = "default_claude_code_max_output_bytes")]
7129 pub max_output_bytes: usize,
7130 #[serde(default)]
7132 pub env_passthrough: Vec<String>,
7133}
7134
7135fn default_claude_code_timeout_secs() -> u64 {
7136 600
7137}
7138
7139fn default_claude_code_allowed_tools() -> Vec<String> {
7140 vec!["Read".into(), "Edit".into(), "Bash".into(), "Write".into()]
7141}
7142
7143fn default_claude_code_max_output_bytes() -> usize {
7144 2_097_152
7145}
7146
7147impl Default for ClaudeCodeConfig {
7148 fn default() -> Self {
7149 Self {
7150 enabled: false,
7151 timeout_secs: default_claude_code_timeout_secs(),
7152 allowed_tools: default_claude_code_allowed_tools(),
7153 system_prompt: None,
7154 max_output_bytes: default_claude_code_max_output_bytes(),
7155 env_passthrough: Vec::new(),
7156 }
7157 }
7158}
7159
7160#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7168#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7169#[prefix = "claude-code-runner"]
7170pub struct ClaudeCodeRunnerConfig {
7171 #[serde(default)]
7173 pub enabled: bool,
7174 #[serde(default)]
7176 pub ssh_host: Option<String>,
7177 #[serde(default = "default_claude_code_runner_tmux_prefix")]
7179 pub tmux_prefix: String,
7180 #[serde(default = "default_claude_code_runner_session_ttl")]
7182 pub session_ttl: u64,
7183}
7184
7185fn default_claude_code_runner_tmux_prefix() -> String {
7186 "zc-claude-".into()
7187}
7188
7189fn default_claude_code_runner_session_ttl() -> u64 {
7190 3600
7191}
7192
7193impl Default for ClaudeCodeRunnerConfig {
7194 fn default() -> Self {
7195 Self {
7196 enabled: false,
7197 ssh_host: None,
7198 tmux_prefix: default_claude_code_runner_tmux_prefix(),
7199 session_ttl: default_claude_code_runner_session_ttl(),
7200 }
7201 }
7202}
7203
7204#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7212#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7213#[prefix = "codex-cli"]
7214pub struct CodexCliConfig {
7215 #[serde(default)]
7217 pub enabled: bool,
7218 #[serde(default = "default_codex_cli_timeout_secs")]
7220 pub timeout_secs: u64,
7221 #[serde(default = "default_codex_cli_max_output_bytes")]
7223 pub max_output_bytes: usize,
7224 #[serde(default)]
7226 pub env_passthrough: Vec<String>,
7227}
7228
7229fn default_codex_cli_timeout_secs() -> u64 {
7230 600
7231}
7232
7233fn default_codex_cli_max_output_bytes() -> usize {
7234 2_097_152
7235}
7236
7237impl Default for CodexCliConfig {
7238 fn default() -> Self {
7239 Self {
7240 enabled: false,
7241 timeout_secs: default_codex_cli_timeout_secs(),
7242 max_output_bytes: default_codex_cli_max_output_bytes(),
7243 env_passthrough: Vec::new(),
7244 }
7245 }
7246}
7247
7248#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7256#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7257#[prefix = "gemini-cli"]
7258pub struct GeminiCliConfig {
7259 #[serde(default)]
7261 pub enabled: bool,
7262 #[serde(default = "default_gemini_cli_timeout_secs")]
7264 pub timeout_secs: u64,
7265 #[serde(default = "default_gemini_cli_max_output_bytes")]
7267 pub max_output_bytes: usize,
7268 #[serde(default)]
7270 pub env_passthrough: Vec<String>,
7271}
7272
7273fn default_gemini_cli_timeout_secs() -> u64 {
7274 600
7275}
7276
7277fn default_gemini_cli_max_output_bytes() -> usize {
7278 2_097_152
7279}
7280
7281impl Default for GeminiCliConfig {
7282 fn default() -> Self {
7283 Self {
7284 enabled: false,
7285 timeout_secs: default_gemini_cli_timeout_secs(),
7286 max_output_bytes: default_gemini_cli_max_output_bytes(),
7287 env_passthrough: Vec::new(),
7288 }
7289 }
7290}
7291
7292#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7300#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7301#[prefix = "opencode-cli"]
7302pub struct OpenCodeCliConfig {
7303 #[serde(default)]
7305 pub enabled: bool,
7306 #[serde(default = "default_opencode_cli_timeout_secs")]
7308 pub timeout_secs: u64,
7309 #[serde(default = "default_opencode_cli_max_output_bytes")]
7311 pub max_output_bytes: usize,
7312 #[serde(default)]
7314 pub env_passthrough: Vec<String>,
7315}
7316
7317fn default_opencode_cli_timeout_secs() -> u64 {
7318 600
7319}
7320
7321fn default_opencode_cli_max_output_bytes() -> usize {
7322 2_097_152
7323}
7324
7325impl Default for OpenCodeCliConfig {
7326 fn default() -> Self {
7327 Self {
7328 enabled: false,
7329 timeout_secs: default_opencode_cli_timeout_secs(),
7330 max_output_bytes: default_opencode_cli_max_output_bytes(),
7331 env_passthrough: Vec::new(),
7332 }
7333 }
7334}
7335
7336#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
7340#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7341#[serde(rename_all = "snake_case")]
7342pub enum ProxyScope {
7343 Environment,
7345 #[default]
7347 Zeroclaw,
7348 Services,
7350}
7351
7352#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7354#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7355#[prefix = "proxy"]
7356pub struct ProxyConfig {
7357 #[serde(default)]
7359 pub enabled: bool,
7360 #[serde(default)]
7362 pub http_proxy: Option<String>,
7363 #[serde(default)]
7365 pub https_proxy: Option<String>,
7366 #[serde(default)]
7368 pub all_proxy: Option<String>,
7369 #[serde(default)]
7371 pub no_proxy: Vec<String>,
7372 #[serde(default)]
7374 pub scope: ProxyScope,
7375 #[serde(default)]
7377 pub services: Vec<String>,
7378}
7379
7380impl Default for ProxyConfig {
7381 fn default() -> Self {
7382 Self {
7383 enabled: false,
7384 http_proxy: None,
7385 https_proxy: None,
7386 all_proxy: None,
7387 no_proxy: Vec::new(),
7388 scope: ProxyScope::Zeroclaw,
7389 services: Vec::new(),
7390 }
7391 }
7392}
7393
7394impl ProxyConfig {
7395 pub fn supported_service_keys() -> &'static [&'static str] {
7396 SUPPORTED_PROXY_SERVICE_KEYS
7397 }
7398
7399 pub fn supported_service_selectors() -> &'static [&'static str] {
7400 SUPPORTED_PROXY_SERVICE_SELECTORS
7401 }
7402
7403 pub fn has_any_proxy_url(&self) -> bool {
7404 normalize_proxy_url_option(self.http_proxy.as_deref()).is_some()
7405 || normalize_proxy_url_option(self.https_proxy.as_deref()).is_some()
7406 || normalize_proxy_url_option(self.all_proxy.as_deref()).is_some()
7407 }
7408
7409 pub fn normalized_services(&self) -> Vec<String> {
7410 normalize_service_list(self.services.clone())
7411 }
7412
7413 pub fn normalized_no_proxy(&self) -> Vec<String> {
7414 normalize_no_proxy_list(self.no_proxy.clone())
7415 }
7416
7417 pub fn validate(&self) -> Result<()> {
7418 for (field, value) in [
7419 ("http_proxy", self.http_proxy.as_deref()),
7420 ("https_proxy", self.https_proxy.as_deref()),
7421 ("all_proxy", self.all_proxy.as_deref()),
7422 ] {
7423 if let Some(url) = normalize_proxy_url_option(value) {
7424 validate_proxy_url(field, &url)?;
7425 }
7426 }
7427
7428 for selector in self.normalized_services() {
7429 if !is_supported_proxy_service_selector(&selector) {
7430 anyhow::bail!(
7431 "Unsupported proxy service selector '{selector}'. Use tool `proxy_config` action `list_services` for valid values"
7432 );
7433 }
7434 }
7435
7436 if self.enabled && !self.has_any_proxy_url() {
7437 anyhow::bail!(
7438 "Proxy is enabled but no proxy URL is configured. Set at least one of http_proxy, https_proxy, or all_proxy"
7439 );
7440 }
7441
7442 if self.enabled
7443 && self.scope == ProxyScope::Services
7444 && self.normalized_services().is_empty()
7445 {
7446 anyhow::bail!(
7447 "proxy.scope='services' requires a non-empty proxy.services list when proxy is enabled"
7448 );
7449 }
7450
7451 Ok(())
7452 }
7453
7454 pub fn should_apply_to_service(&self, service_key: &str) -> bool {
7455 if !self.enabled {
7456 return false;
7457 }
7458
7459 match self.scope {
7460 ProxyScope::Environment => false,
7461 ProxyScope::Zeroclaw => true,
7462 ProxyScope::Services => {
7463 let service_key = service_key.trim().to_ascii_lowercase();
7464 if service_key.is_empty() {
7465 return false;
7466 }
7467
7468 self.normalized_services()
7469 .iter()
7470 .any(|selector| service_selector_matches(selector, &service_key))
7471 }
7472 }
7473 }
7474
7475 pub fn apply_to_reqwest_builder(
7476 &self,
7477 mut builder: reqwest::ClientBuilder,
7478 service_key: &str,
7479 ) -> reqwest::ClientBuilder {
7480 if !self.should_apply_to_service(service_key) {
7481 return builder;
7482 }
7483
7484 let no_proxy = self.no_proxy_value();
7485
7486 if let Some(url) = normalize_proxy_url_option(self.all_proxy.as_deref()) {
7487 match reqwest::Proxy::all(&url) {
7488 Ok(proxy) => {
7489 builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
7490 }
7491 Err(error) => {
7492 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid all_proxy URL: ");
7493 }
7494 }
7495 }
7496
7497 if let Some(url) = normalize_proxy_url_option(self.http_proxy.as_deref()) {
7498 match reqwest::Proxy::http(&url) {
7499 Ok(proxy) => {
7500 builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
7501 }
7502 Err(error) => {
7503 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid http_proxy URL: ");
7504 }
7505 }
7506 }
7507
7508 if let Some(url) = normalize_proxy_url_option(self.https_proxy.as_deref()) {
7509 match reqwest::Proxy::https(&url) {
7510 Ok(proxy) => {
7511 builder = builder.proxy(apply_no_proxy(proxy, no_proxy));
7512 }
7513 Err(error) => {
7514 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid https_proxy URL: ");
7515 }
7516 }
7517 }
7518
7519 builder
7520 }
7521
7522 pub fn apply_to_process_env(&self) {
7523 set_proxy_env_pair("HTTP_PROXY", self.http_proxy.as_deref());
7524 set_proxy_env_pair("HTTPS_PROXY", self.https_proxy.as_deref());
7525 set_proxy_env_pair("ALL_PROXY", self.all_proxy.as_deref());
7526
7527 let no_proxy_joined = {
7528 let list = self.normalized_no_proxy();
7529 (!list.is_empty()).then(|| list.join(","))
7530 };
7531 set_proxy_env_pair("NO_PROXY", no_proxy_joined.as_deref());
7532 }
7533
7534 pub fn clear_process_env() {
7535 clear_proxy_env_pair("HTTP_PROXY");
7536 clear_proxy_env_pair("HTTPS_PROXY");
7537 clear_proxy_env_pair("ALL_PROXY");
7538 clear_proxy_env_pair("NO_PROXY");
7539 }
7540
7541 fn no_proxy_value(&self) -> Option<reqwest::NoProxy> {
7542 let joined = {
7543 let list = self.normalized_no_proxy();
7544 (!list.is_empty()).then(|| list.join(","))
7545 };
7546 joined.as_deref().and_then(reqwest::NoProxy::from_string)
7547 }
7548}
7549
7550fn apply_no_proxy(proxy: reqwest::Proxy, no_proxy: Option<reqwest::NoProxy>) -> reqwest::Proxy {
7551 proxy.no_proxy(no_proxy)
7552}
7553
7554fn normalize_proxy_url_option(raw: Option<&str>) -> Option<String> {
7555 let value = raw?.trim();
7556 (!value.is_empty()).then(|| value.to_string())
7557}
7558
7559fn normalize_no_proxy_list(values: Vec<String>) -> Vec<String> {
7560 normalize_comma_values(values)
7561}
7562
7563fn normalize_service_list(values: Vec<String>) -> Vec<String> {
7564 let mut normalized = normalize_comma_values(values)
7565 .into_iter()
7566 .map(|value| value.to_ascii_lowercase())
7567 .collect::<Vec<_>>();
7568 normalized.sort_unstable();
7569 normalized.dedup();
7570 normalized
7571}
7572
7573fn normalize_comma_values(values: Vec<String>) -> Vec<String> {
7574 let mut output = Vec::new();
7575 for value in values {
7576 for part in value.split(',') {
7577 let normalized = part.trim();
7578 if normalized.is_empty() {
7579 continue;
7580 }
7581 output.push(normalized.to_string());
7582 }
7583 }
7584 output.sort_unstable();
7585 output.dedup();
7586 output
7587}
7588
7589fn is_supported_proxy_service_selector(selector: &str) -> bool {
7590 if SUPPORTED_PROXY_SERVICE_KEYS
7591 .iter()
7592 .any(|known| known.eq_ignore_ascii_case(selector))
7593 {
7594 return true;
7595 }
7596
7597 SUPPORTED_PROXY_SERVICE_SELECTORS
7598 .iter()
7599 .any(|known| known.eq_ignore_ascii_case(selector))
7600}
7601
7602fn service_selector_matches(selector: &str, service_key: &str) -> bool {
7603 if selector == service_key {
7604 return true;
7605 }
7606
7607 if let Some(prefix) = selector.strip_suffix(".*") {
7608 return service_key.starts_with(prefix)
7609 && service_key
7610 .strip_prefix(prefix)
7611 .is_some_and(|suffix| suffix.starts_with('.'));
7612 }
7613
7614 false
7615}
7616
7617const MCP_MAX_TOOL_TIMEOUT_SECS: u64 = 600;
7618
7619fn validate_mcp_config(config: &McpConfig) -> Result<()> {
7620 let mut seen_names = std::collections::HashSet::new();
7621 for (i, server) in config.servers.iter().enumerate() {
7622 let name = server.name.trim();
7623 if name.is_empty() {
7624 validation_bail!(
7625 RequiredFieldEmpty,
7626 format!("mcp.servers[{i}].name"),
7627 "mcp.servers[{i}].name must not be empty"
7628 );
7629 }
7630 if !seen_names.insert(name.to_ascii_lowercase()) {
7631 anyhow::bail!("mcp.servers contains duplicate name: {name}");
7632 }
7633
7634 if let Some(timeout) = server.tool_timeout_secs {
7635 if timeout == 0 {
7636 validation_bail!(
7637 InvalidNumericRange,
7638 format!("mcp.servers[{i}].tool_timeout_secs"),
7639 "mcp.servers[{i}].tool_timeout_secs must be greater than 0"
7640 );
7641 }
7642 if timeout > MCP_MAX_TOOL_TIMEOUT_SECS {
7643 anyhow::bail!(
7644 "mcp.servers[{i}].tool_timeout_secs exceeds max {MCP_MAX_TOOL_TIMEOUT_SECS}"
7645 );
7646 }
7647 }
7648
7649 match server.transport {
7650 McpTransport::Stdio => {
7651 if server.command.trim().is_empty() {
7652 anyhow::bail!(
7653 "mcp.servers[{i}] with transport=stdio requires non-empty command"
7654 );
7655 }
7656 }
7657 McpTransport::Http | McpTransport::Sse => {
7658 let url = server
7659 .url
7660 .as_deref()
7661 .map(str::trim)
7662 .filter(|value| !value.is_empty())
7663 .ok_or_else(|| {
7664 let transport_str = match server.transport {
7665 McpTransport::Http => "http",
7666 McpTransport::Sse => "sse",
7667 McpTransport::Stdio => "stdio",
7668 };
7669 ::zeroclaw_log::record!(
7670 WARN,
7671 ::zeroclaw_log::Event::new(
7672 module_path!(),
7673 ::zeroclaw_log::Action::Reject
7674 )
7675 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
7676 .with_attrs(::serde_json::json!({
7677 "index": i,
7678 "transport": transport_str,
7679 })),
7680 "mcp.servers entry rejected: transport requires url"
7681 );
7682 anyhow::Error::msg(format!(
7683 "mcp.servers[{i}] with transport={transport_str} requires url"
7684 ))
7685 })?;
7686 let parsed = reqwest::Url::parse(url)
7687 .with_context(|| format!("mcp.servers[{i}].url is not a valid URL"))?;
7688 if !matches!(parsed.scheme(), "http" | "https") {
7689 anyhow::bail!("mcp.servers[{i}].url must use http/https");
7690 }
7691 }
7692 }
7693 }
7694 Ok(())
7695}
7696
7697fn validate_proxy_url(field: &str, url: &str) -> Result<()> {
7698 let parsed = reqwest::Url::parse(url)
7699 .with_context(|| format!("Invalid {field} URL: '{url}' is not a valid URL"))?;
7700
7701 match parsed.scheme() {
7702 "http" | "https" | "socks5" | "socks5h" | "socks" => {}
7703 scheme => {
7704 anyhow::bail!(
7705 "Invalid {field} URL scheme '{scheme}'. Allowed: http, https, socks5, socks5h, socks"
7706 );
7707 }
7708 }
7709
7710 if parsed.host_str().is_none() {
7711 anyhow::bail!("Invalid {field} URL: host is required");
7712 }
7713
7714 Ok(())
7715}
7716
7717fn set_proxy_env_pair(key: &str, value: Option<&str>) {
7718 let lowercase_key = key.to_ascii_lowercase();
7719 if let Some(value) = value.and_then(|candidate| normalize_proxy_url_option(Some(candidate))) {
7720 unsafe {
7722 std::env::set_var(key, &value);
7723 std::env::set_var(lowercase_key, value);
7724 }
7725 } else {
7726 unsafe {
7728 std::env::remove_var(key);
7729 std::env::remove_var(lowercase_key);
7730 }
7731 }
7732}
7733
7734fn clear_proxy_env_pair(key: &str) {
7735 unsafe {
7737 std::env::remove_var(key);
7738 std::env::remove_var(key.to_ascii_lowercase());
7739 }
7740}
7741
7742fn runtime_proxy_state() -> &'static RwLock<ProxyConfig> {
7743 RUNTIME_PROXY_CONFIG.get_or_init(|| RwLock::new(ProxyConfig::default()))
7744}
7745
7746fn runtime_proxy_client_cache() -> &'static RwLock<HashMap<String, reqwest::Client>> {
7747 RUNTIME_PROXY_CLIENT_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
7748}
7749
7750fn clear_runtime_proxy_client_cache() {
7751 match runtime_proxy_client_cache().write() {
7752 Ok(mut guard) => {
7753 guard.clear();
7754 }
7755 Err(poisoned) => {
7756 poisoned.into_inner().clear();
7757 }
7758 }
7759}
7760
7761fn runtime_proxy_cache_key(
7762 service_key: &str,
7763 timeout_secs: Option<u64>,
7764 connect_timeout_secs: Option<u64>,
7765) -> String {
7766 format!(
7767 "{}|timeout={}|connect_timeout={}",
7768 service_key.trim().to_ascii_lowercase(),
7769 timeout_secs
7770 .map(|value| value.to_string())
7771 .unwrap_or_else(|| "none".to_string()),
7772 connect_timeout_secs
7773 .map(|value| value.to_string())
7774 .unwrap_or_else(|| "none".to_string())
7775 )
7776}
7777
7778fn runtime_proxy_cached_client(cache_key: &str) -> Option<reqwest::Client> {
7779 match runtime_proxy_client_cache().read() {
7780 Ok(guard) => guard.get(cache_key).cloned(),
7781 Err(poisoned) => poisoned.into_inner().get(cache_key).cloned(),
7782 }
7783}
7784
7785fn set_runtime_proxy_cached_client(cache_key: String, client: reqwest::Client) {
7786 match runtime_proxy_client_cache().write() {
7787 Ok(mut guard) => {
7788 guard.insert(cache_key, client);
7789 }
7790 Err(poisoned) => {
7791 poisoned.into_inner().insert(cache_key, client);
7792 }
7793 }
7794}
7795
7796pub fn set_runtime_proxy_config(config: ProxyConfig) {
7797 match runtime_proxy_state().write() {
7798 Ok(mut guard) => {
7799 *guard = config;
7800 }
7801 Err(poisoned) => {
7802 *poisoned.into_inner() = config;
7803 }
7804 }
7805
7806 clear_runtime_proxy_client_cache();
7807}
7808
7809pub fn runtime_proxy_config() -> ProxyConfig {
7810 match runtime_proxy_state().read() {
7811 Ok(guard) => guard.clone(),
7812 Err(poisoned) => poisoned.into_inner().clone(),
7813 }
7814}
7815
7816pub fn apply_runtime_proxy_to_builder(
7817 builder: reqwest::ClientBuilder,
7818 service_key: &str,
7819) -> reqwest::ClientBuilder {
7820 runtime_proxy_config().apply_to_reqwest_builder(builder, service_key)
7821}
7822
7823pub fn build_runtime_proxy_client(service_key: &str) -> reqwest::Client {
7824 let cache_key = runtime_proxy_cache_key(service_key, None, None);
7825 if let Some(client) = runtime_proxy_cached_client(&cache_key) {
7826 return client;
7827 }
7828
7829 let builder = apply_runtime_proxy_to_builder(reqwest::Client::builder(), service_key);
7830 let client = builder.build().unwrap_or_else(|error| {
7831 ::zeroclaw_log::record!(
7832 WARN,
7833 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7834 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
7835 .with_attrs(
7836 ::serde_json::json!({"service_key": service_key, "error": format!("{}", error)})
7837 ),
7838 "Failed to build proxied client: "
7839 );
7840 reqwest::Client::new()
7841 });
7842 set_runtime_proxy_cached_client(cache_key, client.clone());
7843 client
7844}
7845
7846pub fn build_runtime_proxy_client_with_timeouts(
7847 service_key: &str,
7848 timeout_secs: u64,
7849 connect_timeout_secs: u64,
7850) -> reqwest::Client {
7851 let cache_key =
7852 runtime_proxy_cache_key(service_key, Some(timeout_secs), Some(connect_timeout_secs));
7853 if let Some(client) = runtime_proxy_cached_client(&cache_key) {
7854 return client;
7855 }
7856
7857 let builder = reqwest::Client::builder()
7858 .timeout(std::time::Duration::from_secs(timeout_secs))
7859 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs));
7860 let builder = apply_runtime_proxy_to_builder(builder, service_key);
7861 let client = builder.build().unwrap_or_else(|error| {
7862 ::zeroclaw_log::record!(
7863 WARN,
7864 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7865 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
7866 .with_attrs(
7867 ::serde_json::json!({"service_key": service_key, "error": format!("{}", error)})
7868 ),
7869 "Failed to build proxied timeout client: "
7870 );
7871 reqwest::Client::new()
7872 });
7873 set_runtime_proxy_cached_client(cache_key, client.clone());
7874 client
7875}
7876
7877pub fn build_channel_proxy_client(service_key: &str, proxy_url: Option<&str>) -> reqwest::Client {
7881 match normalize_proxy_url_option(proxy_url) {
7882 Some(url) => build_explicit_proxy_client(service_key, &url, None, None),
7883 None => build_runtime_proxy_client(service_key),
7884 }
7885}
7886
7887pub fn build_channel_proxy_client_with_timeouts(
7891 service_key: &str,
7892 proxy_url: Option<&str>,
7893 timeout_secs: u64,
7894 connect_timeout_secs: u64,
7895) -> reqwest::Client {
7896 match normalize_proxy_url_option(proxy_url) {
7897 Some(url) => build_explicit_proxy_client(
7898 service_key,
7899 &url,
7900 Some(timeout_secs),
7901 Some(connect_timeout_secs),
7902 ),
7903 None => build_runtime_proxy_client_with_timeouts(
7904 service_key,
7905 timeout_secs,
7906 connect_timeout_secs,
7907 ),
7908 }
7909}
7910
7911pub fn apply_channel_proxy_to_builder(
7914 builder: reqwest::ClientBuilder,
7915 service_key: &str,
7916 proxy_url: Option<&str>,
7917) -> reqwest::ClientBuilder {
7918 match normalize_proxy_url_option(proxy_url) {
7919 Some(url) => apply_explicit_proxy_to_builder(builder, service_key, &url),
7920 None => apply_runtime_proxy_to_builder(builder, service_key),
7921 }
7922}
7923
7924fn build_explicit_proxy_client(
7926 service_key: &str,
7927 proxy_url: &str,
7928 timeout_secs: Option<u64>,
7929 connect_timeout_secs: Option<u64>,
7930) -> reqwest::Client {
7931 let cache_key = format!(
7932 "explicit|{}|{}|timeout={}|connect_timeout={}",
7933 service_key.trim().to_ascii_lowercase(),
7934 proxy_url,
7935 timeout_secs
7936 .map(|v| v.to_string())
7937 .unwrap_or_else(|| "none".to_string()),
7938 connect_timeout_secs
7939 .map(|v| v.to_string())
7940 .unwrap_or_else(|| "none".to_string()),
7941 );
7942 if let Some(client) = runtime_proxy_cached_client(&cache_key) {
7943 return client;
7944 }
7945
7946 let mut builder = reqwest::Client::builder();
7947 if let Some(t) = timeout_secs {
7948 builder = builder.timeout(std::time::Duration::from_secs(t));
7949 }
7950 if let Some(ct) = connect_timeout_secs {
7951 builder = builder.connect_timeout(std::time::Duration::from_secs(ct));
7952 }
7953 builder = apply_explicit_proxy_to_builder(builder, service_key, proxy_url);
7954 let client = builder.build().unwrap_or_else(|error| {
7955 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"service_key": service_key, "proxy_url": proxy_url, "error": format!("{}", error)})), "Failed to build channel proxy client: ");
7956 reqwest::Client::new()
7957 });
7958 set_runtime_proxy_cached_client(cache_key, client.clone());
7959 client
7960}
7961
7962fn apply_explicit_proxy_to_builder(
7964 mut builder: reqwest::ClientBuilder,
7965 service_key: &str,
7966 proxy_url: &str,
7967) -> reqwest::ClientBuilder {
7968 match reqwest::Proxy::all(proxy_url) {
7969 Ok(proxy) => {
7970 builder = builder.proxy(proxy);
7971 }
7972 Err(error) => {
7973 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": proxy_url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid channel proxy_url: ");
7974 }
7975 }
7976 builder
7977}
7978
7979trait AsyncReadWrite: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
7990impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send> AsyncReadWrite for T {}
7991
7992pub struct BoxedIo(Box<dyn AsyncReadWrite>);
8000
8001impl tokio::io::AsyncRead for BoxedIo {
8002 fn poll_read(
8003 mut self: std::pin::Pin<&mut Self>,
8004 cx: &mut std::task::Context<'_>,
8005 buf: &mut tokio::io::ReadBuf<'_>,
8006 ) -> std::task::Poll<std::io::Result<()>> {
8007 std::pin::Pin::new(&mut *self.0).poll_read(cx, buf)
8008 }
8009}
8010
8011impl tokio::io::AsyncWrite for BoxedIo {
8012 fn poll_write(
8013 mut self: std::pin::Pin<&mut Self>,
8014 cx: &mut std::task::Context<'_>,
8015 buf: &[u8],
8016 ) -> std::task::Poll<std::io::Result<usize>> {
8017 std::pin::Pin::new(&mut *self.0).poll_write(cx, buf)
8018 }
8019
8020 fn poll_flush(
8021 mut self: std::pin::Pin<&mut Self>,
8022 cx: &mut std::task::Context<'_>,
8023 ) -> std::task::Poll<std::io::Result<()>> {
8024 std::pin::Pin::new(&mut *self.0).poll_flush(cx)
8025 }
8026
8027 fn poll_shutdown(
8028 mut self: std::pin::Pin<&mut Self>,
8029 cx: &mut std::task::Context<'_>,
8030 ) -> std::task::Poll<std::io::Result<()>> {
8031 std::pin::Pin::new(&mut *self.0).poll_shutdown(cx)
8032 }
8033}
8034
8035impl Unpin for BoxedIo {}
8036
8037pub type ProxiedWsStream = tokio_tungstenite::WebSocketStream<BoxedIo>;
8040
8041fn resolve_ws_proxy_url(
8045 service_key: &str,
8046 ws_url: &str,
8047 channel_proxy_url: Option<&str>,
8048) -> Option<String> {
8049 if let Some(url) = normalize_proxy_url_option(channel_proxy_url) {
8051 return Some(url);
8052 }
8053
8054 let cfg = runtime_proxy_config();
8056 if !cfg.should_apply_to_service(service_key) {
8057 return None;
8058 }
8059
8060 if let Ok(parsed) = reqwest::Url::parse(ws_url)
8062 && let Some(host) = parsed.host_str()
8063 {
8064 let no_proxy_entries = cfg.normalized_no_proxy();
8065 if !no_proxy_entries.is_empty() {
8066 let host_lower = host.to_ascii_lowercase();
8067 let matches_no_proxy = no_proxy_entries.iter().any(|entry| {
8068 let entry = entry.trim().to_ascii_lowercase();
8069 if entry == "*" {
8070 return true;
8071 }
8072 if host_lower == entry {
8073 return true;
8074 }
8075 if let Some(suffix) = entry.strip_prefix('.') {
8077 return host_lower.ends_with(suffix) || host_lower == suffix;
8078 }
8079 host_lower.ends_with(&format!(".{entry}"))
8081 });
8082 if matches_no_proxy {
8083 return None;
8084 }
8085 }
8086 }
8087
8088 let is_secure = ws_url.starts_with("wss://") || ws_url.starts_with("wss:");
8091 let preferred = if is_secure {
8092 normalize_proxy_url_option(cfg.https_proxy.as_deref())
8093 } else {
8094 normalize_proxy_url_option(cfg.http_proxy.as_deref())
8095 };
8096 preferred.or_else(|| normalize_proxy_url_option(cfg.all_proxy.as_deref()))
8097}
8098
8099pub async fn ws_connect_with_proxy(
8109 ws_url: &str,
8110 service_key: &str,
8111 channel_proxy_url: Option<&str>,
8112) -> anyhow::Result<(
8113 ProxiedWsStream,
8114 tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
8115)> {
8116 let proxy_url = resolve_ws_proxy_url(service_key, ws_url, channel_proxy_url);
8117
8118 match proxy_url {
8119 None => {
8120 use tokio::net::TcpStream;
8130
8131 let target = reqwest::Url::parse(ws_url)
8132 .with_context(|| format!("Invalid WebSocket URL: {ws_url}"))?;
8133 let target_host = target
8134 .host_str()
8135 .ok_or_else(|| {
8136 ::zeroclaw_log::record!(
8137 WARN,
8138 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
8139 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
8140 .with_attrs(::serde_json::json!({"ws_url": ws_url})),
8141 "WebSocket URL has no host"
8142 );
8143 anyhow::Error::msg(format!("WebSocket URL has no host: {ws_url}"))
8144 })?
8145 .to_string();
8146 let target_port = target
8147 .port_or_known_default()
8148 .unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });
8149
8150 let tcp = TcpStream::connect(format!("{target_host}:{target_port}"))
8151 .await
8152 .with_context(|| format!("TCP connect to {target_host}:{target_port}"))?;
8153
8154 let is_secure = target.scheme() == "wss";
8155 let stream: BoxedIo = if is_secure {
8156 let mut root_store = rustls::RootCertStore::empty();
8157 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
8158 let tls_config = std::sync::Arc::new(
8159 rustls::ClientConfig::builder()
8160 .with_root_certificates(root_store)
8161 .with_no_client_auth(),
8162 );
8163 let connector = tokio_rustls::TlsConnector::from(tls_config);
8164 let server_name = rustls_pki_types::ServerName::try_from(target_host.clone())
8165 .with_context(|| format!("Invalid TLS server name: {target_host}"))?;
8166 let tls_stream = connector
8167 .connect(server_name, tcp)
8168 .await
8169 .with_context(|| format!("TLS handshake with {target_host}"))?;
8170 BoxedIo(Box::new(tls_stream))
8171 } else {
8172 BoxedIo(Box::new(tcp))
8173 };
8174
8175 let default_port = if is_secure { 443 } else { 80 };
8176 let host_header = if target_port == default_port {
8177 target_host.clone()
8178 } else {
8179 format!("{target_host}:{target_port}")
8180 };
8181
8182 let ws_request = tokio_tungstenite::tungstenite::http::Request::builder()
8183 .uri(ws_url)
8184 .header("Host", host_header)
8185 .header("Connection", "Upgrade")
8186 .header("Upgrade", "websocket")
8187 .header(
8188 "Sec-WebSocket-Key",
8189 tokio_tungstenite::tungstenite::handshake::client::generate_key(),
8190 )
8191 .header("Sec-WebSocket-Version", "13")
8192 .body(())
8193 .with_context(|| "Failed to build WebSocket upgrade request")?;
8194
8195 let (ws_stream, response) =
8196 tokio_tungstenite::client_async(ws_request, stream)
8197 .await
8198 .with_context(|| format!("WebSocket handshake failed for {ws_url}"))?;
8199
8200 Ok((ws_stream, response))
8201 }
8202 Some(proxy) => ws_connect_via_proxy(ws_url, &proxy).await,
8203 }
8204}
8205
8206async fn ws_connect_via_proxy(
8208 ws_url: &str,
8209 proxy_url: &str,
8210) -> anyhow::Result<(
8211 ProxiedWsStream,
8212 tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
8213)> {
8214 use tokio::io::{AsyncReadExt, AsyncWriteExt as _};
8215 use tokio::net::TcpStream;
8216
8217 let target =
8218 reqwest::Url::parse(ws_url).with_context(|| format!("Invalid WebSocket URL: {ws_url}"))?;
8219 let target_host = target
8220 .host_str()
8221 .ok_or_else(|| {
8222 ::zeroclaw_log::record!(
8223 WARN,
8224 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
8225 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
8226 .with_attrs(::serde_json::json!({"ws_url": ws_url})),
8227 "WebSocket URL has no host"
8228 );
8229 anyhow::Error::msg(format!("WebSocket URL has no host: {ws_url}"))
8230 })?
8231 .to_string();
8232 let target_port = target
8233 .port_or_known_default()
8234 .unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });
8235
8236 let proxy = reqwest::Url::parse(proxy_url)
8237 .with_context(|| format!("Invalid proxy URL: {proxy_url}"))?;
8238
8239 let stream: BoxedIo = match proxy.scheme() {
8240 "socks5" | "socks5h" | "socks" => {
8241 let proxy_addr = format!(
8242 "{}:{}",
8243 proxy.host_str().unwrap_or("127.0.0.1"),
8244 proxy.port_or_known_default().unwrap_or(1080)
8245 );
8246 let target_addr = format!("{target_host}:{target_port}");
8247 let socks_stream = if proxy.username().is_empty() {
8248 tokio_socks::tcp::Socks5Stream::connect(proxy_addr.as_str(), target_addr.as_str())
8249 .await
8250 .with_context(|| format!("SOCKS5 connect to {target_addr} via {proxy_addr}"))?
8251 } else {
8252 let password = proxy.password().unwrap_or("");
8253 tokio_socks::tcp::Socks5Stream::connect_with_password(
8254 proxy_addr.as_str(),
8255 target_addr.as_str(),
8256 proxy.username(),
8257 password,
8258 )
8259 .await
8260 .with_context(|| format!("SOCKS5 auth connect to {target_addr} via {proxy_addr}"))?
8261 };
8262 let tcp: TcpStream = socks_stream.into_inner();
8263 BoxedIo(Box::new(tcp))
8264 }
8265 "http" | "https" => {
8266 let proxy_host = proxy.host_str().unwrap_or("127.0.0.1");
8267 let proxy_port = proxy.port_or_known_default().unwrap_or(8080);
8268 let proxy_addr = format!("{proxy_host}:{proxy_port}");
8269
8270 let mut tcp = TcpStream::connect(&proxy_addr)
8271 .await
8272 .with_context(|| format!("TCP connect to HTTP proxy {proxy_addr}"))?;
8273
8274 let connect_req = format!(
8276 "CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n\r\n"
8277 );
8278 tcp.write_all(connect_req.as_bytes()).await?;
8279
8280 let mut buf = vec![0u8; 4096];
8282 let mut total = 0usize;
8283 loop {
8284 let n = tcp.read(&mut buf[total..]).await?;
8285 if n == 0 {
8286 anyhow::bail!("HTTP CONNECT proxy closed connection before response");
8287 }
8288 total += n;
8289 if let Some(pos) = find_header_end(&buf[..total]) {
8291 let status_line = std::str::from_utf8(&buf[..pos])
8292 .unwrap_or("")
8293 .lines()
8294 .next()
8295 .unwrap_or("");
8296 if !status_line.contains("200") {
8297 anyhow::bail!(
8298 "HTTP CONNECT proxy returned non-200 response: {status_line}"
8299 );
8300 }
8301 break;
8302 }
8303 if total >= buf.len() {
8304 anyhow::bail!("HTTP CONNECT proxy response too large");
8305 }
8306 }
8307
8308 BoxedIo(Box::new(tcp))
8309 }
8310 scheme => {
8311 anyhow::bail!("Unsupported proxy scheme '{scheme}' for WebSocket connections");
8312 }
8313 };
8314
8315 let is_secure = target.scheme() == "wss";
8317 let stream: BoxedIo = if is_secure {
8318 let mut root_store = rustls::RootCertStore::empty();
8319 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
8320 let tls_config = std::sync::Arc::new(
8321 rustls::ClientConfig::builder()
8322 .with_root_certificates(root_store)
8323 .with_no_client_auth(),
8324 );
8325 let connector = tokio_rustls::TlsConnector::from(tls_config);
8326 let server_name = rustls_pki_types::ServerName::try_from(target_host.clone())
8327 .with_context(|| format!("Invalid TLS server name: {target_host}"))?;
8328
8329 let tls_stream = connector
8333 .connect(server_name, stream)
8334 .await
8335 .with_context(|| format!("TLS handshake with {target_host}"))?;
8336 BoxedIo(Box::new(tls_stream))
8337 } else {
8338 stream
8339 };
8340
8341 let ws_request = tokio_tungstenite::tungstenite::http::Request::builder()
8343 .uri(ws_url)
8344 .header("Host", format!("{target_host}:{target_port}"))
8345 .header("Connection", "Upgrade")
8346 .header("Upgrade", "websocket")
8347 .header(
8348 "Sec-WebSocket-Key",
8349 tokio_tungstenite::tungstenite::handshake::client::generate_key(),
8350 )
8351 .header("Sec-WebSocket-Version", "13")
8352 .body(())
8353 .with_context(|| "Failed to build WebSocket upgrade request")?;
8354
8355 let (ws_stream, response) = tokio_tungstenite::client_async(ws_request, stream)
8356 .await
8357 .with_context(|| format!("WebSocket handshake failed for {ws_url}"))?;
8358
8359 Ok((ws_stream, response))
8360}
8361
8362fn find_header_end(buf: &[u8]) -> Option<usize> {
8364 buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
8365}
8366
8367#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
8377#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8378#[prefix = "storage"]
8379pub struct StorageConfig {
8380 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8382 #[nested]
8383 pub sqlite: HashMap<String, SqliteStorageConfig>,
8384 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8386 #[nested]
8387 pub postgres: HashMap<String, PostgresStorageConfig>,
8388 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8390 #[nested]
8391 pub qdrant: HashMap<String, QdrantStorageConfig>,
8392 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8394 #[nested]
8395 pub markdown: HashMap<String, MarkdownStorageConfig>,
8396 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8398 #[nested]
8399 pub lucid: HashMap<String, LucidStorageConfig>,
8400}
8401
8402#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
8404#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8405#[prefix = "storage-sqlite"]
8406#[serde(default)]
8407pub struct SqliteStorageConfig {
8408 pub path: Option<String>,
8411 pub open_timeout_secs: Option<u64>,
8414}
8415
8416#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8421#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8422#[prefix = "storage-postgres"]
8423#[serde(default)]
8424pub struct PostgresStorageConfig {
8425 #[serde(alias = "dbURL", alias = "database_url", alias = "databaseUrl")]
8428 #[secret]
8429 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
8430 pub db_url: Option<String>,
8431 pub schema: String,
8433 pub table: String,
8435 pub connect_timeout_secs: Option<u64>,
8437 pub vector_enabled: bool,
8439 pub vector_dimensions: usize,
8441}
8442
8443impl Default for PostgresStorageConfig {
8444 fn default() -> Self {
8445 Self {
8446 db_url: None,
8447 schema: default_storage_schema(),
8448 table: default_storage_table(),
8449 connect_timeout_secs: None,
8450 vector_enabled: false,
8451 vector_dimensions: default_pgvector_dimensions(),
8452 }
8453 }
8454}
8455
8456#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8461#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8462#[prefix = "storage-qdrant"]
8463#[serde(default)]
8464pub struct QdrantStorageConfig {
8465 pub url: Option<String>,
8468 pub collection: String,
8471 #[secret]
8474 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
8475 pub api_key: Option<String>,
8476}
8477
8478impl Default for QdrantStorageConfig {
8479 fn default() -> Self {
8480 Self {
8481 url: None,
8482 collection: default_qdrant_collection(),
8483 api_key: None,
8484 }
8485 }
8486}
8487
8488#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
8490#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8491#[prefix = "storage-markdown"]
8492#[serde(default)]
8493pub struct MarkdownStorageConfig {
8494 pub directory: Option<String>,
8497}
8498
8499#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
8501#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8502#[prefix = "storage-lucid"]
8503#[serde(default)]
8504pub struct LucidStorageConfig {
8505 pub binary_path: Option<String>,
8507}
8508
8509fn default_storage_schema() -> String {
8510 "public".into()
8511}
8512
8513fn default_storage_table() -> String {
8514 "memories".into()
8515}
8516
8517fn default_qdrant_collection() -> String {
8518 "zeroclaw_memories".into()
8519}
8520
8521#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
8523#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8524#[serde(rename_all = "snake_case")]
8525pub enum SearchMode {
8526 Bm25,
8528 Embedding,
8530 #[default]
8532 Hybrid,
8533}
8534
8535#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8542#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8543#[prefix = "memory"]
8544#[allow(clippy::struct_excessive_bools)]
8545pub struct MemoryConfig {
8546 pub backend: String,
8552 #[serde(default = "default_auto_save")]
8554 pub auto_save: bool,
8555 #[serde(default = "default_hygiene_enabled")]
8557 pub hygiene_enabled: bool,
8558 #[serde(default = "default_archive_after_days")]
8560 pub archive_after_days: u32,
8561 #[serde(default = "default_purge_after_days")]
8563 pub purge_after_days: u32,
8564 #[serde(default = "default_conversation_retention_days")]
8566 pub conversation_retention_days: u32,
8567 #[serde(default = "default_embedding_provider")]
8569 pub embedding_provider: String,
8570 #[serde(default = "default_embedding_model")]
8572 pub embedding_model: String,
8573 #[serde(default = "default_embedding_dims")]
8575 pub embedding_dimensions: usize,
8576 #[serde(default = "default_vector_weight")]
8578 pub vector_weight: f64,
8579 #[serde(default = "default_keyword_weight")]
8581 pub keyword_weight: f64,
8582 #[serde(default)]
8584 pub search_mode: SearchMode,
8585 #[serde(default = "default_min_relevance_score")]
8589 pub min_relevance_score: f64,
8590 #[serde(default = "default_cache_size")]
8592 pub embedding_cache_size: usize,
8593 #[serde(default = "default_chunk_size")]
8595 pub chunk_max_tokens: usize,
8596
8597 #[serde(default)]
8600 pub response_cache_enabled: bool,
8601 #[serde(default = "default_response_cache_ttl")]
8603 pub response_cache_ttl_minutes: u32,
8604 #[serde(default = "default_response_cache_max")]
8606 pub response_cache_max_entries: usize,
8607 #[serde(default = "default_response_cache_hot_entries")]
8609 pub response_cache_hot_entries: usize,
8610
8611 #[serde(default)]
8614 pub snapshot_enabled: bool,
8615 #[serde(default)]
8617 pub snapshot_on_hygiene: bool,
8618 #[serde(default = "default_true")]
8620 pub auto_hydrate: bool,
8621
8622 #[serde(default = "default_retrieval_stages")]
8625 pub retrieval_stages: Vec<String>,
8626 #[serde(default)]
8628 pub rerank_enabled: bool,
8629 #[serde(default = "default_rerank_threshold")]
8631 pub rerank_threshold: usize,
8632 #[serde(default = "default_fts_early_return_score")]
8634 pub fts_early_return_score: f64,
8635
8636 #[serde(default = "default_namespace")]
8639 pub default_namespace: String,
8640
8641 #[serde(default = "default_conflict_threshold")]
8644 pub conflict_threshold: f64,
8645
8646 #[serde(default)]
8649 pub audit_enabled: bool,
8650 #[serde(default = "default_audit_retention_days")]
8652 pub audit_retention_days: u32,
8653
8654 #[serde(default)]
8657 #[nested]
8658 pub policy: MemoryPolicyConfig,
8659 }
8664
8665#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
8667#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8668#[prefix = "memory.policy"]
8669pub struct MemoryPolicyConfig {
8670 #[serde(default)]
8672 pub max_entries_per_namespace: usize,
8673 #[serde(default)]
8675 pub max_entries_per_category: usize,
8676 #[serde(default)]
8678 pub retention_days_by_category: std::collections::HashMap<String, u32>,
8679 #[serde(default)]
8681 pub read_only_namespaces: Vec<String>,
8682}
8683
8684fn default_retrieval_stages() -> Vec<String> {
8685 vec!["cache".into(), "fts".into(), "vector".into()]
8686}
8687fn default_rerank_threshold() -> usize {
8688 5
8689}
8690fn default_fts_early_return_score() -> f64 {
8691 0.85
8692}
8693fn default_namespace() -> String {
8694 "default".into()
8695}
8696fn default_conflict_threshold() -> f64 {
8697 0.85
8698}
8699fn default_audit_retention_days() -> u32 {
8700 30
8701}
8702
8703fn default_pgvector_dimensions() -> usize {
8704 1536
8705}
8706
8707fn default_embedding_provider() -> String {
8708 "none".into()
8709}
8710fn default_auto_save() -> bool {
8711 true
8712}
8713fn default_hygiene_enabled() -> bool {
8714 true
8715}
8716fn default_archive_after_days() -> u32 {
8717 7
8718}
8719fn default_purge_after_days() -> u32 {
8720 30
8721}
8722fn default_conversation_retention_days() -> u32 {
8723 30
8724}
8725fn default_embedding_model() -> String {
8726 "text-embedding-3-small".into()
8727}
8728fn default_embedding_dims() -> usize {
8729 1536
8730}
8731fn default_vector_weight() -> f64 {
8732 0.7
8733}
8734fn default_keyword_weight() -> f64 {
8735 0.3
8736}
8737fn default_min_relevance_score() -> f64 {
8738 0.4
8739}
8740fn default_cache_size() -> usize {
8741 10_000
8742}
8743fn default_chunk_size() -> usize {
8744 512
8745}
8746fn default_response_cache_ttl() -> u32 {
8747 60
8748}
8749fn default_response_cache_max() -> usize {
8750 5_000
8751}
8752
8753fn default_response_cache_hot_entries() -> usize {
8754 256
8755}
8756
8757impl Default for MemoryConfig {
8758 fn default() -> Self {
8759 Self {
8760 backend: "sqlite".into(),
8761 auto_save: true,
8762 hygiene_enabled: default_hygiene_enabled(),
8763 archive_after_days: default_archive_after_days(),
8764 purge_after_days: default_purge_after_days(),
8765 conversation_retention_days: default_conversation_retention_days(),
8766 embedding_provider: default_embedding_provider(),
8767 embedding_model: default_embedding_model(),
8768 embedding_dimensions: default_embedding_dims(),
8769 vector_weight: default_vector_weight(),
8770 keyword_weight: default_keyword_weight(),
8771 search_mode: SearchMode::default(),
8772 min_relevance_score: default_min_relevance_score(),
8773 embedding_cache_size: default_cache_size(),
8774 chunk_max_tokens: default_chunk_size(),
8775 response_cache_enabled: false,
8776 response_cache_ttl_minutes: default_response_cache_ttl(),
8777 response_cache_max_entries: default_response_cache_max(),
8778 response_cache_hot_entries: default_response_cache_hot_entries(),
8779 snapshot_enabled: false,
8780 snapshot_on_hygiene: false,
8781 auto_hydrate: true,
8782 retrieval_stages: default_retrieval_stages(),
8783 rerank_enabled: false,
8784 rerank_threshold: default_rerank_threshold(),
8785 fts_early_return_score: default_fts_early_return_score(),
8786 default_namespace: default_namespace(),
8787 conflict_threshold: default_conflict_threshold(),
8788 audit_enabled: false,
8789 audit_retention_days: default_audit_retention_days(),
8790 policy: MemoryPolicyConfig::default(),
8791 }
8792 }
8793}
8794
8795#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8799#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8800#[prefix = "observability"]
8801pub struct ObservabilityConfig {
8802 pub backend: String,
8804
8805 #[serde(default)]
8807 pub otel_endpoint: Option<String>,
8808
8809 #[serde(default)]
8811 pub otel_service_name: Option<String>,
8812
8813 #[serde(default)]
8820 pub otel_headers: Option<std::collections::HashMap<String, String>>,
8821
8822 #[serde(default = "default_log_persistence", alias = "runtime_trace_mode")]
8826 pub log_persistence: String,
8827
8828 #[serde(default = "default_log_persistence_path", alias = "runtime_trace_path")]
8830 pub log_persistence_path: String,
8831
8832 #[serde(
8834 default = "default_log_persistence_max_entries",
8835 alias = "runtime_trace_max_entries"
8836 )]
8837 pub log_persistence_max_entries: usize,
8838
8839 #[serde(default = "default_log_tool_io")]
8846 pub log_tool_io: String,
8847
8848 #[serde(default = "default_log_tool_io_truncate_bytes")]
8852 pub log_tool_io_truncate_bytes: usize,
8853
8854 #[serde(default)]
8859 pub log_tool_io_denylist: Vec<String>,
8860}
8861
8862impl Default for ObservabilityConfig {
8863 fn default() -> Self {
8864 Self {
8865 backend: "none".into(),
8866 otel_endpoint: None,
8867 otel_service_name: None,
8868 otel_headers: None,
8869 log_persistence: default_log_persistence(),
8870 log_persistence_path: default_log_persistence_path(),
8871 log_persistence_max_entries: default_log_persistence_max_entries(),
8872 log_tool_io: default_log_tool_io(),
8873 log_tool_io_truncate_bytes: default_log_tool_io_truncate_bytes(),
8874 log_tool_io_denylist: Vec::new(),
8875 }
8876 }
8877}
8878
8879fn default_log_persistence() -> String {
8880 "rolling".to_string()
8881}
8882
8883fn default_log_persistence_path() -> String {
8884 "state/runtime-trace.jsonl".to_string()
8885}
8886
8887fn default_log_persistence_max_entries() -> usize {
8888 200
8889}
8890
8891fn default_log_tool_io() -> String {
8892 "redacted".to_string()
8893}
8894
8895fn default_log_tool_io_truncate_bytes() -> usize {
8896 8192
8897}
8898
8899#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8902#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8903#[prefix = "hooks"]
8904pub struct HooksConfig {
8905 pub enabled: bool,
8910 #[serde(default)]
8911 #[nested]
8912 pub builtin: BuiltinHooksConfig,
8913}
8914
8915impl Default for HooksConfig {
8916 fn default() -> Self {
8917 Self {
8918 enabled: true,
8919 builtin: BuiltinHooksConfig::default(),
8920 }
8921 }
8922}
8923
8924#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
8925#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8926#[prefix = "hooks.builtin"]
8927pub struct BuiltinHooksConfig {
8928 pub command_logger: bool,
8930 #[serde(default)]
8935 #[nested]
8936 pub webhook_audit: WebhookAuditConfig,
8937}
8938
8939#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8945#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8946#[prefix = "hooks.builtin.webhook-audit"]
8947pub struct WebhookAuditConfig {
8948 #[serde(default)]
8950 pub enabled: bool,
8951 #[serde(default)]
8953 pub url: String,
8954 #[serde(default)]
8957 pub tool_patterns: Vec<String>,
8958 #[serde(default)]
8962 pub include_args: bool,
8963 #[serde(default = "default_max_args_bytes")]
8967 pub max_args_bytes: u64,
8968}
8969
8970fn default_max_args_bytes() -> u64 {
8971 4096
8972}
8973
8974impl Default for WebhookAuditConfig {
8975 fn default() -> Self {
8976 Self {
8977 enabled: false,
8978 url: String::new(),
8979 tool_patterns: Vec::new(),
8980 include_args: false,
8981 max_args_bytes: default_max_args_bytes(),
8982 }
8983 }
8984}
8985
8986fn default_auto_approve() -> Vec<String> {
8995 vec![
8996 "file_read".into(),
8997 "memory_recall".into(),
8998 "web_search_tool".into(),
8999 "web_fetch".into(),
9000 "calculator".into(),
9001 "glob_search".into(),
9002 "content_search".into(),
9003 "image_info".into(),
9004 "weather".into(),
9005 "browser".into(),
9006 "browser_open".into(),
9007 ]
9008}
9009
9010fn default_always_ask() -> Vec<String> {
9011 vec![]
9012}
9013
9014impl RiskProfileConfig {
9015 pub fn ensure_default_auto_approve(&mut self) {
9018 let defaults = default_auto_approve();
9019 for entry in defaults {
9020 if !self.auto_approve.iter().any(|existing| existing == &entry) {
9021 self.auto_approve.push(entry);
9022 }
9023 }
9024 }
9025
9026 #[must_use]
9031 pub fn sandbox_config(&self) -> SandboxConfig {
9032 let backend = self
9033 .sandbox_backend
9034 .as_deref()
9035 .map(str::trim)
9036 .filter(|s| !s.is_empty())
9037 .map(parse_sandbox_backend)
9038 .unwrap_or_default();
9039 SandboxConfig {
9040 enabled: self.sandbox_enabled,
9041 backend,
9042 firejail_args: self.firejail_args.clone(),
9043 }
9044 }
9045}
9046
9047fn parse_sandbox_backend(name: &str) -> SandboxBackend {
9048 match name.to_ascii_lowercase().as_str() {
9049 "auto" => SandboxBackend::Auto,
9050 "landlock" => SandboxBackend::Landlock,
9051 "firejail" => SandboxBackend::Firejail,
9052 "bubblewrap" => SandboxBackend::Bubblewrap,
9053 "docker" => SandboxBackend::Docker,
9054 "sandbox-exec" | "sandboxexec" | "seatbelt" => SandboxBackend::SandboxExec,
9055 "none" => SandboxBackend::None,
9056 _ => SandboxBackend::default(),
9057 }
9058}
9059
9060fn is_valid_env_var_name(name: &str) -> bool {
9061 let mut chars = name.chars();
9062 match chars.next() {
9063 Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
9064 _ => return false,
9065 }
9066 chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
9067}
9068
9069#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9081#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9082#[prefix = "risk-profile"]
9083#[serde(default)]
9084pub struct RiskProfileConfig {
9085 pub level: AutonomyLevel,
9087 pub workspace_only: bool,
9089 pub allowed_commands: Vec<String>,
9091 pub forbidden_paths: Vec<String>,
9093 pub require_approval_for_medium_risk: bool,
9095 pub block_high_risk_commands: bool,
9097 pub shell_env_passthrough: Vec<String>,
9099 pub auto_approve: Vec<String>,
9101 pub always_ask: Vec<String>,
9103 #[serde(alias = "allowed_path", alias = "allowed_paths")]
9105 pub allowed_roots: Vec<String>,
9106 pub allowed_tools: Vec<String>,
9111 pub excluded_tools: Vec<String>,
9113 pub sandbox_enabled: Option<bool>,
9116 pub sandbox_backend: Option<String>,
9118 pub firejail_args: Vec<String>,
9120}
9121
9122impl Default for RiskProfileConfig {
9123 fn default() -> Self {
9124 Self {
9125 level: AutonomyLevel::Supervised,
9126 workspace_only: true,
9127 allowed_commands: crate::policy::default_allowed_commands(),
9128 forbidden_paths: crate::policy::default_forbidden_paths(),
9129 require_approval_for_medium_risk: true,
9130 block_high_risk_commands: true,
9131 shell_env_passthrough: vec![],
9132 auto_approve: default_auto_approve(),
9133 always_ask: default_always_ask(),
9134 allowed_roots: Vec::new(),
9135 allowed_tools: Vec::new(),
9136 excluded_tools: Vec::new(),
9137 sandbox_enabled: None,
9138 sandbox_backend: None,
9139 firejail_args: Vec::new(),
9140 }
9141 }
9142}
9143
9144#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9155#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9156#[prefix = "runtime-profile"]
9157#[serde(default)]
9158pub struct RuntimeProfileConfig {
9159 pub agentic: bool,
9161 pub max_tool_iterations: usize,
9163 pub max_actions_per_hour: u32,
9168 pub max_cost_per_day_cents: u32,
9171 pub shell_timeout_secs: u64,
9174 pub max_delegation_depth: u32,
9177 pub delegation_timeout_secs: Option<u64>,
9179 pub agentic_timeout_secs: Option<u64>,
9181 pub max_history_messages: Option<usize>,
9184 pub max_context_tokens: Option<usize>,
9186 pub compact_context: Option<bool>,
9188 pub parallel_tools: Option<bool>,
9190 pub tool_dispatcher: Option<String>,
9192 pub tool_call_dedup_exempt: Vec<String>,
9194 pub max_system_prompt_chars: Option<usize>,
9196 pub context_aware_tools: Option<bool>,
9198 pub max_tool_result_chars: Option<usize>,
9200 pub keep_tool_context_turns: Option<usize>,
9202}
9203
9204impl Default for RuntimeProfileConfig {
9205 fn default() -> Self {
9206 Self {
9207 agentic: false,
9208 max_tool_iterations: 0,
9209 max_actions_per_hour: 20,
9210 max_cost_per_day_cents: 500,
9211 shell_timeout_secs: 60,
9212 max_delegation_depth: 0,
9213 delegation_timeout_secs: None,
9214 agentic_timeout_secs: None,
9215 max_history_messages: None,
9216 max_context_tokens: None,
9217 compact_context: None,
9218 parallel_tools: None,
9219 tool_dispatcher: None,
9220 tool_call_dedup_exempt: Vec::new(),
9221 max_system_prompt_chars: None,
9222 context_aware_tools: None,
9223 max_tool_result_chars: None,
9224 keep_tool_context_turns: None,
9225 }
9226 }
9227}
9228
9229#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9234#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9235#[prefix = "skill-bundle"]
9236#[serde(default)]
9237pub struct SkillBundleConfig {
9238 pub directory: Option<String>,
9240 pub include: Vec<String>,
9242 pub exclude: Vec<String>,
9244}
9245
9246#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9251#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9252#[prefix = "knowledge-bundle"]
9253#[serde(default)]
9254pub struct KnowledgeBundleConfig {
9255 pub sources: Vec<String>,
9257 pub tags: Vec<String>,
9259}
9260
9261#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9265#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9266#[prefix = "mcp-bundle"]
9267#[serde(default)]
9268pub struct McpBundleConfig {
9269 pub servers: Vec<String>,
9271 pub exclude: Vec<String>,
9273}
9274
9275#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9279#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9280#[prefix = "runtime"]
9281pub struct RuntimeConfig {
9282 #[serde(default = "default_runtime_kind")]
9284 pub kind: String,
9285
9286 #[serde(default)]
9288 #[nested]
9289 pub docker: DockerRuntimeConfig,
9290
9291 #[serde(default)]
9296 pub reasoning_enabled: Option<bool>,
9297 #[serde(default, deserialize_with = "deserialize_reasoning_effort_opt")]
9299 pub reasoning_effort: Option<String>,
9300}
9301
9302#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9304#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9305#[prefix = "runtime.docker"]
9306pub struct DockerRuntimeConfig {
9307 #[serde(default = "default_docker_image")]
9309 pub image: String,
9310
9311 #[serde(default = "default_docker_network")]
9313 pub network: String,
9314
9315 #[serde(default = "default_docker_memory_limit_mb")]
9317 pub memory_limit_mb: Option<u64>,
9318
9319 #[serde(default = "default_docker_cpu_limit")]
9321 pub cpu_limit: Option<f64>,
9322
9323 #[serde(default = "default_true")]
9325 pub read_only_rootfs: bool,
9326
9327 #[serde(default = "default_true")]
9329 pub mount_workspace: bool,
9330
9331 #[serde(default)]
9333 pub allowed_workspace_roots: Vec<String>,
9334}
9335
9336fn default_runtime_kind() -> String {
9337 "native".into()
9338}
9339
9340fn default_docker_image() -> String {
9341 "alpine:3.20".into()
9342}
9343
9344fn default_docker_network() -> String {
9345 "none".into()
9346}
9347
9348fn default_docker_memory_limit_mb() -> Option<u64> {
9349 Some(512)
9350}
9351
9352fn default_docker_cpu_limit() -> Option<f64> {
9353 Some(1.0)
9354}
9355
9356impl Default for DockerRuntimeConfig {
9357 fn default() -> Self {
9358 Self {
9359 image: default_docker_image(),
9360 network: default_docker_network(),
9361 memory_limit_mb: default_docker_memory_limit_mb(),
9362 cpu_limit: default_docker_cpu_limit(),
9363 read_only_rootfs: true,
9364 mount_workspace: true,
9365 allowed_workspace_roots: Vec::new(),
9366 }
9367 }
9368}
9369
9370impl Default for RuntimeConfig {
9371 fn default() -> Self {
9372 Self {
9373 kind: default_runtime_kind(),
9374 docker: DockerRuntimeConfig::default(),
9375 reasoning_enabled: None,
9376 reasoning_effort: None,
9377 }
9378 }
9379}
9380
9381#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9387#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9388#[prefix = "reliability"]
9389pub struct ReliabilityConfig {
9390 #[serde(default = "default_provider_retries")]
9392 pub provider_retries: u32,
9393 #[serde(default = "default_provider_backoff_ms")]
9395 pub provider_backoff_ms: u64,
9396 #[serde(default)]
9399 pub api_keys: Vec<String>,
9400 #[serde(default = "default_channel_backoff_secs")]
9402 pub channel_initial_backoff_secs: u64,
9403 #[serde(default = "default_channel_backoff_max_secs")]
9405 pub channel_max_backoff_secs: u64,
9406 #[serde(default = "default_scheduler_poll_secs")]
9408 pub scheduler_poll_secs: u64,
9409 #[serde(default = "default_scheduler_retries")]
9411 pub scheduler_retries: u32,
9412}
9413
9414fn default_provider_retries() -> u32 {
9415 2
9416}
9417
9418fn default_provider_backoff_ms() -> u64 {
9419 500
9420}
9421
9422fn default_channel_backoff_secs() -> u64 {
9423 2
9424}
9425
9426fn default_channel_backoff_max_secs() -> u64 {
9427 60
9428}
9429
9430fn default_scheduler_poll_secs() -> u64 {
9431 15
9432}
9433
9434fn default_scheduler_retries() -> u32 {
9435 2
9436}
9437
9438impl Default for ReliabilityConfig {
9439 fn default() -> Self {
9440 Self {
9441 provider_retries: default_provider_retries(),
9442 provider_backoff_ms: default_provider_backoff_ms(),
9443 api_keys: Vec::new(),
9444 channel_initial_backoff_secs: default_channel_backoff_secs(),
9445 channel_max_backoff_secs: default_channel_backoff_max_secs(),
9446 scheduler_poll_secs: default_scheduler_poll_secs(),
9447 scheduler_retries: default_scheduler_retries(),
9448 }
9449 }
9450}
9451
9452#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9460#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9461#[prefix = "scheduler"]
9462pub struct SchedulerConfig {
9463 #[serde(default = "default_scheduler_enabled")]
9465 pub enabled: bool,
9466 #[serde(default = "default_scheduler_max_tasks")]
9468 pub max_tasks: usize,
9469 #[serde(default = "default_scheduler_max_concurrent")]
9471 pub max_concurrent: usize,
9472 #[serde(default = "default_true")]
9478 pub catch_up_on_startup: bool,
9479 #[serde(default = "default_max_run_history")]
9481 pub max_run_history: u32,
9482}
9483
9484fn default_scheduler_enabled() -> bool {
9485 true
9486}
9487
9488fn default_scheduler_max_tasks() -> usize {
9489 64
9490}
9491
9492fn default_scheduler_max_concurrent() -> usize {
9493 4
9494}
9495
9496impl Default for SchedulerConfig {
9497 fn default() -> Self {
9498 Self {
9499 enabled: default_scheduler_enabled(),
9500 max_tasks: default_scheduler_max_tasks(),
9501 max_concurrent: default_scheduler_max_concurrent(),
9502 catch_up_on_startup: true,
9503 max_run_history: default_max_run_history(),
9504 }
9505 }
9506}
9507
9508#[derive(Debug, Clone, Serialize, Deserialize)]
9526#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9527pub struct ModelRouteConfig {
9528 pub hint: String,
9530 pub model_provider: String,
9532 pub model: String,
9534 #[serde(default)]
9536 pub api_key: Option<String>,
9537}
9538
9539#[derive(Debug, Clone, Serialize, Deserialize)]
9554#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9555pub struct EmbeddingRouteConfig {
9556 pub hint: String,
9558 pub model_provider: String,
9560 pub model: String,
9562 #[serde(default)]
9564 pub dimensions: Option<usize>,
9565 #[serde(default)]
9567 pub api_key: Option<String>,
9568}
9569
9570#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
9575#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9576#[prefix = "query-classification"]
9577pub struct QueryClassificationConfig {
9578 #[serde(default)]
9580 pub enabled: bool,
9581 #[serde(default)]
9583 pub rules: Vec<ClassificationRule>,
9584}
9585
9586#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9588#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9589pub struct ClassificationRule {
9590 pub hint: String,
9592 #[serde(default)]
9594 pub keywords: Vec<String>,
9595 #[serde(default)]
9597 pub patterns: Vec<String>,
9598 #[serde(default)]
9600 pub min_length: Option<usize>,
9601 #[serde(default)]
9603 pub max_length: Option<usize>,
9604 #[serde(default)]
9606 pub priority: i32,
9607}
9608
9609#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9613#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9614#[prefix = "heartbeat"]
9615#[allow(clippy::struct_excessive_bools)]
9616pub struct HeartbeatConfig {
9617 #[serde(default)]
9621 pub enabled: bool,
9622 #[serde(default)]
9625 pub agent: String,
9626 #[serde(default = "default_heartbeat_interval")]
9628 pub interval_minutes: u32,
9629 #[serde(default = "default_two_phase")]
9633 pub two_phase: bool,
9634 #[serde(default)]
9636 pub message: Option<String>,
9637 #[serde(default, alias = "channel")]
9640 pub target: Option<String>,
9641 #[serde(default, alias = "recipient")]
9644 pub to: Option<String>,
9645 #[serde(default)]
9648 pub adaptive: bool,
9649 #[serde(default = "default_heartbeat_min_interval")]
9651 pub min_interval_minutes: u32,
9652 #[serde(default = "default_heartbeat_max_interval")]
9654 pub max_interval_minutes: u32,
9655 #[serde(default)]
9658 pub deadman_timeout_minutes: u32,
9659 #[serde(default)]
9662 pub deadman_channel: Option<String>,
9663 #[serde(default)]
9665 pub deadman_to: Option<String>,
9666 #[serde(default = "default_heartbeat_max_run_history")]
9668 pub max_run_history: u32,
9669 #[serde(default)]
9676 pub load_session_context: bool,
9677 #[serde(default = "default_heartbeat_task_timeout")]
9681 pub task_timeout_secs: u64,
9682}
9683
9684fn default_heartbeat_interval() -> u32 {
9685 30
9686}
9687
9688fn default_two_phase() -> bool {
9689 true
9690}
9691
9692fn default_heartbeat_min_interval() -> u32 {
9693 5
9694}
9695
9696fn default_heartbeat_max_interval() -> u32 {
9697 120
9698}
9699
9700fn default_heartbeat_max_run_history() -> u32 {
9701 100
9702}
9703
9704fn default_heartbeat_task_timeout() -> u64 {
9705 600
9706}
9707
9708impl Default for HeartbeatConfig {
9709 fn default() -> Self {
9710 Self {
9711 enabled: false,
9712 agent: String::new(),
9713 interval_minutes: default_heartbeat_interval(),
9714 two_phase: true,
9715 message: None,
9716 target: None,
9717 to: None,
9718 adaptive: false,
9719 min_interval_minutes: default_heartbeat_min_interval(),
9720 max_interval_minutes: default_heartbeat_max_interval(),
9721 deadman_timeout_minutes: 0,
9722 deadman_channel: None,
9723 deadman_to: None,
9724 max_run_history: default_heartbeat_max_run_history(),
9725 load_session_context: false,
9726 task_timeout_secs: default_heartbeat_task_timeout(),
9727 }
9728 }
9729}
9730
9731#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9741#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9742#[prefix = "cron"]
9743pub struct CronJobDecl {
9744 #[serde(default)]
9746 pub name: Option<String>,
9747 #[serde(default = "default_job_type_decl")]
9749 pub job_type: String,
9750 #[serde(default)]
9752 pub schedule: CronScheduleDecl,
9753 #[serde(default)]
9755 pub command: Option<String>,
9756 #[serde(default)]
9758 pub prompt: Option<String>,
9759 #[serde(default = "default_true")]
9761 pub enabled: bool,
9762 #[serde(default)]
9764 pub model: Option<String>,
9765 #[serde(default)]
9767 pub allowed_tools: Option<Vec<String>>,
9768 #[serde(default = "default_true")]
9771 pub uses_memory: bool,
9772 #[serde(default)]
9774 pub session_target: Option<String>,
9775 #[serde(default)]
9777 #[nested]
9778 pub delivery: Option<DeliveryConfigDecl>,
9779}
9780
9781impl Default for CronJobDecl {
9782 fn default() -> Self {
9783 Self {
9784 name: None,
9785 job_type: default_job_type_decl(),
9786 schedule: CronScheduleDecl::default(),
9787 command: None,
9788 prompt: None,
9789 enabled: true,
9790 model: None,
9791 allowed_tools: None,
9792 uses_memory: true,
9793 session_target: None,
9794 delivery: None,
9795 }
9796 }
9797}
9798
9799#[derive(Debug, Clone, Serialize, Deserialize)]
9801#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9802#[serde(tag = "kind", rename_all = "lowercase")]
9803pub enum CronScheduleDecl {
9804 Cron {
9806 expr: String,
9807 #[serde(default)]
9808 tz: Option<String>,
9809 },
9810 Every { every_ms: u64 },
9812 At { at: String },
9814}
9815
9816impl Default for CronScheduleDecl {
9817 fn default() -> Self {
9818 Self::Cron {
9822 expr: String::new(),
9823 tz: None,
9824 }
9825 }
9826}
9827
9828#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9830#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9831#[prefix = "cron-delivery"]
9832pub struct DeliveryConfigDecl {
9833 #[serde(default = "default_delivery_mode")]
9835 pub mode: String,
9836 #[serde(default)]
9838 pub channel: Option<String>,
9839 #[serde(default)]
9841 pub to: Option<String>,
9842 #[serde(default, skip_serializing_if = "Option::is_none")]
9846 pub thread_id: Option<String>,
9847 #[serde(default = "default_true")]
9849 pub best_effort: bool,
9850}
9851
9852impl Default for DeliveryConfigDecl {
9853 fn default() -> Self {
9854 Self {
9855 mode: default_delivery_mode(),
9856 channel: None,
9857 to: None,
9858 thread_id: None,
9859 best_effort: true,
9860 }
9861 }
9862}
9863
9864fn default_job_type_decl() -> String {
9865 "shell".to_string()
9866}
9867
9868fn default_delivery_mode() -> String {
9869 "none".to_string()
9870}
9871
9872fn default_max_run_history() -> u32 {
9873 50
9874}
9875
9876#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9880#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9881#[prefix = "acp"]
9882pub struct AcpConfig {
9883 #[serde(default, skip_serializing_if = "Option::is_none")]
9887 pub default_agent: Option<String>,
9888 #[serde(default = "default_acp_max_sessions")]
9890 pub max_sessions: usize,
9891 #[serde(default = "default_acp_session_timeout_secs")]
9894 pub session_timeout_secs: u64,
9895}
9896
9897fn default_acp_max_sessions() -> usize {
9898 10
9899}
9900
9901fn default_acp_session_timeout_secs() -> u64 {
9902 3600
9903}
9904
9905impl Default for AcpConfig {
9906 fn default() -> Self {
9907 Self {
9908 default_agent: None,
9909 max_sessions: default_acp_max_sessions(),
9910 session_timeout_secs: default_acp_session_timeout_secs(),
9911 }
9912 }
9913}
9914
9915#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9921#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9922#[prefix = "tunnel"]
9923pub struct TunnelConfig {
9924 pub tunnel_provider: String,
9926
9927 #[serde(default)]
9929 #[nested]
9930 pub cloudflare: Option<CloudflareTunnelConfig>,
9931
9932 #[serde(default)]
9934 #[nested]
9935 pub tailscale: Option<TailscaleTunnelConfig>,
9936
9937 #[serde(default)]
9939 #[nested]
9940 pub ngrok: Option<NgrokTunnelConfig>,
9941
9942 #[serde(default)]
9944 #[nested]
9945 pub openvpn: Option<OpenVpnTunnelConfig>,
9946
9947 #[serde(default)]
9949 #[nested]
9950 pub custom: Option<CustomTunnelConfig>,
9951
9952 #[serde(default)]
9954 #[nested]
9955 pub pinggy: Option<PinggyTunnelConfig>,
9956}
9957
9958impl Default for TunnelConfig {
9959 fn default() -> Self {
9960 Self {
9961 tunnel_provider: "none".into(),
9962 cloudflare: None,
9963 tailscale: None,
9964 ngrok: None,
9965 openvpn: None,
9966 custom: None,
9967 pinggy: None,
9968 }
9969 }
9970}
9971
9972#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9973#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9974#[prefix = "tunnel.cloudflare"]
9975pub struct CloudflareTunnelConfig {
9976 #[serde(default)]
9978 #[secret]
9979 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
9980 pub token: String,
9981}
9982
9983#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9984#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9985#[prefix = "tunnel.tailscale"]
9986pub struct TailscaleTunnelConfig {
9987 #[serde(default)]
9989 pub funnel: bool,
9990 #[serde(default)]
9992 pub hostname: Option<String>,
9993}
9994
9995#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9996#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9997#[prefix = "tunnel.ngrok"]
9998pub struct NgrokTunnelConfig {
9999 #[serde(default)]
10001 #[secret]
10002 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10003 pub auth_token: String,
10004 #[serde(default)]
10006 pub domain: Option<String>,
10007}
10008
10009#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10017#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10018#[prefix = "tunnel.openvpn"]
10019pub struct OpenVpnTunnelConfig {
10020 pub config_file: String,
10022 #[serde(default)]
10024 pub auth_file: Option<String>,
10025 #[serde(default)]
10028 pub advertise_address: Option<String>,
10029 #[serde(default = "default_openvpn_timeout")]
10031 pub connect_timeout_secs: u64,
10032 #[serde(default)]
10034 pub extra_args: Vec<String>,
10035}
10036
10037fn default_openvpn_timeout() -> u64 {
10038 30
10039}
10040
10041impl Default for OpenVpnTunnelConfig {
10042 fn default() -> Self {
10043 Self {
10044 config_file: String::new(),
10045 auth_file: None,
10046 advertise_address: None,
10047 connect_timeout_secs: default_openvpn_timeout(),
10048 extra_args: Vec::new(),
10049 }
10050 }
10051}
10052
10053#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10054#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10055#[prefix = "tunnel.pinggy"]
10056pub struct PinggyTunnelConfig {
10057 #[serde(default)]
10059 #[secret]
10060 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10061 pub token: Option<String>,
10062 #[serde(default)]
10064 pub region: Option<String>,
10065}
10066
10067#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10068#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10069#[prefix = "tunnel.custom"]
10070pub struct CustomTunnelConfig {
10071 #[serde(default)]
10074 pub start_command: String,
10075 #[serde(default)]
10077 pub health_url: Option<String>,
10078 #[serde(default)]
10080 pub url_pattern: Option<String>,
10081}
10082
10083#[allow(clippy::struct_excessive_bools)]
10091#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10092#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10093#[prefix = "channels"]
10094pub struct ChannelsConfig {
10095 #[serde(default = "default_true")]
10097 pub cli: bool,
10098 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10100 #[nested]
10101 pub telegram: HashMap<String, TelegramConfig>,
10102 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10104 #[nested]
10105 pub discord: HashMap<String, DiscordConfig>,
10106 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10108 #[nested]
10109 pub slack: HashMap<String, SlackConfig>,
10110 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10112 #[nested]
10113 pub mattermost: HashMap<String, MattermostConfig>,
10114 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10116 #[nested]
10117 pub webhook: HashMap<String, WebhookConfig>,
10118 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10120 #[nested]
10121 pub imessage: HashMap<String, IMessageConfig>,
10122 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10124 #[nested]
10125 pub matrix: HashMap<String, MatrixConfig>,
10126 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10128 #[nested]
10129 pub signal: HashMap<String, SignalConfig>,
10130 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10132 #[nested]
10133 pub whatsapp: HashMap<String, WhatsAppConfig>,
10134 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10136 #[nested]
10137 pub linq: HashMap<String, LinqConfig>,
10138 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10140 #[nested]
10141 pub wati: HashMap<String, WatiConfig>,
10142 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10144 #[nested]
10145 pub nextcloud_talk: HashMap<String, NextcloudTalkConfig>,
10146 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10148 #[nested]
10149 pub email: HashMap<String, crate::scattered_types::EmailConfig>,
10150 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10152 #[nested]
10153 pub gmail_push: HashMap<String, crate::scattered_types::GmailPushConfig>,
10154 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10156 #[nested]
10157 pub irc: HashMap<String, IrcConfig>,
10158 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10160 #[nested]
10161 pub lark: HashMap<String, LarkConfig>,
10162 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10164 #[nested]
10165 pub line: HashMap<String, LineConfig>,
10166 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10168 #[nested]
10169 pub dingtalk: HashMap<String, DingTalkConfig>,
10170 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10172 #[nested]
10173 pub wecom: HashMap<String, WeComConfig>,
10174 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10176 #[nested]
10177 pub wecom_ws: HashMap<String, WeComWsConfig>,
10178 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10180 #[nested]
10181 pub wechat: HashMap<String, WeChatConfig>,
10182 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10184 #[nested]
10185 pub qq: HashMap<String, QQConfig>,
10186 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10188 #[nested]
10189 pub twitter: HashMap<String, TwitterConfig>,
10190 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10192 #[nested]
10193 pub mochat: HashMap<String, MochatConfig>,
10194 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10195 #[nested]
10196 pub nostr: HashMap<String, NostrConfig>,
10197 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10199 #[nested]
10200 pub clawdtalk: HashMap<String, crate::scattered_types::ClawdTalkConfig>,
10201 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10203 #[nested]
10204 pub reddit: HashMap<String, RedditConfig>,
10205 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10207 #[nested]
10208 pub bluesky: HashMap<String, BlueskyConfig>,
10209 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10211 #[nested]
10212 pub voice_call: HashMap<String, crate::scattered_types::VoiceCallConfig>,
10213 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10215 #[nested]
10216 pub voice_wake: HashMap<String, VoiceWakeConfig>,
10217 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10219 #[nested]
10220 pub voice_duplex: HashMap<String, VoiceDuplexConfig>,
10221 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10223 #[nested]
10224 pub mqtt: HashMap<String, MqttConfig>,
10225 #[serde(default = "default_channel_message_timeout_secs")]
10231 pub message_timeout_secs: u64,
10232 #[serde(default = "default_true")]
10235 pub ack_reactions: bool,
10236 #[serde(default = "default_false")]
10240 pub show_tool_calls: bool,
10241 #[serde(default = "default_true")]
10244 pub session_persistence: bool,
10245 #[serde(default = "default_session_backend")]
10248 pub session_backend: String,
10249 #[serde(default)]
10251 pub session_ttl_hours: u32,
10252 #[serde(default)]
10256 pub debounce_ms: u64,
10257}
10258
10259impl ChannelsConfig {
10260 pub fn channels(&self) -> Vec<super::traits::ChannelInfo> {
10268 use super::traits::ChannelInfo;
10269 vec![
10270 ChannelInfo {
10271 name: "Telegram",
10272 desc: "connect your bot",
10273 configured: !self.telegram.is_empty(),
10274 },
10275 ChannelInfo {
10276 name: "Discord",
10277 desc: "connect your bot",
10278 configured: !self.discord.is_empty(),
10279 },
10280 ChannelInfo {
10281 name: "Slack",
10282 desc: "connect your bot",
10283 configured: !self.slack.is_empty(),
10284 },
10285 ChannelInfo {
10286 name: "Mattermost",
10287 desc: "connect to your bot",
10288 configured: !self.mattermost.is_empty(),
10289 },
10290 ChannelInfo {
10291 name: "iMessage",
10292 desc: "macOS only",
10293 configured: !self.imessage.is_empty(),
10294 },
10295 ChannelInfo {
10296 name: "Matrix",
10297 desc: "self-hosted chat",
10298 configured: !self.matrix.is_empty(),
10299 },
10300 ChannelInfo {
10301 name: "Signal",
10302 desc: "An open-source, encrypted messaging service",
10303 configured: !self.signal.is_empty(),
10304 },
10305 ChannelInfo {
10306 name: "WhatsApp",
10307 desc: "Business Cloud API",
10308 configured: !self.whatsapp.is_empty(),
10309 },
10310 ChannelInfo {
10311 name: "WhatsApp Web",
10312 desc: "native WhatsApp Web (wa-rs)",
10313 configured: self.whatsapp.values().any(|c| c.is_web_config()),
10314 },
10315 ChannelInfo {
10316 name: "Linq",
10317 desc: "iMessage/RCS/SMS via Linq API",
10318 configured: !self.linq.is_empty(),
10319 },
10320 ChannelInfo {
10321 name: "WATI",
10322 desc: "WhatsApp via WATI Business API",
10323 configured: !self.wati.is_empty(),
10324 },
10325 ChannelInfo {
10326 name: "NextCloud Talk",
10327 desc: "NextCloud Talk platform",
10328 configured: !self.nextcloud_talk.is_empty(),
10329 },
10330 ChannelInfo {
10331 name: "Email",
10332 desc: "Email over IMAP/SMTP",
10333 configured: !self.email.is_empty(),
10334 },
10335 ChannelInfo {
10336 name: "Gmail Push",
10337 desc: "Gmail Pub/Sub push notifications",
10338 configured: !self.gmail_push.is_empty(),
10339 },
10340 ChannelInfo {
10341 name: "IRC",
10342 desc: "IRC over TLS",
10343 configured: !self.irc.is_empty(),
10344 },
10345 ChannelInfo {
10346 name: "Lark",
10347 desc: "Lark Bot",
10348 configured: !self.lark.is_empty(),
10349 },
10350 ChannelInfo {
10351 name: "DingTalk",
10352 desc: "DingTalk Stream Mode",
10353 configured: !self.dingtalk.is_empty(),
10354 },
10355 ChannelInfo {
10356 name: "WeCom",
10357 desc: "WeCom Bot Webhook",
10358 configured: !self.wecom.is_empty(),
10359 },
10360 ChannelInfo {
10361 name: "WeCom WebSocket",
10362 desc: "WeCom AI Bot long connection",
10363 configured: !self.wecom_ws.is_empty(),
10364 },
10365 ChannelInfo {
10366 name: "WeChat",
10367 desc: "WeChat iLink Bot",
10368 configured: !self.wechat.is_empty(),
10369 },
10370 ChannelInfo {
10371 name: "QQ Official",
10372 desc: "Tencent QQ Bot",
10373 configured: !self.qq.is_empty(),
10374 },
10375 ChannelInfo {
10376 name: "Nostr",
10377 desc: "Nostr DMs",
10378 configured: !self.nostr.is_empty(),
10379 },
10380 ChannelInfo {
10381 name: "ClawdTalk",
10382 desc: "ClawdTalk Channel",
10383 configured: !self.clawdtalk.is_empty(),
10384 },
10385 ChannelInfo {
10386 name: "Reddit",
10387 desc: "Reddit bot (OAuth2)",
10388 configured: !self.reddit.is_empty(),
10389 },
10390 ChannelInfo {
10391 name: "Bluesky",
10392 desc: "AT Protocol",
10393 configured: !self.bluesky.is_empty(),
10394 },
10395 ChannelInfo {
10396 name: "X/Twitter",
10397 desc: "X/Twitter Bot via API v2",
10398 configured: !self.twitter.is_empty(),
10399 },
10400 ChannelInfo {
10401 name: "Mochat",
10402 desc: "Mochat Customer Service",
10403 configured: !self.mochat.is_empty(),
10404 },
10405 ChannelInfo {
10406 name: "LINE",
10407 desc: "connect your LINE bot",
10408 configured: !self.line.is_empty(),
10409 },
10410 ChannelInfo {
10411 name: "Voice Call",
10412 desc: "outbound voice call channel",
10413 configured: !self.voice_call.is_empty(),
10414 },
10415 ChannelInfo {
10416 name: "VoiceWake",
10417 desc: "voice wake word detection",
10418 configured: !self.voice_wake.is_empty(),
10419 },
10420 ChannelInfo {
10421 name: "MQTT",
10422 desc: "MQTT SOP Listener",
10423 configured: !self.mqtt.is_empty(),
10424 },
10425 ChannelInfo {
10426 name: "Webhook",
10427 desc: "HTTP endpoint",
10428 configured: !self.webhook.is_empty(),
10429 },
10430 ]
10431 }
10432}
10433
10434fn default_channel_message_timeout_secs() -> u64 {
10435 300
10436}
10437
10438fn default_session_backend() -> String {
10439 "sqlite".into()
10440}
10441
10442impl Default for ChannelsConfig {
10443 fn default() -> Self {
10444 Self {
10445 cli: true,
10446 telegram: HashMap::new(),
10447 discord: HashMap::new(),
10448 slack: HashMap::new(),
10449 mattermost: HashMap::new(),
10450 webhook: HashMap::new(),
10451 imessage: HashMap::new(),
10452 matrix: HashMap::new(),
10453 signal: HashMap::new(),
10454 whatsapp: HashMap::new(),
10455 linq: HashMap::new(),
10456 wati: HashMap::new(),
10457 nextcloud_talk: HashMap::new(),
10458 email: HashMap::new(),
10459 gmail_push: HashMap::new(),
10460 irc: HashMap::new(),
10461 lark: HashMap::new(),
10462 line: HashMap::new(),
10463 dingtalk: HashMap::new(),
10464 wecom: HashMap::new(),
10465 wecom_ws: HashMap::new(),
10466 wechat: HashMap::new(),
10467 qq: HashMap::new(),
10468 twitter: HashMap::new(),
10469 mochat: HashMap::new(),
10470 nostr: HashMap::new(),
10471 clawdtalk: HashMap::new(),
10472 reddit: HashMap::new(),
10473 bluesky: HashMap::new(),
10474 voice_call: HashMap::new(),
10475 voice_wake: HashMap::new(),
10476 voice_duplex: HashMap::new(),
10477 mqtt: HashMap::new(),
10478 message_timeout_secs: default_channel_message_timeout_secs(),
10479 ack_reactions: true,
10480 show_tool_calls: false,
10481 session_persistence: true,
10482 session_backend: default_session_backend(),
10483 session_ttl_hours: 0,
10484 debounce_ms: 0,
10485 }
10486 }
10487}
10488
10489#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
10491#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10492#[serde(rename_all = "lowercase")]
10493pub enum StreamMode {
10494 #[default]
10496 Off,
10497 Partial,
10499 #[serde(rename = "multi_message")]
10501 MultiMessage,
10502}
10503
10504fn default_draft_update_interval_ms() -> u64 {
10505 1000
10506}
10507
10508fn default_multi_message_delay_ms() -> u64 {
10509 800
10510}
10511
10512fn default_telegram_approval_timeout_secs() -> u64 {
10513 120
10514}
10515
10516fn default_channel_approval_timeout_secs() -> u64 {
10517 300
10518}
10519
10520fn default_matrix_draft_update_interval_ms() -> u64 {
10521 1500
10522}
10523
10524#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10526#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10527#[prefix = "channels.telegram"]
10528pub struct TelegramConfig {
10529 #[serde(default)]
10534 pub enabled: bool,
10535 #[secret]
10537 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10538 pub bot_token: String,
10539 #[serde(default)]
10541 pub stream_mode: StreamMode,
10542 #[serde(default = "default_draft_update_interval_ms")]
10544 pub draft_update_interval_ms: u64,
10545 #[serde(default)]
10548 pub interrupt_on_new_message: bool,
10549 #[serde(default)]
10552 pub mention_only: bool,
10553 #[serde(default)]
10557 pub ack_reactions: Option<bool>,
10558 #[serde(default)]
10561 pub proxy_url: Option<String>,
10562 #[serde(default = "default_telegram_approval_timeout_secs")]
10565 pub approval_timeout_secs: u64,
10566
10567 #[serde(default)]
10570 pub excluded_tools: Vec<String>,
10571
10572 #[serde(default, skip_serializing_if = "Option::is_none")]
10576 pub default_target: Option<String>,
10577}
10578
10579impl ChannelConfig for TelegramConfig {
10580 fn name() -> &'static str {
10581 "Telegram"
10582 }
10583 fn desc() -> &'static str {
10584 "connect your bot"
10585 }
10586}
10587
10588#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10590#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10591#[prefix = "channels.discord"]
10592#[allow(clippy::struct_excessive_bools)]
10593pub struct DiscordConfig {
10594 #[serde(default)]
10599 pub enabled: bool,
10600 #[secret]
10602 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10603 pub bot_token: String,
10604 #[serde(default)]
10608 pub guild_ids: Vec<String>,
10609 #[serde(default)]
10613 pub channel_ids: Vec<String>,
10614 #[serde(default)]
10619 pub archive: bool,
10620 #[serde(default)]
10623 pub listen_to_bots: bool,
10624 #[serde(default)]
10627 pub interrupt_on_new_message: bool,
10628 #[serde(default)]
10631 pub mention_only: bool,
10632 #[serde(default)]
10635 pub proxy_url: Option<String>,
10636 #[serde(default)]
10640 pub stream_mode: StreamMode,
10641 #[serde(default = "default_draft_update_interval_ms")]
10644 pub draft_update_interval_ms: u64,
10645 #[serde(default = "default_multi_message_delay_ms")]
10648 pub multi_message_delay_ms: u64,
10649 #[serde(default)]
10652 pub stall_timeout_secs: u64,
10653 #[serde(default = "default_channel_approval_timeout_secs")]
10655 pub approval_timeout_secs: u64,
10656
10657 #[serde(default)]
10660 pub excluded_tools: Vec<String>,
10661
10662 #[serde(default, skip_serializing_if = "Option::is_none")]
10666 pub default_target: Option<String>,
10667}
10668
10669impl ChannelConfig for DiscordConfig {
10670 fn name() -> &'static str {
10671 "Discord"
10672 }
10673 fn desc() -> &'static str {
10674 "connect your bot"
10675 }
10676}
10677
10678#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10680#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10681#[prefix = "channels.slack"]
10682#[allow(clippy::struct_excessive_bools)]
10683pub struct SlackConfig {
10684 #[serde(default)]
10689 pub enabled: bool,
10690 #[secret]
10692 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10693 pub bot_token: String,
10694 #[secret]
10696 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10697 pub app_token: Option<String>,
10698 #[serde(default)]
10702 pub channel_ids: Vec<String>,
10703 #[serde(default)]
10706 pub interrupt_on_new_message: bool,
10707 #[serde(default)]
10710 pub thread_replies: Option<bool>,
10711 #[serde(default)]
10714 pub mention_only: bool,
10715 #[serde(default)]
10722 pub strict_mention_in_thread: bool,
10723 #[serde(default)]
10727 pub use_markdown_blocks: bool,
10728 #[serde(default)]
10731 pub proxy_url: Option<String>,
10732 #[serde(default)]
10734 pub stream_drafts: bool,
10735 #[serde(default = "default_slack_draft_update_interval_ms")]
10737 pub draft_update_interval_ms: u64,
10738 #[serde(default)]
10742 pub cancel_reaction: Option<String>,
10743 #[serde(default = "default_channel_approval_timeout_secs")]
10745 pub approval_timeout_secs: u64,
10746
10747 #[serde(default)]
10750 pub excluded_tools: Vec<String>,
10751
10752 #[serde(default, skip_serializing_if = "Option::is_none")]
10756 pub default_target: Option<String>,
10757}
10758
10759fn default_slack_draft_update_interval_ms() -> u64 {
10760 1200
10761}
10762
10763impl ChannelConfig for SlackConfig {
10764 fn name() -> &'static str {
10765 "Slack"
10766 }
10767 fn desc() -> &'static str {
10768 "connect your bot"
10769 }
10770}
10771
10772#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10774#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10775#[prefix = "channels.mattermost"]
10776pub struct MattermostConfig {
10777 #[serde(default)]
10782 pub enabled: bool,
10783 pub url: String,
10785 #[secret]
10788 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10789 #[serde(default)]
10790 pub bot_token: Option<String>,
10791 #[serde(default)]
10795 pub login_id: Option<String>,
10796 #[secret]
10799 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10800 #[serde(default)]
10801 pub password: Option<String>,
10802 #[serde(default)]
10808 pub channel_ids: Vec<String>,
10809 #[serde(default)]
10814 pub team_ids: Vec<String>,
10815 #[serde(default)]
10821 pub discover_dms: Option<bool>,
10822 #[serde(default)]
10825 pub thread_replies: Option<bool>,
10826 #[serde(default)]
10832 pub mention_only: Option<bool>,
10833 #[serde(default)]
10836 pub interrupt_on_new_message: bool,
10837 #[serde(default)]
10840 pub proxy_url: Option<String>,
10841
10842 #[serde(default)]
10845 pub excluded_tools: Vec<String>,
10846
10847 #[serde(default, skip_serializing_if = "Option::is_none")]
10851 pub default_target: Option<String>,
10852}
10853
10854impl ChannelConfig for MattermostConfig {
10855 fn name() -> &'static str {
10856 "Mattermost"
10857 }
10858 fn desc() -> &'static str {
10859 "connect to your bot"
10860 }
10861}
10862
10863#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10868#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10869#[prefix = "channels.webhook"]
10870pub struct WebhookConfig {
10871 #[serde(default)]
10876 pub enabled: bool,
10877 pub port: u16,
10879 #[serde(default)]
10881 pub listen_path: Option<String>,
10882 #[serde(default)]
10884 pub send_url: Option<String>,
10885 #[serde(default)]
10887 pub send_method: Option<String>,
10888 #[serde(default)]
10890 #[secret]
10891 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10892 pub auth_header: Option<String>,
10893 #[secret]
10895 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10896 pub secret: Option<String>,
10897
10898 #[serde(default)]
10901 pub excluded_tools: Vec<String>,
10902
10903 #[serde(default)]
10906 pub max_retries: Option<u32>,
10907 #[serde(default)]
10910 pub retry_base_delay_ms: Option<u64>,
10911 #[serde(default)]
10914 pub retry_max_delay_ms: Option<u64>,
10915}
10916
10917impl ChannelConfig for WebhookConfig {
10918 fn name() -> &'static str {
10919 "Webhook"
10920 }
10921 fn desc() -> &'static str {
10922 "HTTP endpoint"
10923 }
10924}
10925
10926#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10928#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10929#[prefix = "channels.imessage"]
10930pub struct IMessageConfig {
10931 #[serde(default)]
10936 pub enabled: bool,
10937 #[serde(default)]
10940 pub excluded_tools: Vec<String>,
10941}
10942
10943impl ChannelConfig for IMessageConfig {
10944 fn name() -> &'static str {
10945 "iMessage"
10946 }
10947 fn desc() -> &'static str {
10948 "macOS only"
10949 }
10950}
10951
10952#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10954#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10955#[prefix = "channels.matrix"]
10956pub struct MatrixConfig {
10957 #[serde(default)]
10962 pub enabled: bool,
10963 pub homeserver: String,
10965 #[secret]
10968 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10969 #[serde(default)]
10970 pub access_token: Option<String>,
10971 #[serde(default)]
10973 pub user_id: Option<String>,
10974 #[serde(default)]
10976 pub device_id: Option<String>,
10977 #[serde(default)]
10980 pub allowed_rooms: Vec<String>,
10981 #[serde(default)]
10983 pub interrupt_on_new_message: bool,
10984 #[serde(default)]
10988 pub stream_mode: StreamMode,
10989 #[serde(default = "default_matrix_draft_update_interval_ms")]
10991 pub draft_update_interval_ms: u64,
10992 #[serde(default = "default_multi_message_delay_ms")]
10994 pub multi_message_delay_ms: u64,
10995 #[serde(default)]
10998 pub mention_only: bool,
10999 #[secret]
11002 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11003 #[serde(default)]
11004 pub recovery_key: Option<String>,
11005 #[secret]
11007 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11008 #[serde(default)]
11009 pub password: Option<String>,
11010 #[serde(default = "default_channel_approval_timeout_secs")]
11012 pub approval_timeout_secs: u64,
11013 #[serde(default = "default_true")]
11016 pub reply_in_thread: bool,
11017 #[serde(default)]
11022 pub ack_reactions: Option<bool>,
11023
11024 #[serde(default)]
11027 pub excluded_tools: Vec<String>,
11028
11029 #[serde(default, skip_serializing_if = "Option::is_none")]
11033 pub default_target: Option<String>,
11034}
11035
11036impl ChannelConfig for MatrixConfig {
11037 fn name() -> &'static str {
11038 "Matrix"
11039 }
11040 fn desc() -> &'static str {
11041 "self-hosted chat"
11042 }
11043}
11044
11045#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11046#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11047#[prefix = "channels.signal"]
11048pub struct SignalConfig {
11049 #[serde(default)]
11054 pub enabled: bool,
11055 pub http_url: String,
11057 pub account: String,
11059 #[serde(default)]
11064 pub group_ids: Vec<String>,
11065 #[serde(default)]
11069 pub dm_only: bool,
11070 #[serde(default)]
11072 pub ignore_attachments: bool,
11073 #[serde(default)]
11075 pub ignore_stories: bool,
11076 #[serde(default)]
11079 pub proxy_url: Option<String>,
11080 #[serde(default = "default_channel_approval_timeout_secs")]
11082 pub approval_timeout_secs: u64,
11083
11084 #[serde(default)]
11087 pub excluded_tools: Vec<String>,
11088
11089 #[serde(default, skip_serializing_if = "Option::is_none")]
11093 pub default_target: Option<String>,
11094}
11095
11096impl ChannelConfig for SignalConfig {
11097 fn name() -> &'static str {
11098 "Signal"
11099 }
11100 fn desc() -> &'static str {
11101 "An open-source, encrypted messaging service"
11102 }
11103}
11104
11105#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
11112#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11113#[serde(rename_all = "snake_case")]
11114pub enum WhatsAppWebMode {
11115 #[default]
11117 Business,
11118 Personal,
11120}
11121
11122#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
11125#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11126#[serde(rename_all = "snake_case")]
11127pub enum WhatsAppChatPolicy {
11128 #[default]
11130 Allowlist,
11131 Ignore,
11133 All,
11135}
11136
11137#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11141#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11142#[prefix = "channels.whatsapp"]
11143pub struct WhatsAppConfig {
11144 #[serde(default)]
11149 pub enabled: bool,
11150 #[serde(default)]
11152 #[secret]
11153 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11154 pub access_token: Option<String>,
11155 #[serde(default)]
11157 pub phone_number_id: Option<String>,
11158 #[serde(default)]
11161 #[secret]
11162 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11163 pub verify_token: Option<String>,
11164 #[serde(default)]
11168 #[secret]
11169 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11170 pub app_secret: Option<String>,
11171 #[serde(default)]
11174 pub session_path: Option<String>,
11175 #[serde(default)]
11179 pub pair_phone: Option<String>,
11180 #[serde(default)]
11183 pub pair_code: Option<String>,
11184 #[serde(default)]
11188 pub ws_url: Option<String>,
11189 #[serde(default)]
11193 pub mention_only: bool,
11194 #[serde(default)]
11198 pub mode: WhatsAppWebMode,
11199 #[serde(default)]
11202 pub dm_policy: WhatsAppChatPolicy,
11203 #[serde(default)]
11206 pub group_policy: WhatsAppChatPolicy,
11207 #[serde(default)]
11210 pub self_chat_mode: bool,
11211 #[serde(default)]
11216 pub dm_mention_patterns: Vec<String>,
11217 #[serde(default)]
11222 pub group_mention_patterns: Vec<String>,
11223 #[serde(default)]
11226 pub proxy_url: Option<String>,
11227 #[serde(default = "default_channel_approval_timeout_secs")]
11229 pub approval_timeout_secs: u64,
11230
11231 #[serde(default)]
11234 pub excluded_tools: Vec<String>,
11235
11236 #[serde(default, skip_serializing_if = "Option::is_none")]
11240 pub default_target: Option<String>,
11241}
11242
11243impl ChannelConfig for WhatsAppConfig {
11244 fn name() -> &'static str {
11245 "WhatsApp"
11246 }
11247 fn desc() -> &'static str {
11248 "Business Cloud API"
11249 }
11250}
11251
11252#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11253#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11254#[prefix = "channels.linq"]
11255pub struct LinqConfig {
11256 #[serde(default)]
11261 pub enabled: bool,
11262 #[secret]
11264 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11265 pub api_token: String,
11266 pub from_phone: String,
11268 #[serde(default)]
11270 #[secret]
11271 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11272 pub signing_secret: Option<String>,
11273
11274 #[serde(default)]
11277 pub excluded_tools: Vec<String>,
11278}
11279
11280impl ChannelConfig for LinqConfig {
11281 fn name() -> &'static str {
11282 "Linq"
11283 }
11284 fn desc() -> &'static str {
11285 "iMessage/RCS/SMS via Linq API"
11286 }
11287}
11288
11289#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11291#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11292#[prefix = "channels.wati"]
11293pub struct WatiConfig {
11294 #[serde(default)]
11299 pub enabled: bool,
11300 #[secret]
11302 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11303 pub api_token: String,
11304 #[serde(default = "default_wati_api_url")]
11306 pub api_url: String,
11307 #[serde(default)]
11309 pub tenant_id: Option<String>,
11310 #[serde(default)]
11313 pub proxy_url: Option<String>,
11314
11315 #[serde(default)]
11318 pub excluded_tools: Vec<String>,
11319}
11320
11321fn default_wati_api_url() -> String {
11322 "https://live-mt-server.wati.io".to_string()
11323}
11324
11325impl ChannelConfig for WatiConfig {
11326 fn name() -> &'static str {
11327 "WATI"
11328 }
11329 fn desc() -> &'static str {
11330 "WhatsApp via WATI Business API"
11331 }
11332}
11333
11334#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11336#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11337#[prefix = "channels.nextcloud-talk"]
11338pub struct NextcloudTalkConfig {
11339 #[serde(default)]
11344 pub enabled: bool,
11345 pub base_url: String,
11347 #[secret]
11349 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11350 pub app_token: String,
11351 #[serde(default)]
11355 #[secret]
11356 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11357 pub webhook_secret: Option<String>,
11358 #[serde(default)]
11361 pub proxy_url: Option<String>,
11362 #[serde(default)]
11366 pub bot_name: Option<String>,
11367 #[serde(default)]
11370 pub excluded_tools: Vec<String>,
11371 #[serde(default)]
11377 pub stream_mode: StreamMode,
11378 #[serde(default = "default_draft_update_interval_ms")]
11381 pub draft_update_interval_ms: u64,
11382}
11383
11384impl ChannelConfig for NextcloudTalkConfig {
11385 fn name() -> &'static str {
11386 "NextCloud Talk"
11387 }
11388 fn desc() -> &'static str {
11389 "NextCloud Talk platform"
11390 }
11391}
11392
11393impl WhatsAppConfig {
11394 pub fn backend_type(&self) -> &'static str {
11397 if self.phone_number_id.is_some() {
11398 "cloud"
11399 } else if self.session_path.is_some() {
11400 "web"
11401 } else {
11402 "cloud"
11404 }
11405 }
11406
11407 pub fn is_cloud_config(&self) -> bool {
11409 self.phone_number_id.is_some() && self.access_token.is_some() && self.verify_token.is_some()
11410 }
11411
11412 pub fn is_web_config(&self) -> bool {
11414 self.session_path.is_some()
11415 }
11416
11417 pub fn is_ambiguous_config(&self) -> bool {
11421 self.phone_number_id.is_some() && self.session_path.is_some()
11422 }
11423}
11424
11425#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11430#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11431#[prefix = "channels.mqtt"]
11432pub struct MqttConfig {
11433 #[serde(default)]
11438 pub enabled: bool,
11439 pub broker_url: String,
11442 pub client_id: String,
11444 #[serde(default)]
11447 pub topics: Vec<String>,
11448 #[serde(default = "default_mqtt_qos")]
11450 pub qos: u8,
11451 pub username: Option<String>,
11453 #[secret]
11455 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11456 pub password: Option<String>,
11457 #[serde(default)]
11461 pub use_tls: bool,
11462 #[serde(default = "default_mqtt_keep_alive_secs")]
11464 pub keep_alive_secs: u64,
11465
11466 #[serde(default)]
11469 pub excluded_tools: Vec<String>,
11470}
11471
11472impl MqttConfig {
11473 pub fn validate(&self) -> anyhow::Result<()> {
11482 if self.qos > 2 {
11484 anyhow::bail!("qos must be 0, 1, or 2, got {}", self.qos);
11485 }
11486
11487 let is_tls_scheme = self.broker_url.starts_with("mqtts://");
11489 let is_mqtt_scheme = self.broker_url.starts_with("mqtt://");
11490
11491 if !is_tls_scheme && !is_mqtt_scheme {
11492 anyhow::bail!(
11493 "broker_url must start with 'mqtt://' or 'mqtts://', got: {}",
11494 self.broker_url
11495 );
11496 }
11497
11498 if is_mqtt_scheme && self.use_tls {
11500 anyhow::bail!("use_tls is true but broker_url uses 'mqtt://' (not 'mqtts://')");
11501 }
11502
11503 if is_tls_scheme && !self.use_tls {
11504 anyhow::bail!(
11505 "use_tls is false but broker_url uses 'mqtts://' (requires use_tls: true)"
11506 );
11507 }
11508
11509 if self.topics.is_empty() {
11511 anyhow::bail!("at least one topic must be configured");
11512 }
11513
11514 if self.client_id.is_empty() {
11516 validation_bail!(
11517 RequiredFieldEmpty,
11518 "client_id",
11519 "client_id must not be empty"
11520 );
11521 }
11522
11523 Ok(())
11524 }
11525}
11526
11527impl ChannelConfig for MqttConfig {
11528 fn name() -> &'static str {
11529 "MQTT"
11530 }
11531 fn desc() -> &'static str {
11532 "MQTT SOP Listener"
11533 }
11534}
11535
11536fn default_mqtt_qos() -> u8 {
11537 1
11538}
11539
11540fn default_mqtt_keep_alive_secs() -> u64 {
11541 30
11542}
11543
11544#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11546#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11547#[prefix = "channels.irc"]
11548pub struct IrcConfig {
11549 #[serde(default)]
11554 pub enabled: bool,
11555 pub server: String,
11557 #[serde(default = "default_irc_port")]
11559 pub port: u16,
11560 pub nickname: String,
11562 pub username: Option<String>,
11564 #[serde(default)]
11566 pub channels: Vec<String>,
11567 #[secret]
11569 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11570 pub server_password: Option<String>,
11571 #[secret]
11573 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11574 pub nickserv_password: Option<String>,
11575 #[secret]
11577 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11578 pub sasl_password: Option<String>,
11579 pub verify_tls: Option<bool>,
11581 #[serde(default)]
11584 pub mention_only: bool,
11585
11586 #[serde(default)]
11589 pub excluded_tools: Vec<String>,
11590
11591 #[serde(default, skip_serializing_if = "Option::is_none")]
11595 pub default_target: Option<String>,
11596}
11597
11598impl ChannelConfig for IrcConfig {
11599 fn name() -> &'static str {
11600 "IRC"
11601 }
11602 fn desc() -> &'static str {
11603 "IRC over TLS"
11604 }
11605}
11606
11607fn default_irc_port() -> u16 {
11608 6697
11609}
11610
11611#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
11616#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11617#[serde(rename_all = "lowercase")]
11618pub enum LarkReceiveMode {
11619 #[default]
11620 Websocket,
11621 Webhook,
11622}
11623
11624#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11627#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11628#[prefix = "channels.lark"]
11629pub struct LarkConfig {
11630 #[serde(default)]
11635 pub enabled: bool,
11636 pub app_id: String,
11638 #[secret]
11640 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11641 pub app_secret: String,
11642 #[serde(default)]
11644 #[secret]
11645 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11646 pub encrypt_key: Option<String>,
11647 #[serde(default)]
11649 #[secret]
11650 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11651 pub verification_token: Option<String>,
11652 #[serde(default)]
11655 pub mention_only: bool,
11656 #[serde(default)]
11658 pub use_feishu: bool,
11659 #[serde(default)]
11661 pub receive_mode: LarkReceiveMode,
11662 #[serde(default)]
11665 pub port: Option<u16>,
11666 #[serde(default)]
11669 pub proxy_url: Option<String>,
11670
11671 #[serde(default)]
11674 pub excluded_tools: Vec<String>,
11675
11676 #[serde(default, skip_serializing_if = "Option::is_none")]
11680 pub default_target: Option<String>,
11681}
11682
11683impl ChannelConfig for LarkConfig {
11684 fn name() -> &'static str {
11685 "Lark"
11686 }
11687 fn desc() -> &'static str {
11688 "Lark Bot"
11689 }
11690}
11691
11692#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
11694#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11695#[serde(rename_all = "lowercase")]
11696pub enum LineDmPolicy {
11697 Open,
11699 #[default]
11702 Pairing,
11703 Allowlist,
11705}
11706
11707#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
11709#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11710#[serde(rename_all = "lowercase")]
11711pub enum LineGroupPolicy {
11712 Open,
11714 #[default]
11716 Mention,
11717 Disabled,
11719}
11720
11721#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11723#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11724#[prefix = "channels.line"]
11725pub struct LineConfig {
11726 #[serde(default)]
11731 pub enabled: bool,
11732 #[serde(default)]
11736 #[secret]
11737 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11738 pub channel_access_token: String,
11739 #[serde(default)]
11743 #[secret]
11744 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11745 pub channel_secret: String,
11746 #[serde(default)]
11752 pub dm_policy: LineDmPolicy,
11753 #[serde(default)]
11759 pub group_policy: LineGroupPolicy,
11760 #[serde(default = "default_line_webhook_port")]
11762 pub webhook_port: u16,
11763 #[serde(default)]
11766 pub proxy_url: Option<String>,
11767
11768 #[serde(default)]
11771 pub excluded_tools: Vec<String>,
11772}
11773
11774fn default_line_webhook_port() -> u16 {
11775 8443
11776}
11777
11778impl ChannelConfig for LineConfig {
11779 fn name() -> &'static str {
11780 "LINE"
11781 }
11782 fn desc() -> &'static str {
11783 "connect your LINE bot"
11784 }
11785}
11786
11787#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
11795#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11796#[prefix = "security"]
11797pub struct SecurityConfig {
11798 #[serde(default)]
11800 #[nested]
11801 pub audit: AuditConfig,
11802
11803 #[serde(default)]
11805 #[nested]
11806 pub otp: OtpConfig,
11807
11808 #[serde(default)]
11810 #[nested]
11811 pub estop: EstopConfig,
11812
11813 #[serde(default)]
11815 #[nested]
11816 pub nevis: NevisConfig,
11817
11818 #[serde(default)]
11820 #[nested]
11821 pub webauthn: WebAuthnConfig,
11822}
11823
11824#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
11829#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11830#[prefix = "security.webauthn"]
11831pub struct WebAuthnConfig {
11832 #[serde(default)]
11834 pub enabled: bool,
11835 #[serde(default = "default_webauthn_rp_id")]
11837 pub rp_id: String,
11838 #[serde(default = "default_webauthn_rp_origin")]
11840 pub rp_origin: String,
11841 #[serde(default = "default_webauthn_rp_name")]
11843 pub rp_name: String,
11844}
11845
11846impl Default for WebAuthnConfig {
11847 fn default() -> Self {
11848 Self {
11849 enabled: false,
11850 rp_id: default_webauthn_rp_id(),
11851 rp_origin: default_webauthn_rp_origin(),
11852 rp_name: default_webauthn_rp_name(),
11853 }
11854 }
11855}
11856
11857fn default_webauthn_rp_id() -> String {
11858 "localhost".into()
11859}
11860
11861fn default_webauthn_rp_origin() -> String {
11862 "http://localhost:42617".into()
11863}
11864
11865fn default_webauthn_rp_name() -> String {
11866 "ZeroClaw".into()
11867}
11868
11869#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
11871#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11872#[serde(rename_all = "kebab-case")]
11873pub enum OtpMethod {
11874 #[default]
11876 Totp,
11877 Pairing,
11879 CliPrompt,
11881}
11882
11883#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
11885#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11886#[prefix = "security.otp"]
11887#[serde(deny_unknown_fields)]
11888pub struct OtpConfig {
11889 #[serde(default)]
11891 pub enabled: bool,
11892
11893 #[serde(default)]
11895 pub method: OtpMethod,
11896
11897 #[serde(default = "default_otp_token_ttl_secs")]
11899 pub token_ttl_secs: u64,
11900
11901 #[serde(default = "default_otp_cache_valid_secs")]
11903 pub cache_valid_secs: u64,
11904
11905 #[serde(default = "default_otp_gated_actions")]
11907 pub gated_actions: Vec<String>,
11908
11909 #[serde(default)]
11911 pub gated_domains: Vec<String>,
11912
11913 #[serde(default)]
11915 pub gated_domain_categories: Vec<String>,
11916
11917 #[serde(default = "default_otp_challenge_max_attempts")]
11919 pub challenge_max_attempts: u32,
11920}
11921
11922fn default_otp_token_ttl_secs() -> u64 {
11923 30
11924}
11925
11926fn default_otp_cache_valid_secs() -> u64 {
11927 300
11928}
11929
11930fn default_otp_challenge_max_attempts() -> u32 {
11931 3
11932}
11933
11934fn default_otp_gated_actions() -> Vec<String> {
11935 vec![
11936 "shell".to_string(),
11937 "file_write".to_string(),
11938 "browser_open".to_string(),
11939 "browser".to_string(),
11940 "memory_forget".to_string(),
11941 ]
11942}
11943
11944impl Default for OtpConfig {
11945 fn default() -> Self {
11946 Self {
11947 enabled: false,
11948 method: OtpMethod::Totp,
11949 token_ttl_secs: default_otp_token_ttl_secs(),
11950 cache_valid_secs: default_otp_cache_valid_secs(),
11951 gated_actions: default_otp_gated_actions(),
11952 gated_domains: Vec::new(),
11953 gated_domain_categories: Vec::new(),
11954 challenge_max_attempts: default_otp_challenge_max_attempts(),
11955 }
11956 }
11957}
11958
11959#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
11961#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11962#[prefix = "security.estop"]
11963#[serde(deny_unknown_fields)]
11964pub struct EstopConfig {
11965 #[serde(default)]
11967 pub enabled: bool,
11968
11969 #[serde(default = "default_estop_state_file")]
11971 pub state_file: String,
11972
11973 #[serde(default = "default_true")]
11975 pub require_otp_to_resume: bool,
11976}
11977
11978fn default_estop_state_file() -> String {
11979 default_path_under_config_dir("estop-state.json")
11980}
11981
11982impl Default for EstopConfig {
11983 fn default() -> Self {
11984 Self {
11985 enabled: false,
11986 state_file: default_estop_state_file(),
11987 require_otp_to_resume: true,
11988 }
11989 }
11990}
11991
11992#[derive(Clone, Serialize, Deserialize, Configurable)]
11997#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11998#[prefix = "security.nevis"]
11999#[serde(deny_unknown_fields)]
12000pub struct NevisConfig {
12001 #[serde(default)]
12003 pub enabled: bool,
12004
12005 #[serde(default)]
12007 pub instance_url: String,
12008
12009 #[serde(default = "default_nevis_realm")]
12011 pub realm: String,
12012
12013 #[serde(default)]
12015 pub client_id: String,
12016
12017 #[serde(default)]
12019 #[secret]
12020 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12021 pub client_secret: Option<String>,
12022
12023 #[serde(default = "default_nevis_token_validation")]
12025 pub token_validation: String,
12026
12027 #[serde(default)]
12029 pub jwks_url: Option<String>,
12030
12031 #[serde(default)]
12033 pub role_mapping: Vec<NevisRoleMappingConfig>,
12034
12035 #[serde(default)]
12037 pub require_mfa: bool,
12038
12039 #[serde(default = "default_nevis_session_timeout_secs")]
12041 pub session_timeout_secs: u64,
12042}
12043
12044impl std::fmt::Debug for NevisConfig {
12045 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12046 f.debug_struct("NevisConfig")
12047 .field("enabled", &self.enabled)
12048 .field("instance_url", &self.instance_url)
12049 .field("realm", &self.realm)
12050 .field("client_id", &self.client_id)
12051 .field(
12052 "client_secret",
12053 &self.client_secret.as_ref().map(|_| "[REDACTED]"),
12054 )
12055 .field("token_validation", &self.token_validation)
12056 .field("jwks_url", &self.jwks_url)
12057 .field("role_mapping", &self.role_mapping)
12058 .field("require_mfa", &self.require_mfa)
12059 .field("session_timeout_secs", &self.session_timeout_secs)
12060 .finish()
12061 }
12062}
12063
12064impl NevisConfig {
12065 pub fn validate(&self) -> Result<(), String> {
12070 if !self.enabled {
12071 return Ok(());
12072 }
12073
12074 if self.instance_url.trim().is_empty() {
12075 return Err("nevis.instance_url is required when Nevis IAM is enabled".into());
12076 }
12077
12078 if self.client_id.trim().is_empty() {
12079 return Err("nevis.client_id is required when Nevis IAM is enabled".into());
12080 }
12081
12082 if self.realm.trim().is_empty() {
12083 return Err("nevis.realm is required when Nevis IAM is enabled".into());
12084 }
12085
12086 match self.token_validation.as_str() {
12087 "local" | "remote" => {}
12088 other => {
12089 return Err(format!(
12090 "nevis.token_validation has invalid value '{other}': \
12091 expected 'local' or 'remote'"
12092 ));
12093 }
12094 }
12095
12096 if self.token_validation == "local" && self.jwks_url.is_none() {
12097 return Err("nevis.jwks_url is required when token_validation is 'local'".into());
12098 }
12099
12100 if self.session_timeout_secs == 0 {
12101 return Err("nevis.session_timeout_secs must be greater than 0".into());
12102 }
12103
12104 Ok(())
12105 }
12106}
12107
12108fn default_nevis_realm() -> String {
12109 "master".into()
12110}
12111
12112fn default_nevis_token_validation() -> String {
12113 "local".into()
12114}
12115
12116fn default_nevis_session_timeout_secs() -> u64 {
12117 3600
12118}
12119
12120impl Default for NevisConfig {
12121 fn default() -> Self {
12122 Self {
12123 enabled: false,
12124 instance_url: String::new(),
12125 realm: default_nevis_realm(),
12126 client_id: String::new(),
12127 client_secret: None,
12128 token_validation: default_nevis_token_validation(),
12129 jwks_url: None,
12130 role_mapping: Vec::new(),
12131 require_mfa: false,
12132 session_timeout_secs: default_nevis_session_timeout_secs(),
12133 }
12134 }
12135}
12136
12137#[derive(Debug, Clone, Serialize, Deserialize)]
12139#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12140#[serde(deny_unknown_fields)]
12141pub struct NevisRoleMappingConfig {
12142 pub nevis_role: String,
12144
12145 #[serde(default)]
12147 pub zeroclaw_permissions: Vec<String>,
12148
12149 #[serde(default)]
12151 pub workspace_access: Vec<String>,
12152}
12153
12154#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12156#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12157#[prefix = "security.sandbox"]
12158pub struct SandboxConfig {
12159 #[serde(default)]
12161 pub enabled: Option<bool>,
12162
12163 #[serde(default)]
12165 pub backend: SandboxBackend,
12166
12167 #[serde(default)]
12169 pub firejail_args: Vec<String>,
12170}
12171
12172impl Default for SandboxConfig {
12173 fn default() -> Self {
12174 Self {
12175 enabled: None, backend: SandboxBackend::Auto,
12177 firejail_args: Vec::new(),
12178 }
12179 }
12180}
12181
12182#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12184#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12185#[serde(rename_all = "lowercase")]
12186pub enum SandboxBackend {
12187 #[default]
12189 Auto,
12190 Landlock,
12192 Firejail,
12194 Bubblewrap,
12196 Docker,
12198 #[serde(alias = "sandbox-exec")]
12200 SandboxExec,
12201 None,
12203}
12204
12205#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12207#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12208#[prefix = "security.audit"]
12209pub struct AuditConfig {
12210 #[serde(default = "default_audit_enabled")]
12212 pub enabled: bool,
12213
12214 #[serde(default = "default_audit_log_path")]
12216 pub log_path: String,
12217
12218 #[serde(default = "default_audit_max_size_mb")]
12220 pub max_size_mb: u32,
12221
12222 #[serde(default)]
12224 pub sign_events: bool,
12225}
12226
12227fn default_audit_enabled() -> bool {
12228 true
12229}
12230
12231fn default_audit_log_path() -> String {
12232 "audit.log".to_string()
12233}
12234
12235fn default_audit_max_size_mb() -> u32 {
12236 100
12237}
12238
12239impl Default for AuditConfig {
12240 fn default() -> Self {
12241 Self {
12242 enabled: default_audit_enabled(),
12243 log_path: default_audit_log_path(),
12244 max_size_mb: default_audit_max_size_mb(),
12245 sign_events: false,
12246 }
12247 }
12248}
12249
12250#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12252#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12253#[prefix = "channels.dingtalk"]
12254pub struct DingTalkConfig {
12255 #[serde(default)]
12260 pub enabled: bool,
12261 pub client_id: String,
12263 #[secret]
12265 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12266 pub client_secret: String,
12267 #[serde(default)]
12270 pub proxy_url: Option<String>,
12271
12272 #[serde(default)]
12275 pub excluded_tools: Vec<String>,
12276}
12277
12278impl ChannelConfig for DingTalkConfig {
12279 fn name() -> &'static str {
12280 "DingTalk"
12281 }
12282 fn desc() -> &'static str {
12283 "DingTalk Stream Mode"
12284 }
12285}
12286
12287#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12289#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12290#[prefix = "channels.wecom"]
12291pub struct WeComConfig {
12292 #[serde(default)]
12297 pub enabled: bool,
12298 #[secret]
12300 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12301 pub webhook_key: String,
12302
12303 #[serde(default)]
12306 pub excluded_tools: Vec<String>,
12307}
12308
12309impl ChannelConfig for WeComConfig {
12310 fn name() -> &'static str {
12311 "WeCom"
12312 }
12313 fn desc() -> &'static str {
12314 "WeCom Bot Webhook"
12315 }
12316}
12317
12318fn default_wecom_ws_file_retention_days() -> u32 {
12319 7
12320}
12321
12322fn default_wecom_ws_max_file_size_mb() -> u64 {
12323 20
12324}
12325
12326fn default_wecom_ws_stream_mode() -> StreamMode {
12327 StreamMode::Partial
12328}
12329
12330#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12335#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12336#[prefix = "channels.wecom_ws"]
12337pub struct WeComWsConfig {
12338 #[serde(default)]
12343 pub enabled: bool,
12344 pub bot_id: String,
12346 #[secret]
12348 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12349 pub secret: String,
12350 #[serde(default)]
12352 pub allowed_users: Vec<String>,
12353 #[serde(default)]
12355 pub allowed_groups: Vec<String>,
12356 #[serde(default)]
12362 pub bot_name: Option<String>,
12363 #[serde(default = "default_wecom_ws_file_retention_days")]
12365 pub file_retention_days: u32,
12366 #[serde(default = "default_wecom_ws_max_file_size_mb")]
12368 pub max_file_size_mb: u64,
12369 #[serde(default = "default_wecom_ws_stream_mode")]
12371 pub stream_mode: StreamMode,
12372 #[serde(default)]
12374 pub proxy_url: Option<String>,
12375 #[serde(default)]
12378 pub excluded_tools: Vec<String>,
12379}
12380
12381impl Default for WeComWsConfig {
12382 fn default() -> Self {
12383 Self {
12384 enabled: false,
12385 bot_id: String::new(),
12386 secret: String::new(),
12387 allowed_users: Vec::new(),
12388 allowed_groups: Vec::new(),
12389 bot_name: None,
12390 file_retention_days: default_wecom_ws_file_retention_days(),
12391 max_file_size_mb: default_wecom_ws_max_file_size_mb(),
12392 stream_mode: default_wecom_ws_stream_mode(),
12393 proxy_url: None,
12394 excluded_tools: Vec::new(),
12395 }
12396 }
12397}
12398
12399impl ChannelConfig for WeComWsConfig {
12400 fn name() -> &'static str {
12401 "WeCom WebSocket"
12402 }
12403 fn desc() -> &'static str {
12404 "WeCom AI Bot long connection"
12405 }
12406}
12407
12408#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12414#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12415#[prefix = "channels.wechat"]
12416pub struct WeChatConfig {
12417 #[serde(default)]
12422 pub enabled: bool,
12423 #[serde(default)]
12425 pub api_base_url: Option<String>,
12426 #[serde(default)]
12428 pub cdn_base_url: Option<String>,
12429 #[serde(default)]
12432 pub state_dir: Option<String>,
12433
12434 #[serde(default)]
12437 pub excluded_tools: Vec<String>,
12438}
12439
12440impl ChannelConfig for WeChatConfig {
12441 fn name() -> &'static str {
12442 "WeChat"
12443 }
12444 fn desc() -> &'static str {
12445 "WeChat iLink Bot"
12446 }
12447}
12448
12449#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12451#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12452#[prefix = "channels.qq"]
12453pub struct QQConfig {
12454 #[serde(default)]
12459 pub enabled: bool,
12460 pub app_id: String,
12462 #[secret]
12464 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12465 pub app_secret: String,
12466 #[serde(default)]
12469 pub proxy_url: Option<String>,
12470
12471 #[serde(default)]
12474 pub excluded_tools: Vec<String>,
12475}
12476
12477impl ChannelConfig for QQConfig {
12478 fn name() -> &'static str {
12479 "QQ Official"
12480 }
12481 fn desc() -> &'static str {
12482 "Tencent QQ Bot"
12483 }
12484}
12485
12486#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12488#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12489#[prefix = "channels.twitter"]
12490pub struct TwitterConfig {
12491 #[serde(default)]
12496 pub enabled: bool,
12497 #[secret]
12499 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12500 pub bearer_token: String,
12501
12502 #[serde(default)]
12505 pub excluded_tools: Vec<String>,
12506}
12507
12508impl ChannelConfig for TwitterConfig {
12509 fn name() -> &'static str {
12510 "X/Twitter"
12511 }
12512 fn desc() -> &'static str {
12513 "X/Twitter Bot via API v2"
12514 }
12515}
12516
12517#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12519#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12520#[prefix = "channels.mochat"]
12521pub struct MochatConfig {
12522 #[serde(default)]
12527 pub enabled: bool,
12528 pub api_url: String,
12530 #[secret]
12532 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12533 pub api_token: String,
12534 #[serde(default = "default_mochat_poll_interval")]
12536 pub poll_interval_secs: u64,
12537
12538 #[serde(default)]
12541 pub excluded_tools: Vec<String>,
12542}
12543
12544fn default_mochat_poll_interval() -> u64 {
12545 5
12546}
12547
12548impl ChannelConfig for MochatConfig {
12549 fn name() -> &'static str {
12550 "Mochat"
12551 }
12552 fn desc() -> &'static str {
12553 "Mochat Customer Service"
12554 }
12555}
12556
12557#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12559#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12560#[prefix = "channels.reddit"]
12561pub struct RedditConfig {
12562 #[serde(default)]
12567 pub enabled: bool,
12568 pub client_id: String,
12570 #[secret]
12572 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12573 pub client_secret: String,
12574 #[secret]
12576 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12577 pub refresh_token: String,
12578 pub username: String,
12580 #[serde(default)]
12584 pub subreddits: Vec<String>,
12585
12586 #[serde(default)]
12589 pub excluded_tools: Vec<String>,
12590}
12591
12592impl ChannelConfig for RedditConfig {
12593 fn name() -> &'static str {
12594 "Reddit"
12595 }
12596 fn desc() -> &'static str {
12597 "Reddit bot (OAuth2)"
12598 }
12599}
12600
12601#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12603#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12604#[prefix = "channels.bluesky"]
12605pub struct BlueskyConfig {
12606 #[serde(default)]
12611 pub enabled: bool,
12612 pub handle: String,
12614 #[secret]
12616 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12617 pub app_password: String,
12618
12619 #[serde(default)]
12622 pub excluded_tools: Vec<String>,
12623}
12624
12625impl ChannelConfig for BlueskyConfig {
12626 fn name() -> &'static str {
12627 "Bluesky"
12628 }
12629 fn desc() -> &'static str {
12630 "AT Protocol"
12631 }
12632}
12633
12634#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
12639#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12640pub struct VoiceDuplexConfig {
12641 #[serde(default)]
12646 pub enabled: bool,
12647 #[serde(default)]
12650 pub excluded_tools: Vec<String>,
12651}
12652
12653#[derive(Debug, Clone, Serialize, Deserialize, zeroclaw_macros::Configurable)]
12659#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12660#[prefix = "voice-wake"]
12661pub struct VoiceWakeConfig {
12662 #[serde(default)]
12667 pub enabled: bool,
12668 #[serde(default = "default_voice_wake_word")]
12671 pub wake_word: String,
12672 #[serde(default = "default_voice_wake_silence_timeout_ms")]
12675 pub silence_timeout_ms: u32,
12676 #[serde(default = "default_voice_wake_energy_threshold")]
12679 pub energy_threshold: f32,
12680 #[serde(default = "default_voice_wake_max_capture_secs")]
12683 pub max_capture_secs: u32,
12684
12685 #[serde(default)]
12688 pub excluded_tools: Vec<String>,
12689}
12690
12691fn default_voice_wake_word() -> String {
12692 "hey zeroclaw".into()
12693}
12694
12695fn default_voice_wake_silence_timeout_ms() -> u32 {
12696 2000
12697}
12698
12699fn default_voice_wake_energy_threshold() -> f32 {
12700 0.01
12701}
12702
12703fn default_voice_wake_max_capture_secs() -> u32 {
12704 30
12705}
12706
12707impl Default for VoiceWakeConfig {
12708 fn default() -> Self {
12709 Self {
12710 enabled: false,
12711 wake_word: default_voice_wake_word(),
12712 silence_timeout_ms: default_voice_wake_silence_timeout_ms(),
12713 energy_threshold: default_voice_wake_energy_threshold(),
12714 max_capture_secs: default_voice_wake_max_capture_secs(),
12715 excluded_tools: Vec::new(),
12716 }
12717 }
12718}
12719
12720impl ChannelConfig for VoiceWakeConfig {
12721 fn name() -> &'static str {
12722 "VoiceWake"
12723 }
12724 fn desc() -> &'static str {
12725 "voice wake word detection"
12726 }
12727}
12728
12729#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12731#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12732#[prefix = "channels.nostr"]
12733pub struct NostrConfig {
12734 #[serde(default)]
12739 pub enabled: bool,
12740 #[secret]
12742 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12743 pub private_key: String,
12744 #[serde(default = "default_nostr_relays")]
12746 pub relays: Vec<String>,
12747
12748 #[serde(default)]
12751 pub excluded_tools: Vec<String>,
12752}
12753
12754impl ChannelConfig for NostrConfig {
12755 fn name() -> &'static str {
12756 "Nostr"
12757 }
12758 fn desc() -> &'static str {
12759 "Nostr DMs"
12760 }
12761}
12762
12763pub fn default_nostr_relays() -> Vec<String> {
12764 vec![
12765 "wss://relay.damus.io".to_string(),
12766 "wss://nos.lol".to_string(),
12767 "wss://relay.primal.net".to_string(),
12768 "wss://relay.snort.social".to_string(),
12769 ]
12770}
12771
12772#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12780#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12781#[prefix = "notion"]
12782pub struct NotionConfig {
12783 #[serde(default)]
12784 pub enabled: bool,
12785 #[serde(default)]
12786 #[secret]
12787 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12788 pub api_key: String,
12789 #[serde(default)]
12790 pub database_id: String,
12791 #[serde(default = "default_notion_poll_interval")]
12792 pub poll_interval_secs: u64,
12793 #[serde(default = "default_notion_status_prop")]
12794 pub status_property: String,
12795 #[serde(default = "default_notion_input_prop")]
12796 pub input_property: String,
12797 #[serde(default = "default_notion_result_prop")]
12798 pub result_property: String,
12799 #[serde(default = "default_notion_max_concurrent")]
12800 pub max_concurrent: usize,
12801 #[serde(default = "default_notion_recover_stale")]
12802 pub recover_stale: bool,
12803}
12804
12805fn default_notion_poll_interval() -> u64 {
12806 5
12807}
12808fn default_notion_status_prop() -> String {
12809 "Status".into()
12810}
12811fn default_notion_input_prop() -> String {
12812 "Input".into()
12813}
12814fn default_notion_result_prop() -> String {
12815 "Result".into()
12816}
12817fn default_notion_max_concurrent() -> usize {
12818 4
12819}
12820fn default_notion_recover_stale() -> bool {
12821 true
12822}
12823
12824impl Default for NotionConfig {
12825 fn default() -> Self {
12826 Self {
12827 enabled: false,
12828 api_key: String::new(),
12829 database_id: String::new(),
12830 poll_interval_secs: default_notion_poll_interval(),
12831 status_property: default_notion_status_prop(),
12832 input_property: default_notion_input_prop(),
12833 result_property: default_notion_result_prop(),
12834 max_concurrent: default_notion_max_concurrent(),
12835 recover_stale: default_notion_recover_stale(),
12836 }
12837 }
12838}
12839
12840#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12858#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12859#[prefix = "jira"]
12860pub struct JiraConfig {
12861 #[serde(default)]
12863 pub enabled: bool,
12864 #[serde(default)]
12866 pub base_url: String,
12867 #[serde(
12875 default,
12876 skip_serializing_if = "Option::is_none",
12877 deserialize_with = "deserialize_optional_email_skip_empty"
12878 )]
12879 pub email: Option<String>,
12880 #[serde(default)]
12882 #[secret]
12883 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12884 pub api_token: String,
12885 #[serde(default = "default_jira_allowed_actions")]
12891 pub allowed_actions: Vec<String>,
12892 #[serde(default = "default_jira_timeout_secs")]
12894 pub timeout_secs: u64,
12895}
12896
12897fn default_jira_allowed_actions() -> Vec<String> {
12898 vec!["get_ticket".to_string()]
12899}
12900
12901fn default_jira_timeout_secs() -> u64 {
12902 30
12903}
12904
12905impl Default for JiraConfig {
12906 fn default() -> Self {
12907 Self {
12908 enabled: false,
12909 base_url: String::new(),
12910 email: None,
12911 api_token: String::new(),
12912 allowed_actions: default_jira_allowed_actions(),
12913 timeout_secs: default_jira_timeout_secs(),
12914 }
12915 }
12916}
12917
12918#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12922#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12923#[prefix = "cloud-ops"]
12924pub struct CloudOpsConfig {
12925 #[serde(default)]
12927 pub enabled: bool,
12928 #[serde(default = "default_cloud_ops_cloud")]
12930 pub default_cloud: String,
12931 #[serde(default = "default_cloud_ops_supported_clouds")]
12933 pub supported_clouds: Vec<String>,
12934 #[serde(default = "default_cloud_ops_iac_tools")]
12936 pub iac_tools: Vec<String>,
12937 #[serde(default = "default_cloud_ops_cost_threshold")]
12939 pub cost_threshold_monthly_usd: f64,
12940 #[serde(default = "default_cloud_ops_waf")]
12942 pub well_architected_frameworks: Vec<String>,
12943}
12944
12945impl Default for CloudOpsConfig {
12946 fn default() -> Self {
12947 Self {
12948 enabled: false,
12949 default_cloud: default_cloud_ops_cloud(),
12950 supported_clouds: default_cloud_ops_supported_clouds(),
12951 iac_tools: default_cloud_ops_iac_tools(),
12952 cost_threshold_monthly_usd: default_cloud_ops_cost_threshold(),
12953 well_architected_frameworks: default_cloud_ops_waf(),
12954 }
12955 }
12956}
12957
12958impl CloudOpsConfig {
12959 pub fn validate(&self) -> Result<()> {
12960 if self.enabled {
12961 if self.default_cloud.trim().is_empty() {
12962 anyhow::bail!(
12963 "cloud_ops.default_cloud must not be empty when cloud_ops is enabled"
12964 );
12965 }
12966 if self.supported_clouds.is_empty() {
12967 anyhow::bail!(
12968 "cloud_ops.supported_clouds must not be empty when cloud_ops is enabled"
12969 );
12970 }
12971 for (i, cloud) in self.supported_clouds.iter().enumerate() {
12972 if cloud.trim().is_empty() {
12973 validation_bail!(
12974 RequiredFieldEmpty,
12975 format!("cloud_ops.supported_clouds[{i}]"),
12976 "cloud_ops.supported_clouds[{i}] must not be empty"
12977 );
12978 }
12979 }
12980 if !self.supported_clouds.contains(&self.default_cloud) {
12981 anyhow::bail!(
12982 "cloud_ops.default_cloud '{}' is not in cloud_ops.supported_clouds {:?}",
12983 self.default_cloud,
12984 self.supported_clouds
12985 );
12986 }
12987 if self.cost_threshold_monthly_usd < 0.0 {
12988 anyhow::bail!(
12989 "cloud_ops.cost_threshold_monthly_usd must be non-negative, got {}",
12990 self.cost_threshold_monthly_usd
12991 );
12992 }
12993 if self.iac_tools.is_empty() {
12994 anyhow::bail!("cloud_ops.iac_tools must not be empty when cloud_ops is enabled");
12995 }
12996 }
12997 Ok(())
12998 }
12999}
13000
13001fn default_cloud_ops_cloud() -> String {
13002 "aws".into()
13003}
13004
13005fn default_cloud_ops_supported_clouds() -> Vec<String> {
13006 vec!["aws".into(), "azure".into(), "gcp".into()]
13007}
13008
13009fn default_cloud_ops_iac_tools() -> Vec<String> {
13010 vec!["terraform".into()]
13011}
13012
13013fn default_cloud_ops_cost_threshold() -> f64 {
13014 100.0
13015}
13016
13017fn default_cloud_ops_waf() -> Vec<String> {
13018 vec!["aws-waf".into()]
13019}
13020
13021fn default_conversational_ai_language() -> String {
13024 "en".into()
13025}
13026
13027fn default_conversational_ai_supported_languages() -> Vec<String> {
13028 vec!["en".into(), "de".into(), "fr".into(), "it".into()]
13029}
13030
13031fn default_conversational_ai_escalation_threshold() -> f64 {
13032 0.3
13033}
13034
13035fn default_conversational_ai_max_turns() -> usize {
13036 50
13037}
13038
13039fn default_conversational_ai_timeout_secs() -> u64 {
13040 1800
13041}
13042
13043#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13048#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13049#[prefix = "conversational-ai"]
13050pub struct ConversationalAiConfig {
13051 #[serde(default)]
13053 pub enabled: bool,
13054 #[serde(default = "default_conversational_ai_language")]
13056 pub default_language: String,
13057 #[serde(default = "default_conversational_ai_supported_languages")]
13059 pub supported_languages: Vec<String>,
13060 #[serde(default = "default_true")]
13062 pub auto_detect_language: bool,
13063 #[serde(default = "default_conversational_ai_escalation_threshold")]
13065 pub escalation_confidence_threshold: f64,
13066 #[serde(default = "default_conversational_ai_max_turns")]
13068 pub max_conversation_turns: usize,
13069 #[serde(default = "default_conversational_ai_timeout_secs")]
13071 pub conversation_timeout_secs: u64,
13072 #[serde(default)]
13074 pub analytics_enabled: bool,
13075 #[serde(default)]
13077 pub knowledge_base_tool: Option<String>,
13078}
13079
13080impl ConversationalAiConfig {
13081 pub fn is_disabled(&self) -> bool {
13087 !self.enabled
13088 }
13089}
13090
13091impl Default for ConversationalAiConfig {
13092 fn default() -> Self {
13093 Self {
13094 enabled: false,
13095 default_language: default_conversational_ai_language(),
13096 supported_languages: default_conversational_ai_supported_languages(),
13097 auto_detect_language: true,
13098 escalation_confidence_threshold: default_conversational_ai_escalation_threshold(),
13099 max_conversation_turns: default_conversational_ai_max_turns(),
13100 conversation_timeout_secs: default_conversational_ai_timeout_secs(),
13101 analytics_enabled: false,
13102 knowledge_base_tool: None,
13103 }
13104 }
13105}
13106
13107#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13111#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13112#[prefix = "security-ops"]
13113pub struct SecurityOpsConfig {
13114 #[serde(default)]
13116 pub enabled: bool,
13117 #[serde(default = "default_playbooks_dir")]
13119 pub playbooks_dir: String,
13120 #[serde(default)]
13122 pub auto_triage: bool,
13123 #[serde(default = "default_require_approval")]
13125 pub require_approval_for_actions: bool,
13126 #[serde(default = "default_max_auto_severity")]
13129 pub max_auto_severity: String,
13130 #[serde(default = "default_report_output_dir")]
13132 pub report_output_dir: String,
13133 #[serde(default)]
13135 pub siem_integration: Option<String>,
13136}
13137
13138fn default_playbooks_dir() -> String {
13139 default_path_under_config_dir("playbooks")
13140}
13141
13142fn default_require_approval() -> bool {
13143 true
13144}
13145
13146fn default_max_auto_severity() -> String {
13147 "low".into()
13148}
13149
13150fn default_report_output_dir() -> String {
13151 default_path_under_config_dir("security-reports")
13152}
13153
13154impl Default for SecurityOpsConfig {
13155 fn default() -> Self {
13156 Self {
13157 enabled: false,
13158 playbooks_dir: default_playbooks_dir(),
13159 auto_triage: false,
13160 require_approval_for_actions: true,
13161 max_auto_severity: default_max_auto_severity(),
13162 report_output_dir: default_report_output_dir(),
13163 siem_integration: None,
13164 }
13165 }
13166}
13167
13168impl Default for Config {
13171 fn default() -> Self {
13172 let home =
13173 UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
13174 let zeroclaw_dir = home.join(".zeroclaw");
13175
13176 Self {
13177 data_dir: zeroclaw_dir.join("data"),
13178 config_path: zeroclaw_dir.join("config.toml"),
13179 env_overridden_paths: std::collections::HashSet::new(),
13180 pre_override_snapshots: std::collections::HashMap::new(),
13181 dirty_paths: std::collections::HashSet::new(),
13182 schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
13183 providers: crate::providers::Providers::default(),
13184 model_routes: Vec::new(),
13185 embedding_routes: Vec::new(),
13186 observability: ObservabilityConfig::default(),
13187 trust: crate::scattered_types::TrustConfig::default(),
13188 backup: BackupConfig::default(),
13189 data_retention: DataRetentionConfig::default(),
13190 cloud_ops: CloudOpsConfig::default(),
13191 conversational_ai: ConversationalAiConfig::default(),
13192 security: SecurityConfig::default(),
13193 security_ops: SecurityOpsConfig::default(),
13194 runtime: RuntimeConfig::default(),
13195 reliability: ReliabilityConfig::default(),
13196 scheduler: SchedulerConfig::default(),
13197 pacing: PacingConfig::default(),
13198 skills: SkillsConfig::default(),
13199 pipeline: PipelineConfig::default(),
13200 heartbeat: HeartbeatConfig::default(),
13201 cron: HashMap::new(),
13202 acp: AcpConfig::default(),
13203 channels: ChannelsConfig::default(),
13204 memory: MemoryConfig::default(),
13205 storage: StorageConfig::default(),
13206 tunnel: TunnelConfig::default(),
13207 gateway: GatewayConfig::default(),
13208 composio: ComposioConfig::default(),
13209 microsoft365: Microsoft365Config::default(),
13210 secrets: SecretsConfig::default(),
13211 browser: BrowserConfig::default(),
13212 browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
13213 http_request: HttpRequestConfig::default(),
13214 multimodal: MultimodalConfig::default(),
13215 media_pipeline: MediaPipelineConfig::default(),
13216 web_fetch: WebFetchConfig::default(),
13217 link_enricher: LinkEnricherConfig::default(),
13218 text_browser: TextBrowserConfig::default(),
13219 web_search: WebSearchConfig::default(),
13220 project_intel: ProjectIntelConfig::default(),
13221 google_workspace: GoogleWorkspaceConfig::default(),
13222 proxy: ProxyConfig::default(),
13223 cost: CostConfig::default(),
13224 peripherals: PeripheralsConfig::default(),
13225 delegate: DelegateToolConfig::default(),
13226 agents: HashMap::new(),
13227 risk_profiles: HashMap::new(),
13228 runtime_profiles: HashMap::new(),
13229 skill_bundles: HashMap::new(),
13230 knowledge_bundles: HashMap::new(),
13231 mcp_bundles: HashMap::new(),
13232 peer_groups: HashMap::new(),
13233 hooks: HooksConfig::default(),
13234 hardware: HardwareConfig::default(),
13235 query_classification: QueryClassificationConfig::default(),
13236 transcription: TranscriptionConfig::default(),
13237 tts: TtsConfig::default(),
13238 mcp: McpConfig::default(),
13239 nodes: NodesConfig::default(),
13240 onboard_state: OnboardStateConfig::default(),
13241 notion: NotionConfig::default(),
13242 jira: JiraConfig::default(),
13243 node_transport: NodeTransportConfig::default(),
13244 knowledge: KnowledgeConfig::default(),
13245 linkedin: LinkedInConfig::default(),
13246 image_gen: ImageGenConfig::default(),
13247 file_upload: FileUploadConfig::default(),
13248 file_upload_bundle: FileUploadBundleConfig::default(),
13249 file_download: FileDownloadConfig::default(),
13250 plugins: PluginsConfig::default(),
13251 locale: None,
13252 verifiable_intent: VerifiableIntentConfig::default(),
13253 claude_code: ClaudeCodeConfig::default(),
13254 claude_code_runner: ClaudeCodeRunnerConfig::default(),
13255 codex_cli: CodexCliConfig::default(),
13256 gemini_cli: GeminiCliConfig::default(),
13257 opencode_cli: OpenCodeCliConfig::default(),
13258 sop: SopConfig::default(),
13259 shell_tool: ShellToolConfig::default(),
13260 escalation: EscalationConfig::default(),
13261 }
13262 }
13263}
13264
13265fn default_config_and_data_dirs() -> Result<(PathBuf, PathBuf)> {
13266 let config_dir = default_config_dir()?;
13267 Ok((config_dir.clone(), config_dir.join("data")))
13272}
13273
13274fn default_config_dir() -> Result<PathBuf> {
13275 if let Ok(custom) = std::env::var("ZEROCLAW_CONFIG_DIR") {
13276 let custom = custom.trim();
13277 if !custom.is_empty() {
13278 return Ok(expand_tilde_path(custom));
13279 }
13280 }
13281
13282 if let Ok(home) = std::env::var("HOME")
13283 && !home.is_empty()
13284 {
13285 return Ok(PathBuf::from(home).join(".zeroclaw"));
13286 }
13287
13288 let home = UserDirs::new()
13289 .map(|u| u.home_dir().to_path_buf())
13290 .context("Could not find home directory")?;
13291 Ok(home.join(".zeroclaw"))
13292}
13293
13294fn default_path_under_config_dir(relative: &str) -> String {
13308 match default_config_dir() {
13309 Ok(dir) => dir.join(relative).to_string_lossy().into_owned(),
13310 Err(_) => format!("~/.zeroclaw/{relative}"),
13311 }
13312}
13313
13314pub fn resolve_config_dir_for_data(data_dir: &Path) -> (PathBuf, PathBuf) {
13315 let data_config_dir = data_dir.to_path_buf();
13316 if data_config_dir.join("config.toml").exists() {
13317 return (data_config_dir.clone(), data_config_dir.join("data"));
13318 }
13319
13320 let legacy_config_dir = data_dir.parent().map(|parent| parent.join(".zeroclaw"));
13321 if let Some(legacy_dir) = legacy_config_dir {
13322 if legacy_dir.join("config.toml").exists() {
13323 return (legacy_dir, data_config_dir);
13324 }
13325
13326 if data_dir.file_name().is_some_and(|name| {
13331 name == std::ffi::OsStr::new("data") || name == std::ffi::OsStr::new("workspace")
13332 }) {
13333 return (legacy_dir, data_config_dir);
13334 }
13335 }
13336
13337 (data_config_dir.clone(), data_config_dir.join("data"))
13338}
13339
13340pub async fn resolve_runtime_dirs_for_onboarding() -> Result<(PathBuf, PathBuf)> {
13346 let (default_zeroclaw_dir, default_data_dir) = default_config_and_data_dirs()?;
13347 let (config_dir, data_dir, _) =
13348 resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_data_dir).await?;
13349 Ok((config_dir, data_dir))
13350}
13351
13352#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13353enum ConfigResolutionSource {
13354 EnvConfigDir,
13355 EnvDataDir,
13356 EnvWorkspaceLegacy,
13357 DefaultConfigDir,
13358 HomebrewConfigDir,
13359}
13360
13361impl ConfigResolutionSource {
13362 const fn as_str(self) -> &'static str {
13363 match self {
13364 Self::EnvConfigDir => "ZEROCLAW_CONFIG_DIR",
13365 Self::EnvDataDir => "ZEROCLAW_DATA_DIR",
13366 Self::EnvWorkspaceLegacy => "ZEROCLAW_WORKSPACE",
13367 Self::DefaultConfigDir => "default",
13368 Self::HomebrewConfigDir => "homebrew",
13369 }
13370 }
13371}
13372
13373fn expand_tilde_path(path: &str) -> PathBuf {
13379 let expanded = shellexpand::tilde(path);
13380 let expanded_str = expanded.as_ref();
13381
13382 if expanded_str.starts_with('~') {
13384 if let Some(user_dirs) = UserDirs::new() {
13385 let home = user_dirs.home_dir();
13386 if let Some(rest) = expanded_str.strip_prefix('~') {
13388 return home.join(rest.trim_start_matches(['/', '\\']));
13389 }
13390 }
13391 ::zeroclaw_log::record!(
13393 WARN,
13394 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13395 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
13396 .with_attrs(::serde_json::json!({"path": path})),
13397 "Failed to expand tilde: HOME environment variable is not set and UserDirs failed. \
13398 In cron/non-TTY environments, use absolute paths or set HOME explicitly."
13399 );
13400 }
13401
13402 PathBuf::from(expanded_str)
13403}
13404
13405async fn try_resolve_macos_homebrew_config_dir(exe: &Path) -> Option<PathBuf> {
13411 let parts = exe.iter().collect::<Vec<_>>();
13412 let prefix = match parts.as_slice() {
13413 [prefix @ .., cellar, formula, _version, bin, exe_name]
13414 if *cellar == std::ffi::OsStr::new("Cellar")
13415 && *formula == std::ffi::OsStr::new("zeroclaw")
13416 && *bin == std::ffi::OsStr::new("bin")
13417 && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
13418 {
13419 prefix.iter().collect::<PathBuf>()
13420 }
13421 [prefix @ .., opt, formula, bin, exe_name]
13422 if *opt == std::ffi::OsStr::new("opt")
13423 && *formula == std::ffi::OsStr::new("zeroclaw")
13424 && *bin == std::ffi::OsStr::new("bin")
13425 && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
13426 {
13427 let prefix = prefix.iter().collect::<PathBuf>();
13428 if !prefix.as_os_str().is_empty()
13429 && fs::metadata(prefix.join("Cellar"))
13430 .await
13431 .is_ok_and(|metadata| metadata.is_dir())
13432 {
13433 prefix
13434 } else {
13435 return None;
13436 }
13437 }
13438 [prefix @ .., bin, exe_name]
13439 if *bin == std::ffi::OsStr::new("bin")
13440 && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
13441 {
13442 let prefix = prefix.iter().collect::<PathBuf>();
13443 if !prefix.as_os_str().is_empty()
13444 && fs::metadata(prefix.join("Cellar"))
13445 .await
13446 .is_ok_and(|metadata| metadata.is_dir())
13447 {
13448 prefix
13449 } else {
13450 return None;
13451 }
13452 }
13453 _ => return None,
13454 };
13455 Some(prefix.join("var").join("zeroclaw"))
13456}
13457
13458async fn resolve_runtime_config_dirs(
13459 default_zeroclaw_dir: &Path,
13460 default_data_dir: &Path,
13461) -> Result<(PathBuf, PathBuf, ConfigResolutionSource)> {
13462 if let Ok(custom_config_dir) = std::env::var("ZEROCLAW_CONFIG_DIR") {
13463 let custom_config_dir = custom_config_dir.trim();
13464 if !custom_config_dir.is_empty() {
13465 if std::env::var("ZEROCLAW_DATA_DIR")
13469 .ok()
13470 .filter(|v| !v.trim().is_empty())
13471 .is_some()
13472 {
13473 ::zeroclaw_log::record!(
13474 WARN,
13475 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13476 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
13477 "ZEROCLAW_CONFIG_DIR is set; ZEROCLAW_DATA_DIR is ignored \
13478 (CONFIG_DIR pins both the config directory and the data \
13479 directory under it)."
13480 );
13481 }
13482 if std::env::var("ZEROCLAW_WORKSPACE")
13483 .ok()
13484 .filter(|v| !v.is_empty())
13485 .is_some()
13486 {
13487 ::zeroclaw_log::record!(
13488 WARN,
13489 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13490 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
13491 "ZEROCLAW_CONFIG_DIR is set; ZEROCLAW_WORKSPACE (deprecated) \
13492 is ignored. ZEROCLAW_WORKSPACE will be removed in a future \
13493 release; switch any remaining references to ZEROCLAW_DATA_DIR."
13494 );
13495 }
13496 let zeroclaw_dir = expand_tilde_path(custom_config_dir);
13497 return Ok((
13498 zeroclaw_dir.clone(),
13499 zeroclaw_dir.join("data"),
13500 ConfigResolutionSource::EnvConfigDir,
13501 ));
13502 }
13503 }
13504
13505 if let Ok(custom_data) = std::env::var("ZEROCLAW_DATA_DIR")
13506 && !custom_data.trim().is_empty()
13507 {
13508 if std::env::var("ZEROCLAW_WORKSPACE")
13509 .ok()
13510 .filter(|v| !v.is_empty())
13511 .is_some()
13512 {
13513 ::zeroclaw_log::record!(
13514 WARN,
13515 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13516 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
13517 "ZEROCLAW_DATA_DIR and ZEROCLAW_WORKSPACE are both set; \
13518 ZEROCLAW_WORKSPACE (deprecated) is ignored. \
13519 ZEROCLAW_WORKSPACE will be removed in a future release."
13520 );
13521 }
13522 let expanded = expand_tilde_path(&custom_data);
13523 let (zeroclaw_dir, data_dir) = resolve_config_dir_for_data(&expanded);
13524 return Ok((zeroclaw_dir, data_dir, ConfigResolutionSource::EnvDataDir));
13525 }
13526
13527 if let Ok(custom_workspace) = std::env::var("ZEROCLAW_WORKSPACE")
13528 && !custom_workspace.is_empty()
13529 {
13530 ::zeroclaw_log::record!(
13531 WARN,
13532 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13533 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
13534 "ZEROCLAW_WORKSPACE is deprecated; use ZEROCLAW_DATA_DIR instead. \
13535 ZEROCLAW_WORKSPACE will be removed in a future release."
13536 );
13537 let expanded = expand_tilde_path(&custom_workspace);
13538 let (zeroclaw_dir, data_dir) = resolve_config_dir_for_data(&expanded);
13539 return Ok((
13540 zeroclaw_dir,
13541 data_dir,
13542 ConfigResolutionSource::EnvWorkspaceLegacy,
13543 ));
13544 }
13545
13546 if cfg!(target_os = "macos")
13547 && let Ok(exe) = std::env::current_exe()
13548 && let Some(homebrew_config_dir) = try_resolve_macos_homebrew_config_dir(&exe).await
13549 {
13550 return Ok((
13551 homebrew_config_dir.clone(),
13552 homebrew_config_dir.join("workspace"),
13553 ConfigResolutionSource::HomebrewConfigDir,
13554 ));
13555 }
13556
13557 Ok((
13558 default_zeroclaw_dir.to_path_buf(),
13559 default_data_dir.to_path_buf(),
13560 ConfigResolutionSource::DefaultConfigDir,
13561 ))
13562}
13563
13564fn config_dir_creation_error(path: &Path) -> String {
13565 format!(
13566 "Failed to create config directory: {}. If running as an OpenRC service, \
13567 ensure this path is writable by user 'zeroclaw'.",
13568 path.display()
13569 )
13570}
13571
13572const SAVE_PRESERVE_KEYS: &[&str] = &["schema_version"];
13578
13579fn ensure_blank_line_before_sections(toml: &str) -> String {
13585 let mut out = String::with_capacity(toml.len() + 64);
13586 let mut prev_line_blank = true; for line in toml.lines() {
13588 let is_section_header = line.starts_with('[');
13589 if is_section_header && !prev_line_blank {
13590 out.push('\n');
13591 }
13592 out.push_str(line);
13593 out.push('\n');
13594 prev_line_blank = line.trim().is_empty();
13595 }
13596 out
13597}
13598
13599fn prune_default_values(actual: &mut toml::Table, defaults: &toml::Table) {
13609 let keys: Vec<String> = actual.keys().cloned().collect();
13610 for key in keys {
13611 if SAVE_PRESERVE_KEYS.contains(&key.as_str()) {
13612 continue;
13613 }
13614 let Some(default_value) = defaults.get(&key) else {
13615 continue;
13619 };
13620 let Some(child) = actual.remove(&key) else {
13621 continue;
13622 };
13623 let pruned = match (child, default_value) {
13624 (toml::Value::Table(mut child_table), toml::Value::Table(default_subtable)) => {
13625 prune_default_values(&mut child_table, default_subtable);
13626 if child_table.is_empty() {
13627 None
13628 } else {
13629 Some(toml::Value::Table(child_table))
13630 }
13631 }
13632 (child, default_value) => {
13633 if &child == default_value {
13634 None
13635 } else {
13636 Some(child)
13637 }
13638 }
13639 };
13640 if let Some(value) = pruned {
13641 actual.insert(key, value);
13642 }
13643 }
13644}
13645
13646fn is_local_ollama_endpoint(api_url: Option<&str>) -> bool {
13647 let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
13648 return true;
13649 };
13650
13651 reqwest::Url::parse(raw)
13652 .ok()
13653 .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
13654 .is_some_and(|host| matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1" | "0.0.0.0"))
13655}
13656
13657fn is_official_ollama_cloud_endpoint(api_url: Option<&str>) -> bool {
13658 let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
13659 return false;
13660 };
13661
13662 reqwest::Url::parse(raw)
13663 .ok()
13664 .and_then(|url| {
13665 url.host_str().map(|host| {
13666 host.eq_ignore_ascii_case("ollama.com")
13667 || host.eq_ignore_ascii_case("api.ollama.com")
13668 })
13669 })
13670 .unwrap_or(false)
13671}
13672
13673fn has_ollama_cloud_credential(config_api_key: Option<&str>) -> bool {
13674 config_api_key
13675 .map(str::trim)
13676 .is_some_and(|value| !value.is_empty())
13677}
13678
13679pub async fn ensure_bootstrap_files(workspace_dir: &Path) -> Result<()> {
13685 let defaults: &[(&str, &str)] = &[
13686 (
13687 "IDENTITY.md",
13688 "# IDENTITY.md — Who Am I?\n\n\
13689 I am ZeroClaw, an autonomous AI agent.\n\n\
13690 ## Traits\n\
13691 - Helpful, precise, and safety-conscious\n\
13692 - I prioritize clarity and correctness\n",
13693 ),
13694 (
13695 "SOUL.md",
13696 "# SOUL.md — Who You Are\n\n\
13697 You are ZeroClaw, an autonomous AI agent.\n\n\
13698 ## Core Principles\n\
13699 - Be helpful and accurate\n\
13700 - Respect user intent and boundaries\n\
13701 - Ask before taking destructive actions\n\
13702 - Prefer safe, reversible operations\n",
13703 ),
13704 ];
13705
13706 for (filename, content) in defaults {
13707 let path = workspace_dir.join(filename);
13708 if !path.exists() {
13709 fs::write(&path, content)
13710 .await
13711 .with_context(|| format!("Failed to create default {filename} in workspace"))?;
13712 }
13713 }
13714
13715 Ok(())
13716}
13717
13718impl Config {
13719 pub fn channel_external_peers(&self, channel_type: &str, alias: &str) -> Vec<String> {
13726 let mut out: Vec<String> = Vec::new();
13727 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
13728 for group in self.peer_groups.values() {
13729 let group_matches = match group.channel.split_once('.') {
13730 Some((ty, al)) => ty == channel_type && al == alias,
13731 None => group.channel == channel_type,
13732 };
13733 if !group_matches {
13734 continue;
13735 }
13736 for peer in &group.external_peers {
13737 let username = peer.as_str().to_string();
13738 if seen.insert(username.clone()) {
13739 out.push(username);
13740 }
13741 }
13742 }
13743 out
13744 }
13745
13746 pub fn integration_descriptors(&self) -> Vec<crate::config::IntegrationDescriptor> {
13752 vec![
13760 self.browser.integration_descriptor(),
13761 self.google_workspace.integration_descriptor(),
13762 crate::config::IntegrationDescriptor {
13763 display_name: "Cron",
13764 description: "Scheduled tasks",
13765 category: "ToolsAutomation",
13766 active: !self.cron.is_empty(),
13767 },
13768 ]
13769 }
13770
13771 pub fn unknown_keys(raw_toml: &str) -> Vec<String> {
13779 let raw: toml::Table = match raw_toml.parse() {
13780 Ok(t) => t,
13781 Err(_) => return Vec::new(),
13782 };
13783 static DEFAULTS: OnceLock<toml::Table> = OnceLock::new();
13784 let defaults = DEFAULTS.get_or_init(|| {
13785 toml::to_string(&Config::default())
13786 .ok()
13787 .and_then(|s| s.parse().ok())
13788 .unwrap_or_default()
13789 });
13790 raw.keys()
13791 .filter(|key| {
13792 if defaults.contains_key(key.as_str()) {
13793 return false;
13794 }
13795 if crate::migration::V1_LEGACY_KEYS.contains(&key.as_str()) {
13796 return false;
13797 }
13798 let mut t = toml::Table::new();
13799 t.insert((*key).clone(), raw[key.as_str()].clone());
13800 let consumed = toml::to_string(&t)
13801 .ok()
13802 .and_then(|s| toml::from_str::<Config>(&s).ok())
13803 .and_then(|c| toml::to_string(&c).ok())
13804 .and_then(|s| s.parse::<toml::Table>().ok())
13805 .is_some_and(|t| t != *defaults);
13806 !consumed
13807 })
13808 .cloned()
13809 .collect()
13810 }
13811
13812 pub fn prop_is_env_overridden(&self, path: &str) -> bool {
13816 self.env_overridden_paths.contains(path)
13817 }
13818
13819 pub async fn load_or_init() -> Result<Self> {
13820 let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_data_dirs()?;
13821
13822 let (zeroclaw_dir, _legacy_workspace_dir, resolution_source) =
13828 resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
13829
13830 let config_toml_path = zeroclaw_dir.join("config.toml");
13843 let needs_fs_migration = config_toml_path.is_file()
13844 && matches!(
13845 std::fs::read_to_string(&config_toml_path)
13846 .ok()
13847 .and_then(|raw| toml::from_str::<toml::Value>(&raw).ok())
13848 .and_then(|v| crate::migration::detect_version(&v).ok()),
13849 Some(v) if v < crate::migration::CURRENT_SCHEMA_VERSION
13850 );
13851 if needs_fs_migration
13852 && let Err(e) = crate::schema::v2::migrate_v2_to_v3_install_filesystem(&zeroclaw_dir)
13853 {
13854 ::zeroclaw_log::record!(
13855 WARN,
13856 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13857 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
13858 .with_attrs(::serde_json::json!({
13859 "install": zeroclaw_dir.display().to_string(),
13860 "error": format!("{}", e),
13861 })),
13862 "[system] filesystem migration failed; continuing with legacy layout"
13863 );
13864 } else if !needs_fs_migration
13865 && let Err(e) =
13866 crate::schema::v2::relocate_default_agent_skills_to_shared(&zeroclaw_dir)
13867 {
13868 ::zeroclaw_log::record!(
13869 WARN,
13870 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13871 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
13872 .with_attrs(::serde_json::json!({
13873 "install": zeroclaw_dir.display().to_string(),
13874 "error": format!("{}", e),
13875 })),
13876 "[system] skills relocation to shared workspace failed; continuing"
13877 );
13878 }
13879
13880 let config_path = zeroclaw_dir.join("config.toml");
13881
13882 let data_dir = zeroclaw_dir.join("data");
13900 fs::create_dir_all(&data_dir).await.with_context(|| {
13901 format!(
13902 "Failed to create data directory: {}",
13903 data_dir.display().to_string()
13904 )
13905 })?;
13906 let workspace_dir = data_dir;
13909
13910 let shared_dir = zeroclaw_dir.join("shared");
13915 fs::create_dir_all(&shared_dir).await.with_context(|| {
13916 format!(
13917 "Failed to create shared workspace directory: {}",
13918 shared_dir.display()
13919 )
13920 })?;
13921
13922 fs::create_dir_all(&zeroclaw_dir)
13923 .await
13924 .with_context(|| config_dir_creation_error(&zeroclaw_dir))?;
13925
13926 if config_path.exists() {
13927 #[cfg(unix)]
13929 {
13930 use std::os::unix::fs::PermissionsExt;
13931 if let Ok(meta) = fs::metadata(&config_path).await
13932 && meta.permissions().mode() & 0o004 != 0
13933 {
13934 ::zeroclaw_log::record!(
13935 WARN,
13936 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13937 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
13938 &format!(
13939 "Config file {:?} is world-readable (mode {:o}). \
13940 Consider restricting with: chmod 600 {:?}",
13941 config_path,
13942 meta.permissions().mode() & 0o777,
13943 config_path
13944 )
13945 );
13946 }
13947 }
13948
13949 let contents = fs::read_to_string(&config_path)
13950 .await
13951 .context("Failed to read config file")?;
13952
13953 let stale_version = toml::from_str::<toml::Value>(&contents)
13975 .ok()
13976 .as_ref()
13977 .and_then(|v| crate::migration::detect_version(v).ok())
13978 .filter(|n| *n != crate::migration::CURRENT_SCHEMA_VERSION);
13979 let mut config: Config = crate::migration::migrate_to_current(&contents)
13980 .context("Failed to migrate config")?;
13981 if let Some(from_version) = stale_version {
13982 ::zeroclaw_log::record!(
13983 WARN,
13984 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13985 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
13986 &format!(
13987 "Config at {} is schema_version {from_version}; auto-migrated to {} in memory. \
13988 Run `zeroclaw config migrate` to commit the migration to disk. \
13989 V0.8.0 also replaced the env-var override grammar; see \
13990 https://github.com/zeroclaw-labs/zeroclaw/blob/master/docs/book/src/reference/env-vars.md \
13991 for the migration recipes.",
13992 config_path.display().to_string(),
13993 crate::migration::CURRENT_SCHEMA_VERSION
13994 )
13995 );
13996 }
13997
13998 if let Some(default_profile) = config.risk_profiles.get_mut("default") {
14018 default_profile.ensure_default_auto_approve();
14019 }
14020
14021 for key in Self::unknown_keys(&contents) {
14026 ::zeroclaw_log::record!(
14027 WARN,
14028 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14029 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
14030 .with_attrs(::serde_json::json!({"key": key})),
14031 "Unknown config key ignored: \"\". Check config.toml for typos or deprecated options."
14032 );
14033 }
14034 config.config_path = config_path.clone();
14036 config.data_dir = workspace_dir;
14037
14038 let install_root = config.install_root_dir();
14042 for alias in config.skill_bundles.keys().cloned().collect::<Vec<_>>() {
14043 if let Ok(dir) =
14044 crate::skill_bundles::resolve_directory(&config, &install_root, &alias)
14045 && let Err(e) = std::fs::create_dir_all(&dir)
14046 {
14047 ::zeroclaw_log::record!(
14048 WARN,
14049 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14050 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
14051 &format!(
14052 "skill-bundle '{alias}' directory creation failed at {}: {e}",
14053 dir.display().to_string()
14054 )
14055 );
14056 }
14057 }
14058
14059 let store = crate::secrets::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt);
14060 config.decrypt_secrets(&store)?;
14062
14063 let applied = crate::env_overrides::apply_env_overrides(&mut config)?;
14068 config.env_overridden_paths = applied.paths;
14069 config.pre_override_snapshots = applied.snapshots;
14070
14071 if let Err(e) = config.validate() {
14078 ::zeroclaw_log::record!(
14079 WARN,
14080 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14081 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
14082 .with_attrs(::serde_json::json!({"error": format!("{e:#}")})),
14083 "[system] config has validation errors — booting anyway so you \
14084 can fix them via /config or `zeroclaw config set`"
14085 );
14086 }
14087 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"path": config.config_path.display().to_string(), "workspace": config.data_dir.display().to_string(), "source": resolution_source.as_str(), "initialized": true})), "Config loaded");
14088 Ok(config)
14089 } else {
14090 let mut config = Config {
14091 config_path: config_path.clone(),
14092 data_dir: workspace_dir,
14093 ..Config::default()
14094 };
14095 config.save().await?;
14099
14100 #[cfg(unix)]
14102 {
14103 use std::{fs::Permissions, os::unix::fs::PermissionsExt};
14104 let _ = fs::set_permissions(&config_path, Permissions::from_mode(0o600)).await;
14105 }
14106
14107 let applied = crate::env_overrides::apply_env_overrides(&mut config)?;
14108 config.env_overridden_paths = applied.paths;
14109 config.pre_override_snapshots = applied.snapshots;
14110
14111 if let Err(e) = config.validate() {
14115 ::zeroclaw_log::record!(
14116 WARN,
14117 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14118 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
14119 .with_attrs(::serde_json::json!({"error": format!("{e:#}")})),
14120 "[system] freshly-initialized config has validation errors — \
14121 booting anyway so you can fix them via /config"
14122 );
14123 }
14124 ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"path": config.config_path.display().to_string(), "workspace": config.data_dir.display().to_string(), "source": resolution_source.as_str(), "initialized": true})), "Config loaded");
14125 Ok(config)
14126 }
14127 }
14128
14129 pub fn collect_warnings(&self) -> Vec<crate::validation_warnings::ValidationWarning> {
14142 Vec::new()
14143 }
14144
14145 pub fn validate(&self) -> Result<()> {
14150 if self.tunnel.tunnel_provider.trim() == "openvpn" {
14152 let openvpn = self.tunnel.openvpn.as_ref().ok_or_else(|| {
14153 ::zeroclaw_log::record!(
14154 WARN,
14155 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
14156 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
14157 "tunnel.tunnel_provider='openvpn' rejected: [tunnel.openvpn] block missing"
14158 );
14159 anyhow::Error::msg("tunnel.tunnel_provider='openvpn' requires [tunnel.openvpn]")
14160 })?;
14161
14162 if openvpn.config_file.trim().is_empty() {
14163 validation_bail!(
14164 RequiredFieldEmpty,
14165 "tunnel.openvpn.config_file",
14166 "tunnel.openvpn.config_file must not be empty"
14167 );
14168 }
14169 if openvpn.connect_timeout_secs == 0 {
14170 validation_bail!(
14171 InvalidNumericRange,
14172 "tunnel.openvpn.connect_timeout_secs",
14173 "tunnel.openvpn.connect_timeout_secs must be greater than 0"
14174 );
14175 }
14176 }
14177
14178 if self.gateway.host.trim().is_empty() {
14180 validation_bail!(
14181 RequiredFieldEmpty,
14182 "gateway.host",
14183 "gateway.host must not be empty"
14184 );
14185 }
14186 if self.heartbeat.enabled {
14189 let hb_agent = self.heartbeat.agent.trim();
14190 if hb_agent.is_empty() {
14191 validation_bail!(
14192 RequiredFieldEmpty,
14193 "heartbeat.agent",
14194 "heartbeat.agent must reference a configured agent when heartbeat.enabled = true"
14195 );
14196 }
14197 if !self.agents.contains_key(hb_agent) {
14198 validation_bail!(
14199 DanglingReference,
14200 "heartbeat.agent",
14201 "heartbeat.agent = {hb_agent:?} but no [agents.{hb_agent}] entry is configured"
14202 );
14203 }
14204 }
14205 if let Some(ref prefix) = self.gateway.path_prefix {
14206 if !prefix.is_empty() {
14209 if !prefix.starts_with('/') {
14210 validation_bail!(
14211 InvalidFormat,
14212 "gateway.path_prefix",
14213 "gateway.path_prefix must start with '/'"
14214 );
14215 }
14216 if prefix.ends_with('/') {
14217 validation_bail!(
14218 InvalidFormat,
14219 "gateway.path_prefix",
14220 "gateway.path_prefix must not end with '/' (including bare '/')"
14221 );
14222 }
14223 if let Some(bad) = prefix.chars().find(|c| {
14226 !matches!(c, '/' | '-' | '_' | '.' | '~'
14227 | 'a'..='z' | 'A'..='Z' | '0'..='9'
14228 | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '='
14229 | ':' | '@')
14230 }) {
14231 anyhow::bail!(
14232 "gateway.path_prefix contains invalid character '{bad}'; \
14233 only unreserved and sub-delim URI characters are allowed"
14234 );
14235 }
14236 }
14237 }
14238
14239 if !self.skill_bundles.is_empty() {
14245 let install_root = self.install_root_dir();
14246 for alias in self.skill_bundles.keys() {
14247 let dir = crate::skill_bundles::resolve_directory(self, &install_root, alias)
14248 .map_err(|e| {
14249 ::zeroclaw_log::record!(
14250 WARN,
14251 ::zeroclaw_log::Event::new(
14252 module_path!(),
14253 ::zeroclaw_log::Action::Reject
14254 )
14255 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
14256 .with_attrs(::serde_json::json!({
14257 "skill_bundle": alias,
14258 "error": format!("{}", e),
14259 })),
14260 "skill_bundles.<alias>.directory could not be resolved"
14261 );
14262 anyhow::Error::msg(e.to_string())
14263 })?;
14264 if let Err(e) = crate::skill_bundles::validate_directory(&dir, &install_root) {
14265 validation_bail!(
14266 InvalidFormat,
14267 format!("skill-bundles.{alias}.directory"),
14268 "{e}"
14269 );
14270 }
14271 }
14272 if let Err(e) = crate::skill_bundles::validate_uniqueness(self, &install_root) {
14273 validation_bail!(InvalidFormat, "skill-bundles", "{e}");
14274 }
14275 }
14276
14277 let mut profile_aliases: Vec<&String> = self.risk_profiles.keys().collect();
14281 profile_aliases.sort();
14282 for profile_alias in profile_aliases {
14283 let profile = &self.risk_profiles[profile_alias];
14284 for (i, env_name) in profile.shell_env_passthrough.iter().enumerate() {
14285 if !is_valid_env_var_name(env_name) {
14286 anyhow::bail!(
14287 "risk_profiles.{profile_alias}.shell_env_passthrough[{i}] is invalid ({env_name}); expected [A-Za-z_][A-Za-z0-9_]*"
14288 );
14289 }
14290 }
14291 }
14292
14293 if self.security.otp.challenge_max_attempts == 0 {
14295 validation_bail!(
14296 InvalidNumericRange,
14297 "security.otp.challenge_max_attempts",
14298 "security.otp.challenge_max_attempts must be greater than 0"
14299 );
14300 }
14301 if self.security.otp.token_ttl_secs == 0 {
14302 validation_bail!(
14303 InvalidNumericRange,
14304 "security.otp.token_ttl_secs",
14305 "security.otp.token_ttl_secs must be greater than 0"
14306 );
14307 }
14308 if self.security.otp.cache_valid_secs == 0 {
14309 validation_bail!(
14310 InvalidNumericRange,
14311 "security.otp.cache_valid_secs",
14312 "security.otp.cache_valid_secs must be greater than 0"
14313 );
14314 }
14315 if self.security.otp.cache_valid_secs < self.security.otp.token_ttl_secs {
14316 anyhow::bail!(
14317 "security.otp.cache_valid_secs must be greater than or equal to security.otp.token_ttl_secs"
14318 );
14319 }
14320 if self.security.otp.challenge_max_attempts == 0 {
14321 validation_bail!(
14322 InvalidNumericRange,
14323 "security.otp.challenge_max_attempts",
14324 "security.otp.challenge_max_attempts must be greater than 0"
14325 );
14326 }
14327 for (i, action) in self.security.otp.gated_actions.iter().enumerate() {
14328 let normalized = action.trim();
14329 if normalized.is_empty() {
14330 validation_bail!(
14331 RequiredFieldEmpty,
14332 format!("security.otp.gated_actions[{i}]"),
14333 "security.otp.gated_actions[{i}] must not be empty"
14334 );
14335 }
14336 if !normalized
14337 .chars()
14338 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
14339 {
14340 anyhow::bail!(
14341 "security.otp.gated_actions[{i}] contains invalid characters: {normalized}"
14342 );
14343 }
14344 }
14345 DomainMatcher::new(
14346 &self.security.otp.gated_domains,
14347 &self.security.otp.gated_domain_categories,
14348 )
14349 .with_context(
14350 || "Invalid security.otp.gated_domains or security.otp.gated_domain_categories",
14351 )?;
14352 if self.security.estop.state_file.trim().is_empty() {
14353 validation_bail!(
14354 RequiredFieldEmpty,
14355 "security.estop.state_file",
14356 "security.estop.state_file must not be empty"
14357 );
14358 }
14359
14360 if self.scheduler.max_concurrent == 0 {
14362 validation_bail!(
14363 InvalidNumericRange,
14364 "scheduler.max_concurrent",
14365 "scheduler.max_concurrent must be greater than 0"
14366 );
14367 }
14368 if self.scheduler.max_tasks == 0 {
14369 validation_bail!(
14370 InvalidNumericRange,
14371 "scheduler.max_tasks",
14372 "scheduler.max_tasks must be greater than 0"
14373 );
14374 }
14375
14376 for (i, route) in self.model_routes.iter().enumerate() {
14378 if route.hint.trim().is_empty() {
14379 validation_bail!(
14380 RequiredFieldEmpty,
14381 format!("model_routes[{i}].hint"),
14382 "model_routes[{i}].hint must not be empty"
14383 );
14384 }
14385 let mp = route.model_provider.trim();
14386 if mp.is_empty() {
14387 validation_bail!(
14388 RequiredFieldEmpty,
14389 format!("model_routes[{i}].model_provider"),
14390 "model_routes[{i}].model_provider must not be empty"
14391 );
14392 }
14393 match mp.split_once('.') {
14398 Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
14399 if self.providers.models.find(ty, inner).is_none() {
14400 validation_bail!(
14401 DanglingReference,
14402 format!("model_routes[{i}].model_provider"),
14403 "model_routes[{i}].model_provider = {mp:?} but providers.models.{ty}.{inner} is not configured",
14404 );
14405 }
14406 }
14407 _ => validation_bail!(
14408 InvalidFormat,
14409 format!("model_routes[{i}].model_provider"),
14410 "model_routes[{i}].model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
14411 ),
14412 }
14413 if route.model.trim().is_empty() {
14414 validation_bail!(
14415 RequiredFieldEmpty,
14416 format!("model_routes[{i}].model"),
14417 "model_routes[{i}].model must not be empty"
14418 );
14419 }
14420 }
14421
14422 for (i, route) in self.embedding_routes.iter().enumerate() {
14424 if route.hint.trim().is_empty() {
14425 validation_bail!(
14426 RequiredFieldEmpty,
14427 format!("embedding_routes[{i}].hint"),
14428 "embedding_routes[{i}].hint must not be empty"
14429 );
14430 }
14431 let mp = route.model_provider.trim();
14432 if mp.is_empty() {
14433 validation_bail!(
14434 RequiredFieldEmpty,
14435 format!("embedding_routes[{i}].model_provider"),
14436 "embedding_routes[{i}].model_provider must not be empty"
14437 );
14438 }
14439 match mp.split_once('.') {
14442 Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
14443 if self.providers.models.find(ty, inner).is_none() {
14444 validation_bail!(
14445 DanglingReference,
14446 format!("embedding_routes[{i}].model_provider"),
14447 "embedding_routes[{i}].model_provider = {mp:?} but providers.models.{ty}.{inner} is not configured",
14448 );
14449 }
14450 }
14451 _ => validation_bail!(
14452 InvalidFormat,
14453 format!("embedding_routes[{i}].model_provider"),
14454 "embedding_routes[{i}].model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
14455 ),
14456 }
14457 if route.model.trim().is_empty() {
14458 validation_bail!(
14459 RequiredFieldEmpty,
14460 format!("embedding_routes[{i}].model"),
14461 "embedding_routes[{i}].model must not be empty"
14462 );
14463 }
14464 }
14465
14466 for (type_key, alias_key, profile) in self.providers.models.iter_entries() {
14467 let profile_name = format!("{type_key}.{alias_key}");
14468
14469 let has_uri = profile
14470 .uri
14471 .as_deref()
14472 .map(str::trim)
14473 .is_some_and(|value| !value.is_empty());
14474
14475 let has_api_key = profile
14486 .api_key
14487 .as_deref()
14488 .is_some_and(|v| !v.trim().is_empty());
14489 let has_model = profile
14490 .model
14491 .as_deref()
14492 .is_some_and(|v| !v.trim().is_empty());
14493 if !has_uri && !has_api_key && !has_model {
14494 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"model_provider": profile_name, "profile_name": profile_name})), "providers.models. is empty (no uri / api_key / model). \
14495 Skipping at runtime; finish onboarding via the dashboard or `zeroclaw onboard` \
14496 to make this model_provider usable.");
14497 continue;
14498 }
14499
14500 if let Some(uri) = profile.uri.as_deref().map(str::trim)
14501 && !uri.is_empty()
14502 {
14503 let parsed = reqwest::Url::parse(uri).with_context(|| {
14504 format!("providers.models.{profile_name}.uri is not a valid URL")
14505 })?;
14506 if !matches!(parsed.scheme(), "http" | "https") {
14507 anyhow::bail!("providers.models.{profile_name}.uri must use http/https");
14508 }
14509 }
14510
14511 if let Some(temp) = profile.temperature {
14512 validate_temperature(temp).map_err(|e| {
14513 ::zeroclaw_log::record!(
14514 WARN,
14515 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
14516 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
14517 .with_attrs(::serde_json::json!({
14518 "profile": profile_name,
14519 "temperature": temp,
14520 "error": format!("{}", e),
14521 })),
14522 "providers.models.<alias>.temperature rejected"
14523 );
14524 anyhow::Error::msg(format!("providers.models.{profile_name}.temperature: {e}"))
14525 })?;
14526 }
14527
14528 for (key, value) in &profile.pricing {
14529 if value.is_nan() {
14530 anyhow::bail!(
14531 "providers.models.{profile_name}.pricing.{key}: value must not be NaN"
14532 );
14533 }
14534 if *value < 0.0 {
14535 anyhow::bail!(
14536 "providers.models.{profile_name}.pricing.{key}: value must be >= 0.0 (got {value})"
14537 );
14538 }
14539 }
14540 }
14541
14542 for w in self.collect_warnings() {
14548 ::zeroclaw_log::record!(
14549 WARN,
14550 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14551 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
14552 .with_attrs(::serde_json::json!({"path": w.path, "code": w.code})),
14553 &format!("{}", w.message)
14554 );
14555 }
14556
14557 for (alias, cfg) in &self.providers.models.ollama {
14559 let entry = &cfg.base;
14560 if !entry
14561 .model
14562 .as_deref()
14563 .is_some_and(|model| model.trim().ends_with(":cloud"))
14564 {
14565 continue;
14566 }
14567
14568 if is_local_ollama_endpoint(entry.uri.as_deref()) {
14569 anyhow::bail!(
14570 "providers.models.ollama.{alias}.model uses ':cloud', but uri is local or unset. Set uri to a remote Ollama endpoint (for example https://ollama.com)."
14571 );
14572 }
14573 if is_official_ollama_cloud_endpoint(entry.uri.as_deref())
14574 && !has_ollama_cloud_credential(entry.api_key.as_deref())
14575 {
14576 anyhow::bail!(
14577 "providers.models.ollama.{alias}.model uses ':cloud', but no API key is configured. Set api_key on [providers.models.ollama.{alias}] (or via the schema-mirror grammar: ZEROCLAW_providers__models__ollama__{alias}__api_key=<value>)."
14578 );
14579 }
14580 }
14581
14582 if self.microsoft365.enabled {
14584 let tenant = self
14585 .microsoft365
14586 .tenant_id
14587 .as_deref()
14588 .map(str::trim)
14589 .filter(|s| !s.is_empty());
14590 if tenant.is_none() {
14591 anyhow::bail!(
14592 "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
14593 );
14594 }
14595 let client = self
14596 .microsoft365
14597 .client_id
14598 .as_deref()
14599 .map(str::trim)
14600 .filter(|s| !s.is_empty());
14601 if client.is_none() {
14602 anyhow::bail!(
14603 "microsoft365.client_id must not be empty when microsoft365 is enabled"
14604 );
14605 }
14606 let flow = self.microsoft365.auth_flow.trim();
14607 if flow != "client_credentials" && flow != "device_code" {
14608 anyhow::bail!(
14609 "microsoft365.auth_flow must be 'client_credentials' or 'device_code'"
14610 );
14611 }
14612 if flow == "client_credentials"
14613 && self
14614 .microsoft365
14615 .client_secret
14616 .as_deref()
14617 .is_none_or(|s| s.trim().is_empty())
14618 {
14619 anyhow::bail!(
14620 "microsoft365.client_secret must not be empty when auth_flow is 'client_credentials'"
14621 );
14622 }
14623 }
14624
14625 if self.microsoft365.enabled {
14627 let tenant = self
14628 .microsoft365
14629 .tenant_id
14630 .as_deref()
14631 .map(str::trim)
14632 .filter(|s| !s.is_empty());
14633 if tenant.is_none() {
14634 anyhow::bail!(
14635 "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
14636 );
14637 }
14638 let client = self
14639 .microsoft365
14640 .client_id
14641 .as_deref()
14642 .map(str::trim)
14643 .filter(|s| !s.is_empty());
14644 if client.is_none() {
14645 anyhow::bail!(
14646 "microsoft365.client_id must not be empty when microsoft365 is enabled"
14647 );
14648 }
14649 let flow = self.microsoft365.auth_flow.trim();
14650 if flow != "client_credentials" && flow != "device_code" {
14651 anyhow::bail!("microsoft365.auth_flow must be client_credentials or device_code");
14652 }
14653 if flow == "client_credentials"
14654 && self
14655 .microsoft365
14656 .client_secret
14657 .as_deref()
14658 .is_none_or(|s| s.trim().is_empty())
14659 {
14660 anyhow::bail!(
14661 "microsoft365.client_secret must not be empty when auth_flow is client_credentials"
14662 );
14663 }
14664 }
14665
14666 if self.mcp.enabled {
14668 validate_mcp_config(&self.mcp)?;
14669 }
14670
14671 if self.knowledge.enabled {
14673 if self.knowledge.max_nodes == 0 {
14674 validation_bail!(
14675 InvalidNumericRange,
14676 "knowledge.max_nodes",
14677 "knowledge.max_nodes must be greater than 0"
14678 );
14679 }
14680 if self.knowledge.db_path.trim().is_empty() {
14681 validation_bail!(
14682 RequiredFieldEmpty,
14683 "knowledge.db_path",
14684 "knowledge.db_path must not be empty"
14685 );
14686 }
14687 }
14688
14689 let mut seen_gws_services = std::collections::HashSet::new();
14691 for (i, service) in self.google_workspace.allowed_services.iter().enumerate() {
14692 let normalized = service.trim();
14693 if normalized.is_empty() {
14694 validation_bail!(
14695 RequiredFieldEmpty,
14696 format!("google_workspace.allowed_services[{i}]"),
14697 "google_workspace.allowed_services[{i}] must not be empty"
14698 );
14699 }
14700 if !normalized
14701 .chars()
14702 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
14703 {
14704 anyhow::bail!(
14705 "google_workspace.allowed_services[{i}] contains invalid characters: {normalized}"
14706 );
14707 }
14708 if !seen_gws_services.insert(normalized.to_string()) {
14709 anyhow::bail!(
14710 "google_workspace.allowed_services contains duplicate entry: {normalized}"
14711 );
14712 }
14713 }
14714
14715 let effective_services: std::collections::HashSet<&str> =
14720 if self.google_workspace.allowed_services.is_empty() {
14721 DEFAULT_GWS_SERVICES.iter().copied().collect()
14722 } else {
14723 self.google_workspace
14724 .allowed_services
14725 .iter()
14726 .map(|s| s.trim())
14727 .collect()
14728 };
14729
14730 let mut seen_gws_operations = std::collections::HashSet::new();
14731 for (i, operation) in self.google_workspace.allowed_operations.iter().enumerate() {
14732 let service = operation.service.trim();
14733 let resource = operation.resource.trim();
14734
14735 if service.is_empty() {
14736 validation_bail!(
14737 RequiredFieldEmpty,
14738 format!("google_workspace.allowed_operations[{i}].service"),
14739 "google_workspace.allowed_operations[{i}].service must not be empty"
14740 );
14741 }
14742 if resource.is_empty() {
14743 anyhow::bail!(
14744 "google_workspace.allowed_operations[{i}].resource must not be empty"
14745 );
14746 }
14747
14748 if !effective_services.contains(service) {
14749 anyhow::bail!(
14750 "google_workspace.allowed_operations[{i}].service '{service}' is not in the \
14751 effective allowed_services; this entry can never match at runtime"
14752 );
14753 }
14754 if !service
14755 .chars()
14756 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
14757 {
14758 anyhow::bail!(
14759 "google_workspace.allowed_operations[{i}].service contains invalid characters: {service}"
14760 );
14761 }
14762 if !resource
14763 .chars()
14764 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
14765 {
14766 anyhow::bail!(
14767 "google_workspace.allowed_operations[{i}].resource contains invalid characters: {resource}"
14768 );
14769 }
14770
14771 if let Some(ref sub_resource) = operation.sub_resource {
14772 let sub = sub_resource.trim();
14773 if sub.is_empty() {
14774 anyhow::bail!(
14775 "google_workspace.allowed_operations[{i}].sub_resource must not be empty when present"
14776 );
14777 }
14778 if !sub
14779 .chars()
14780 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
14781 {
14782 anyhow::bail!(
14783 "google_workspace.allowed_operations[{i}].sub_resource contains invalid characters: {sub}"
14784 );
14785 }
14786 }
14787
14788 if operation.methods.is_empty() {
14789 validation_bail!(
14790 RequiredFieldEmpty,
14791 format!("google_workspace.allowed_operations[{i}].methods"),
14792 "google_workspace.allowed_operations[{i}].methods must not be empty"
14793 );
14794 }
14795
14796 let mut seen_methods = std::collections::HashSet::new();
14797 for (j, method) in operation.methods.iter().enumerate() {
14798 let normalized = method.trim();
14799 if normalized.is_empty() {
14800 anyhow::bail!(
14801 "google_workspace.allowed_operations[{i}].methods[{j}] must not be empty"
14802 );
14803 }
14804 if !normalized
14805 .chars()
14806 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
14807 {
14808 anyhow::bail!(
14809 "google_workspace.allowed_operations[{i}].methods[{j}] contains invalid characters: {normalized}"
14810 );
14811 }
14812 if !seen_methods.insert(normalized.to_string()) {
14813 anyhow::bail!(
14814 "google_workspace.allowed_operations[{i}].methods contains duplicate entry: {normalized}"
14815 );
14816 }
14817 }
14818
14819 let sub_key = operation
14820 .sub_resource
14821 .as_deref()
14822 .map(str::trim)
14823 .unwrap_or("");
14824 let operation_key = format!("{service}:{resource}:{sub_key}");
14825 if !seen_gws_operations.insert(operation_key.clone()) {
14826 anyhow::bail!(
14827 "google_workspace.allowed_operations contains duplicate service/resource/sub_resource entry: {operation_key}"
14828 );
14829 }
14830 }
14831
14832 if self.project_intel.enabled {
14834 let lang = &self.project_intel.default_language;
14835 if !["en", "de", "fr", "it"].contains(&lang.as_str()) {
14836 anyhow::bail!(
14837 "project_intel.default_language must be one of: en, de, fr, it (got '{lang}')"
14838 );
14839 }
14840 let sens = &self.project_intel.risk_sensitivity;
14841 if !["low", "medium", "high"].contains(&sens.as_str()) {
14842 anyhow::bail!(
14843 "project_intel.risk_sensitivity must be one of: low, medium, high (got '{sens}')"
14844 );
14845 }
14846 if let Some(ref tpl_dir) = self.project_intel.templates_dir
14847 && !std::path::Path::new(tpl_dir).exists()
14848 {
14849 anyhow::bail!("project_intel.templates_dir path does not exist: {tpl_dir}");
14850 }
14851 }
14852
14853 self.proxy.validate()?;
14855 self.cloud_ops.validate()?;
14856
14857 if self.notion.enabled {
14859 if self.notion.database_id.trim().is_empty() {
14860 anyhow::bail!("notion.database_id must not be empty when notion.enabled = true");
14861 }
14862 if self.notion.poll_interval_secs == 0 {
14863 validation_bail!(
14864 InvalidNumericRange,
14865 "notion.poll_interval_secs",
14866 "notion.poll_interval_secs must be greater than 0"
14867 );
14868 }
14869 if self.notion.max_concurrent == 0 {
14870 validation_bail!(
14871 InvalidNumericRange,
14872 "notion.max_concurrent",
14873 "notion.max_concurrent must be greater than 0"
14874 );
14875 }
14876 if self.notion.status_property.trim().is_empty() {
14877 validation_bail!(
14878 RequiredFieldEmpty,
14879 "notion.status_property",
14880 "notion.status_property must not be empty"
14881 );
14882 }
14883 if self.notion.input_property.trim().is_empty() {
14884 validation_bail!(
14885 RequiredFieldEmpty,
14886 "notion.input_property",
14887 "notion.input_property must not be empty"
14888 );
14889 }
14890 if self.notion.result_property.trim().is_empty() {
14891 validation_bail!(
14892 RequiredFieldEmpty,
14893 "notion.result_property",
14894 "notion.result_property must not be empty"
14895 );
14896 }
14897 }
14898
14899 if let Some(ref pinggy) = self.tunnel.pinggy
14901 && let Some(ref region) = pinggy.region
14902 {
14903 let r = region.trim().to_ascii_lowercase();
14904 if !r.is_empty() && !matches!(r.as_str(), "us" | "eu" | "ap" | "br" | "au") {
14905 anyhow::bail!(
14906 "tunnel.pinggy.region must be one of: us, eu, ap, br, au (or omitted for auto)"
14907 );
14908 }
14909 }
14910
14911 if self.jira.enabled {
14913 if self.jira.base_url.trim().is_empty() {
14914 anyhow::bail!("jira.base_url must not be empty when jira.enabled = true");
14915 }
14916 if self.jira.api_token.trim().is_empty()
14917 && std::env::var("JIRA_API_TOKEN")
14918 .unwrap_or_default()
14919 .trim()
14920 .is_empty()
14921 {
14922 anyhow::bail!(
14923 "jira.api_token must be set (or JIRA_API_TOKEN env var) when jira.enabled = true"
14924 );
14925 }
14926 let valid_actions = [
14927 "get_ticket",
14928 "search_tickets",
14929 "comment_ticket",
14930 "list_projects",
14931 "myself",
14932 "list_transitions",
14933 "transition_ticket",
14934 "create_ticket",
14935 ];
14936 for action in &self.jira.allowed_actions {
14937 if !valid_actions.contains(&action.as_str()) {
14938 anyhow::bail!(
14939 "jira.allowed_actions contains unknown action: '{}'. \
14940 Valid: get_ticket, search_tickets, comment_ticket, list_projects, myself, list_transitions, transition_ticket, create_ticket",
14941 action
14942 );
14943 }
14944 }
14945 }
14946
14947 if let Err(msg) = self.security.nevis.validate() {
14949 anyhow::bail!("security.nevis: {msg}");
14950 }
14951
14952 if self.delegate.timeout_secs == 0 {
14954 validation_bail!(
14955 InvalidNumericRange,
14956 "delegate.timeout_secs",
14957 "delegate.timeout_secs must be greater than 0"
14958 );
14959 }
14960 if self.delegate.agentic_timeout_secs == 0 {
14961 validation_bail!(
14962 InvalidNumericRange,
14963 "delegate.agentic_timeout_secs",
14964 "delegate.agentic_timeout_secs must be greater than 0"
14965 );
14966 }
14967
14968 let mut agent_aliases: Vec<&String> = self.agents.keys().collect();
14973 agent_aliases.sort();
14974 for alias in agent_aliases {
14975 let agent = &self.agents[alias];
14976
14977 let mp = agent.model_provider.trim();
14980 if mp.is_empty() {
14981 validation_bail!(
14982 RequiredFieldEmpty,
14983 format!("agents.{alias}.model_provider"),
14984 "agents.{alias}.model_provider must reference a configured model model_provider (e.g. \"anthropic.default\")",
14985 );
14986 }
14987 match mp.split_once('.') {
14988 Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
14989 let exists = self
14990 .get_map_keys(&format!("providers.models.{ty}"))
14991 .is_some_and(|keys| keys.iter().any(|k| k == inner));
14992 if !exists {
14993 validation_bail!(
14994 DanglingReference,
14995 format!("agents.{alias}.model_provider"),
14996 "agents.{alias}.model_provider = {mp:?} but providers.models.{ty}.{inner} is not configured",
14997 );
14998 }
14999 }
15000 _ => validation_bail!(
15001 InvalidFormat,
15002 format!("agents.{alias}.model_provider"),
15003 "agents.{alias}.model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
15004 ),
15005 }
15006
15007 for (i, ch) in agent.channels.iter().enumerate() {
15012 let trimmed = ch.trim();
15013 match trimmed.split_once('.') {
15014 Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
15015 let ty_kebab = ty.replace('_', "-");
15023 let exists = self
15024 .get_map_keys(&format!("channels.{ty_kebab}"))
15025 .is_some_and(|keys| keys.iter().any(|k| k == inner));
15026 if !exists {
15027 validation_bail!(
15028 DanglingReference,
15029 format!("agents.{alias}.channels[{i}]"),
15030 "agents.{alias}.channels[{i}] = {trimmed:?} but channels.{ty}.{inner} is not configured",
15031 );
15032 }
15033 }
15034 _ => validation_bail!(
15035 InvalidFormat,
15036 format!("agents.{alias}.channels[{i}]"),
15037 "agents.{alias}.channels[{i}] must be dotted form `<type>.<alias>` (got {trimmed:?})",
15038 ),
15039 }
15040 }
15041
15042 let typed_provider_refs: &[(&str, &str, &str)] = &[
15050 ("providers.tts", "tts_provider", agent.tts_provider.trim()),
15051 (
15052 "providers.transcription",
15053 "transcription_provider",
15054 agent.transcription_provider.trim(),
15055 ),
15056 (
15058 "providers.models",
15059 "classifier_provider",
15060 agent.classifier_provider.trim(),
15061 ),
15062 ];
15063 for (section_prefix, field, value) in typed_provider_refs {
15064 if value.is_empty() {
15065 continue;
15066 }
15067 match value.split_once('.') {
15068 Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
15069 let exists = self
15070 .get_map_keys(&format!("{section_prefix}.{ty}"))
15071 .is_some_and(|keys| keys.iter().any(|k| k == inner));
15072 if !exists {
15073 validation_bail!(
15074 DanglingReference,
15075 format!("agents.{alias}.{field}"),
15076 "agents.{alias}.{field} = {value:?} but {section_prefix}.{ty}.{inner} is not configured",
15077 );
15078 }
15079 }
15080 _ => validation_bail!(
15081 InvalidFormat,
15082 format!("agents.{alias}.{field}"),
15083 "agents.{alias}.{field} must be dotted form `<type>.<alias>` (got {value:?})",
15084 ),
15085 }
15086 }
15087
15088 let bare_multi: &[(&str, &str, &[String])] = &[
15096 ("skill-bundles", "skill_bundles", &agent.skill_bundles),
15097 (
15098 "knowledge-bundles",
15099 "knowledge_bundles",
15100 &agent.knowledge_bundles,
15101 ),
15102 ("mcp-bundles", "mcp_bundles", &agent.mcp_bundles),
15103 ];
15104 for (section, field, values) in bare_multi {
15105 for (i, key) in values.iter().enumerate() {
15106 let trimmed = key.trim();
15107 if trimmed.is_empty() {
15108 continue;
15109 }
15110 let exists = self
15111 .get_map_keys(section)
15112 .is_some_and(|keys| keys.iter().any(|k| k == trimmed));
15113 if !exists {
15114 validation_bail!(
15115 DanglingReference,
15116 format!("agents.{alias}.{field}[{i}]"),
15117 "agents.{alias}.{field}[{i}] = {trimmed:?} but {section}.{trimmed} is not configured",
15118 );
15119 }
15120 }
15121 }
15122 let bare_single: &[(&str, &str, &str)] = &[
15123 ("risk-profiles", "risk-profile", agent.risk_profile.as_str()),
15124 (
15125 "runtime-profiles",
15126 "runtime-profile",
15127 agent.runtime_profile.as_str(),
15128 ),
15129 ];
15130 for (section, field, raw) in bare_single {
15131 let trimmed = raw.trim();
15132 if trimmed.is_empty() {
15133 continue;
15134 }
15135 let exists = self
15136 .get_map_keys(section)
15137 .is_some_and(|keys| keys.iter().any(|k| k == trimmed));
15138 if !exists {
15139 validation_bail!(
15140 DanglingReference,
15141 format!("agents.{alias}.{field}"),
15142 "agents.{alias}.{field} = {trimmed:?} but {section}.{trimmed} is not configured",
15143 );
15144 }
15145 }
15146
15147 if agent.enabled && agent.risk_profile.trim().is_empty() {
15152 validation_bail!(
15153 RequiredFieldEmpty,
15154 format!("agents.{alias}.risk-profile"),
15155 "agents.{alias}.risk_profile must reference a configured [risk_profiles.<alias>] entry",
15156 );
15157 }
15158
15159 if agent.precheck.timeout_secs == 0 {
15160 validation_bail!(
15161 InvalidNumericRange,
15162 format!("agents.{alias}.precheck.timeout_secs"),
15163 "agents.{alias}.precheck.timeout_secs must be greater than 0",
15164 );
15165 }
15166
15167 for (target, mode) in &agent.workspace.access {
15170 let target_str = target.as_str();
15171 if target_str == alias.as_str() {
15172 validation_bail!(
15173 InvalidFormat,
15174 format!("agents.{alias}.workspace.access.{target_str}"),
15175 "agents.{alias}.workspace.access.{target_str} = {mode:?} but {target_str} is this agent itself; an agent always has full access to its own workspace, so self-references in the cross-agent allowlist are not permitted",
15176 );
15177 }
15178 if !self.agents.contains_key(target_str) {
15179 validation_bail!(
15180 DanglingReference,
15181 format!("agents.{alias}.workspace.access.{target_str}"),
15182 "agents.{alias}.workspace.access.{target_str} = {mode:?} but agents.{target_str} is not configured",
15183 );
15184 }
15185 }
15186
15187 let agent_backend = agent.memory.backend;
15193 for (i, target) in agent.workspace.read_memory_from.iter().enumerate() {
15194 let target_str = target.as_str();
15195 if target_str == alias.as_str() {
15196 validation_bail!(
15197 InvalidFormat,
15198 format!("agents.{alias}.workspace.read_memory_from[{i}]"),
15199 "agents.{alias}.workspace.read_memory_from[{i}] = {target_str:?} but {target_str} is this agent itself; an agent always sees its own memory rows, so self-references in the cross-agent allowlist are not permitted",
15200 );
15201 }
15202 let Some(target_agent) = self.agents.get(target_str) else {
15203 validation_bail!(
15204 DanglingReference,
15205 format!("agents.{alias}.workspace.read_memory_from[{i}]"),
15206 "agents.{alias}.workspace.read_memory_from[{i}] = {target_str:?} but agents.{target_str} is not configured",
15207 );
15208 };
15209 if target_agent.memory.backend != agent_backend {
15210 let target_backend = target_agent.memory.backend;
15211 validation_bail!(
15212 InvalidFormat,
15213 format!("agents.{alias}.workspace.read_memory_from[{i}]"),
15214 "agents.{alias}.workspace.read_memory_from[{i}] points at agents.{target_str} which uses memory backend {target_backend:?}, but agents.{alias} uses {agent_backend:?}; the allowlist must point at same-backend siblings only",
15215 );
15216 }
15217 }
15218 }
15219
15220 let mut peer_group_names: Vec<&String> = self.peer_groups.keys().collect();
15227 peer_group_names.sort();
15228 for group_name in peer_group_names {
15229 let group = &self.peer_groups[group_name];
15230 let group_channel = group.channel.trim();
15231 if group_channel.is_empty() {
15232 validation_bail!(
15233 RequiredFieldEmpty,
15234 format!("peer_groups.{group_name}.channel"),
15235 "peer_groups.{group_name}.channel must name a channel type (e.g. \"discord\") or dotted alias (e.g. \"discord.work\")",
15236 );
15237 }
15238 let (group_channel_type, group_channel_alias) = match group_channel.split_once('.') {
15243 Some((ty, al)) => (ty, Some(al)),
15244 None => (group_channel, None),
15245 };
15246 let group_channel_type_kebab = group_channel_type.replace('_', "-");
15247 let channel_aliases =
15248 self.get_map_keys(&format!("channels.{group_channel_type_kebab}"));
15249 if channel_aliases.is_none() {
15250 validation_bail!(
15251 DanglingReference,
15252 format!("peer_groups.{group_name}.channel"),
15253 "peer_groups.{group_name}.channel = {group_channel:?} but no [channels.{group_channel_type}.*] block is configured",
15254 );
15255 }
15256 if let Some(alias) = group_channel_alias {
15257 let exists = channel_aliases
15258 .as_ref()
15259 .is_some_and(|keys| keys.iter().any(|k| k == alias));
15260 if !exists {
15261 validation_bail!(
15262 DanglingReference,
15263 format!("peer_groups.{group_name}.channel"),
15264 "peer_groups.{group_name}.channel = {group_channel:?} but [channels.{group_channel_type}.{alias}] is not configured",
15265 );
15266 }
15267 }
15268 for (i, member) in group.agents.iter().enumerate() {
15269 let member_str = member.as_str();
15270 let Some(member_agent) = self.agents.get(member_str) else {
15271 validation_bail!(
15272 DanglingReference,
15273 format!("peer_groups.{group_name}.agents[{i}]"),
15274 "peer_groups.{group_name}.agents[{i}] = {member_str:?} but agents.{member_str} is not configured",
15275 );
15276 };
15277 let has_channel_match = member_agent.channels.iter().any(|ch| {
15278 let ch_str = ch.as_str();
15279 match group_channel_alias {
15280 Some(alias) => ch_str == format!("{group_channel_type}.{alias}"),
15281 None => ch_str.starts_with(&format!("{group_channel_type}.")),
15282 }
15283 });
15284 if !has_channel_match {
15285 let needs_msg = match group_channel_alias {
15286 Some(alias) => format!("entry for {group_channel_type}.{alias}"),
15287 None => format!("entry of type {group_channel_type:?}"),
15288 };
15289 validation_bail!(
15290 InvalidFormat,
15291 format!("peer_groups.{group_name}.agents[{i}]"),
15292 "peer_groups.{group_name}.agents[{i}] = {member_str:?} but agents.{member_str}.channels has no {needs_msg}",
15293 );
15294 }
15295 }
15296 }
15297
15298 Ok(())
15299 }
15300
15301 pub fn mark_dirty(&mut self, path: &str) {
15302 self.dirty_paths.insert(path.to_string());
15303 }
15304
15305 pub fn clear_dirty(&mut self) {
15306 self.dirty_paths.clear();
15307 }
15308
15309 pub fn set_prop_persistent(&mut self, name: &str, value_str: &str) -> Result<()> {
15310 self.set_prop(name, value_str)?;
15311 self.mark_dirty(name);
15312 Ok(())
15313 }
15314
15315 pub fn set_secret_persistent(&mut self, name: &str, value: String) -> Result<()> {
15316 self.set_secret(name, value)?;
15317 self.mark_dirty(name);
15318 Ok(())
15319 }
15320
15321 async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
15322 if self
15323 .config_path
15324 .parent()
15325 .is_some_and(|parent| !parent.as_os_str().is_empty())
15326 {
15327 return Ok(self.config_path.clone());
15328 }
15329
15330 let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_data_dirs()?;
15331 let (zeroclaw_dir, _workspace_dir, source) =
15332 resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
15333 let file_name = self
15334 .config_path
15335 .file_name()
15336 .filter(|name| !name.is_empty())
15337 .unwrap_or_else(|| std::ffi::OsStr::new("config.toml"));
15338 let resolved = zeroclaw_dir.join(file_name);
15339 ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"path": self.config_path.display().to_string(), "resolved": resolved.display().to_string(), "source": source.as_str()})), "Config path missing parent directory; resolving from runtime environment");
15340 Ok(resolved)
15341 }
15342
15343 pub async fn save(&self) -> Result<()> {
15344 let mut config_to_save = self.clone();
15346 let config_path = self.resolve_config_path_for_save().await?;
15347 let zeroclaw_dir = config_path
15348 .parent()
15349 .context("Config path must have a parent directory")?;
15350 let store = crate::secrets::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
15351
15352 if !self.pre_override_snapshots.is_empty() {
15359 crate::env_overrides::mask_env_overrides_for_save(
15360 &mut config_to_save,
15361 &self.pre_override_snapshots,
15362 )?;
15363 }
15364
15365 config_to_save.encrypt_secrets(&store)?;
15367
15368 let mut new_table: toml::Table = toml::Value::try_from(&config_to_save)
15375 .context("Failed to serialize config to TOML value")?
15376 .try_into()
15377 .context("Serialized config is not a TOML table")?;
15378 let default_table: toml::Table = toml::Value::try_from(Config::default())
15379 .ok()
15380 .and_then(|v| v.try_into().ok())
15381 .unwrap_or_default();
15382 prune_default_values(&mut new_table, &default_table);
15383 let new_toml = ensure_blank_line_before_sections(
15384 &toml::to_string_pretty(&new_table).context("Failed to serialize pruned config")?,
15385 );
15386
15387 let toml_str = if config_path.exists() {
15390 let existing = fs::read_to_string(&config_path).await.unwrap_or_default();
15391 if existing.is_empty() {
15392 new_toml
15393 } else {
15394 let mut doc: toml_edit::DocumentMut = existing
15395 .parse()
15396 .context("Failed to parse existing config for comment preservation")?;
15397 crate::migration::sync_table(doc.as_table_mut(), &new_table);
15398 ensure_blank_line_before_sections(&doc.to_string())
15402 }
15403 } else {
15404 new_toml
15405 };
15406
15407 write_config_atomically(&config_path, &toml_str).await
15408 }
15409
15410 pub async fn save_dirty(&mut self) -> Result<()> {
15417 if self.dirty_paths.is_empty() {
15418 return Ok(());
15419 }
15420
15421 let config_path = self.resolve_config_path_for_save().await?;
15422 if !config_path.exists() {
15423 let result = self.save().await;
15424 if result.is_ok() {
15425 self.clear_dirty();
15426 }
15427 return result;
15428 }
15429
15430 let mut config_to_save = self.clone();
15431 let zeroclaw_dir = config_path
15432 .parent()
15433 .context("Config path must have a parent directory")?;
15434 let store = crate::secrets::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
15435
15436 if !self.pre_override_snapshots.is_empty() {
15437 crate::env_overrides::mask_env_overrides_for_save(
15438 &mut config_to_save,
15439 &self.pre_override_snapshots,
15440 )?;
15441 }
15442 config_to_save.encrypt_secrets(&store)?;
15443
15444 let full_table: toml::Table = toml::Value::try_from(&config_to_save)
15445 .context("Failed to serialize config to TOML value")?
15446 .try_into()
15447 .context("Serialized config is not a TOML table")?;
15448 let default_table: toml::Table = toml::Value::try_from(Config::default())
15449 .ok()
15450 .and_then(|v| v.try_into().ok())
15451 .unwrap_or_default();
15452
15453 let existing = fs::read_to_string(&config_path).await.with_context(|| {
15454 format!(
15455 "Failed to read existing config for incremental save: {}",
15456 config_path.display()
15457 )
15458 })?;
15459 let mut doc: toml_edit::DocumentMut = existing
15460 .parse()
15461 .context("Failed to parse existing config for incremental save")?;
15462
15463 for path in &self.dirty_paths {
15464 apply_dirty_path(doc.as_table_mut(), path, &full_table, &default_table);
15465 }
15466
15467 let toml_str = ensure_blank_line_before_sections(&doc.to_string());
15468
15469 write_config_atomically(&config_path, &toml_str).await?;
15470 self.clear_dirty();
15471 Ok(())
15472 }
15473}
15474
15475async fn write_config_atomically(config_path: &Path, toml_str: &str) -> Result<()> {
15477 let parent_dir = config_path
15478 .parent()
15479 .context("Config path must have a parent directory")?;
15480
15481 fs::create_dir_all(parent_dir).await.with_context(|| {
15482 format!(
15483 "Failed to create config directory: {}",
15484 parent_dir.display()
15485 )
15486 })?;
15487
15488 let file_name = config_path
15489 .file_name()
15490 .and_then(|v| v.to_str())
15491 .unwrap_or("config.toml");
15492 let temp_path = parent_dir.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
15493 let backup_path = parent_dir.join(format!("{file_name}.bak"));
15494
15495 let mut temp_file = OpenOptions::new()
15496 .create_new(true)
15497 .write(true)
15498 .open(&temp_path)
15499 .await
15500 .with_context(|| {
15501 format!(
15502 "Failed to create temporary config file: {}",
15503 temp_path.display()
15504 )
15505 })?;
15506 temp_file
15507 .write_all(toml_str.as_bytes())
15508 .await
15509 .context("Failed to write temporary config contents")?;
15510 temp_file
15511 .sync_all()
15512 .await
15513 .context("Failed to fsync temporary config file")?;
15514 drop(temp_file);
15515
15516 let had_existing_config = config_path.exists();
15517 if had_existing_config {
15518 fs::copy(config_path, &backup_path).await.with_context(|| {
15519 format!(
15520 "Failed to create config backup before atomic replace: {}",
15521 backup_path.display()
15522 )
15523 })?;
15524 }
15525
15526 if let Err(e) = fs::rename(&temp_path, config_path).await {
15527 let _ = fs::remove_file(&temp_path).await;
15528 if had_existing_config && backup_path.exists() {
15529 fs::copy(&backup_path, config_path)
15530 .await
15531 .context("Failed to restore config backup")?;
15532 }
15533 anyhow::bail!("Failed to atomically replace config file: {e}");
15534 }
15535
15536 #[cfg(unix)]
15537 {
15538 use std::{fs::Permissions, os::unix::fs::PermissionsExt};
15539 if let Err(err) = fs::set_permissions(config_path, Permissions::from_mode(0o600)).await {
15540 ::zeroclaw_log::record!(
15541 WARN,
15542 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15543 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
15544 &format!(
15545 "Failed to harden config permissions to 0600 at {}: {}",
15546 config_path.display().to_string(),
15547 err
15548 )
15549 );
15550 }
15551 }
15552
15553 sync_directory(parent_dir).await?;
15554
15555 if had_existing_config {
15556 let _ = fs::remove_file(&backup_path).await;
15557 }
15558
15559 Ok(())
15560}
15561
15562fn apply_dirty_path(
15566 root: &mut toml_edit::Table,
15567 dotted: &str,
15568 full_table: &toml::Table,
15569 default_table: &toml::Table,
15570) {
15571 let raw: Vec<&str> = dotted.split('.').collect();
15572 if raw.is_empty() {
15573 return;
15574 }
15575 let segments: Vec<String> = resolve_dirty_segments(full_table, &raw);
15582 let segs: Vec<&str> = segments.iter().map(String::as_str).collect();
15583
15584 let mem_val = lookup_path_in_table(full_table, &segs);
15585 let default_val = lookup_path_in_table(default_table, &segs);
15586
15587 let should_delete = match (mem_val, default_val) {
15588 (None, _) => true,
15589 (Some(m), Some(d)) if m == d => true,
15590 _ => false,
15591 };
15592
15593 if should_delete {
15594 delete_path_in_doc(root, &segs);
15595 } else if let Some(value) = mem_val {
15596 let mut pruned = value.clone();
15597 prune_empty_leaves(&mut pruned);
15598 set_path_in_doc(root, &segs, &pruned);
15599 }
15600}
15601
15602fn prune_empty_leaves(value: &mut toml::Value) {
15609 match value {
15610 toml::Value::Table(t) => {
15611 let keys: Vec<String> = t.keys().cloned().collect();
15612 for key in keys {
15613 if let Some(inner) = t.get_mut(&key) {
15614 prune_empty_leaves(inner);
15615 }
15616 let drop = match t.get(&key) {
15617 Some(toml::Value::Array(arr)) => arr.is_empty(),
15618 Some(toml::Value::Table(inner)) => inner.is_empty(),
15619 Some(toml::Value::String(s)) => s.is_empty(),
15620 _ => false,
15621 };
15622 if drop {
15623 t.remove(&key);
15624 }
15625 }
15626 }
15627 toml::Value::Array(arr) => {
15628 for item in arr.iter_mut() {
15629 prune_empty_leaves(item);
15630 }
15631 }
15632 _ => {}
15633 }
15634}
15635
15636fn resolve_dirty_segments(root: &toml::Table, raw: &[&str]) -> Vec<String> {
15637 let mut out: Vec<String> = Vec::with_capacity(raw.len());
15638 let mut current: Option<&toml::Value> = None;
15639 for seg in raw {
15640 let table_opt: Option<&toml::Table> = if out.is_empty() {
15641 Some(root)
15642 } else {
15643 current.and_then(|v| v.as_table())
15644 };
15645 let resolved = match table_opt {
15646 Some(t) if t.contains_key(*seg) => (*seg).to_string(),
15647 Some(t) => {
15648 let snake = seg.replace('-', "_");
15649 if t.contains_key(&snake) {
15650 snake
15651 } else {
15652 (*seg).to_string()
15653 }
15654 }
15655 None => (*seg).to_string(),
15656 };
15657 current = table_opt.and_then(|t| t.get(&resolved));
15658 out.push(resolved);
15659 }
15660 out
15661}
15662
15663fn lookup_path_in_table<'a>(root: &'a toml::Table, segs: &[&str]) -> Option<&'a toml::Value> {
15664 let mut current: Option<&toml::Value> = None;
15665 for (i, seg) in segs.iter().enumerate() {
15666 let table = if i == 0 { root } else { current?.as_table()? };
15667 current = table.get(*seg);
15668 }
15669 current
15670}
15671
15672fn delete_path_in_doc(root: &mut toml_edit::Table, segs: &[&str]) {
15673 let Some((last, parents)) = segs.split_last() else {
15674 return;
15675 };
15676 let mut cursor: &mut toml_edit::Table = root;
15677 for seg in parents {
15678 cursor = match cursor.get_mut(seg).and_then(|i| i.as_table_mut()) {
15679 Some(t) => t,
15680 None => return,
15681 };
15682 }
15683 cursor.remove(last);
15684}
15685
15686fn set_path_in_doc(root: &mut toml_edit::Table, segs: &[&str], value: &toml::Value) {
15687 let Some((last, parents)) = segs.split_last() else {
15688 return;
15689 };
15690 let mut cursor: &mut toml_edit::Table = root;
15691 for seg in parents {
15692 if !cursor.contains_key(seg) {
15693 cursor.insert(seg, toml_edit::Item::Table(toml_edit::Table::new()));
15694 }
15695 cursor = match cursor.get_mut(seg).and_then(|i| i.as_table_mut()) {
15696 Some(t) => t,
15697 None => return,
15698 };
15699 }
15700 let new_item = crate::migration::toml_value_to_edit_item(value);
15701 cursor.insert(last, new_item);
15702}
15703
15704#[allow(clippy::unused_async)] async fn sync_directory(path: &Path) -> Result<()> {
15706 #[cfg(unix)]
15707 {
15708 let dir = File::open(path).await.with_context(|| {
15709 format!(
15710 "Failed to open directory for fsync: {}",
15711 path.display().to_string()
15712 )
15713 })?;
15714 dir.sync_all().await.with_context(|| {
15715 format!(
15716 "Failed to fsync directory metadata: {}",
15717 path.display().to_string()
15718 )
15719 })?;
15720 Ok(())
15721 }
15722
15723 #[cfg(windows)]
15724 {
15725 use std::os::windows::fs::OpenOptionsExt;
15726 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x02000000;
15727 let dir = std::fs::OpenOptions::new()
15728 .read(true)
15729 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
15730 .open(path)
15731 .with_context(|| {
15732 format!(
15733 "Failed to open directory for fsync: {}",
15734 path.display().to_string()
15735 )
15736 })?;
15737 if let Err(e) = dir.sync_all() {
15742 if e.raw_os_error() == Some(5) {
15743 ::zeroclaw_log::record!(
15744 TRACE,
15745 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
15746 &format!(
15747 "Ignoring expected ACCESS_DENIED when fsyncing directory on Windows: {}",
15748 path.display().to_string()
15749 )
15750 );
15751 } else {
15752 return Err(e).with_context(|| {
15753 format!(
15754 "Failed to fsync directory metadata: {}",
15755 path.display().to_string()
15756 )
15757 });
15758 }
15759 }
15760 Ok(())
15761 }
15762
15763 #[cfg(not(any(unix, windows)))]
15764 {
15765 let _ = path;
15766 Ok(())
15767 }
15768}
15769
15770#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
15778#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
15779#[prefix = "sop"]
15780pub struct SopConfig {
15781 #[serde(default)]
15786 pub sops_dir: Option<String>,
15787
15788 #[serde(default = "default_sop_execution_mode")]
15792 pub default_execution_mode: String,
15793
15794 #[serde(default = "default_sop_max_concurrent_total")]
15796 pub max_concurrent_total: usize,
15797
15798 #[serde(default = "default_sop_approval_timeout_secs")]
15802 pub approval_timeout_secs: u64,
15803
15804 #[serde(default = "default_sop_max_finished_runs")]
15807 pub max_finished_runs: usize,
15808}
15809
15810fn default_sop_execution_mode() -> String {
15811 "supervised".to_string()
15812}
15813
15814fn default_sop_max_concurrent_total() -> usize {
15815 4
15816}
15817
15818fn default_sop_approval_timeout_secs() -> u64 {
15819 300
15820}
15821
15822fn default_sop_max_finished_runs() -> usize {
15823 100
15824}
15825
15826impl Default for SopConfig {
15827 fn default() -> Self {
15828 Self {
15829 sops_dir: None,
15830 default_execution_mode: default_sop_execution_mode(),
15831 max_concurrent_total: default_sop_max_concurrent_total(),
15832 approval_timeout_secs: default_sop_approval_timeout_secs(),
15833 max_finished_runs: default_sop_max_finished_runs(),
15834 }
15835 }
15836}
15837
15838macro_rules! impl_enum_prop_kind {
15842 ($($ty:ty),+ $(,)?) => {
15843 $(impl HasPropKind for $ty { const PROP_KIND: PropKind = PropKind::Enum; })+
15844 };
15845}
15846impl_enum_prop_kind!(
15847 WireApi,
15848 HardwareTransport,
15849 McpTransport,
15850 ToolFilterGroupMode,
15851 SkillsPromptInjectionMode,
15852 FirecrawlMode,
15853 ProxyScope,
15854 SearchMode,
15855 CronScheduleDecl,
15856 StreamMode,
15857 WhatsAppWebMode,
15858 WhatsAppChatPolicy,
15859 LineDmPolicy,
15860 LineGroupPolicy,
15861 LarkReceiveMode,
15862 OtpMethod,
15863 SandboxBackend,
15864 AutonomyLevel,
15865 AuthMode,
15866 OpenAIEndpoint,
15867 AzureEndpoint,
15868 AnthropicEndpoint,
15869 MoonshotEndpoint,
15870 QwenEndpoint,
15871 BedrockEndpoint,
15872 OpenRouterEndpoint,
15873 OllamaEndpoint,
15874 TogetherEndpoint,
15875 FireworksEndpoint,
15876 GroqEndpoint,
15877 MistralEndpoint,
15878 DeepseekEndpoint,
15879 CohereEndpoint,
15880 PerplexityEndpoint,
15881 XaiEndpoint,
15882 CerebrasEndpoint,
15883 SambanovaEndpoint,
15884 HyperbolicEndpoint,
15885 DeepinfraEndpoint,
15886 HuggingfaceEndpoint,
15887 Ai21Endpoint,
15888 RekaEndpoint,
15889 BasetenEndpoint,
15890 NscaleEndpoint,
15891 AnyscaleEndpoint,
15892 NebiusEndpoint,
15893 FriendliEndpoint,
15894 StepfunEndpoint,
15895 AihubmixEndpoint,
15896 SiliconflowEndpoint,
15897 AstraiEndpoint,
15898 AvianEndpoint,
15899 DeepmystEndpoint,
15900 VeniceEndpoint,
15901 NovitaEndpoint,
15902 NvidiaEndpoint,
15903 TelnyxEndpoint,
15904 VercelEndpoint,
15905 CloudflareEndpoint,
15906 OvhEndpoint,
15907 CopilotEndpoint,
15908 OpenAITtsEndpoint,
15909 ElevenLabsTtsEndpoint,
15910 GoogleTtsEndpoint,
15911 EdgeTtsEndpoint,
15912 PiperTtsEndpoint,
15913 GlmEndpoint,
15914 MinimaxEndpoint,
15915 ZaiEndpoint,
15916 DoubaoEndpoint,
15917 YiEndpoint,
15918 HunyuanEndpoint,
15919 QianfanEndpoint,
15920 BaichuanEndpoint,
15921 GeminiEndpoint,
15922 GeminiCliEndpoint,
15923 LmstudioEndpoint,
15924 LlamacppEndpoint,
15925 SglangEndpoint,
15926 VllmEndpoint,
15927 OsaurusEndpoint,
15928 LitellmEndpoint,
15929 LeptonEndpoint,
15930 SyntheticEndpoint,
15931 OpencodeEndpoint,
15932 KiloCliEndpoint,
15933 CustomEndpoint,
15934);
15935
15936impl HasPropKind for serde_json::Value {
15937 const PROP_KIND: PropKind = PropKind::String;
15948}
15949
15950#[cfg(test)]
15951mod tests {
15952 use super::*;
15953 #[cfg(unix)]
15954 use std::os::unix::fs::PermissionsExt;
15955 use std::path::PathBuf;
15956 use tempfile::TempDir;
15957 use tokio::sync::MutexGuard;
15958 use tokio::test;
15959
15960 #[test]
15963 async fn expand_tilde_path_handles_absolute_path() {
15964 let path = expand_tilde_path("/absolute/path");
15965 assert_eq!(path, PathBuf::from("/absolute/path"));
15966 }
15967
15968 #[test]
15969 async fn expand_tilde_path_handles_relative_path() {
15970 let path = expand_tilde_path("relative/path");
15971 assert_eq!(path, PathBuf::from("relative/path"));
15972 }
15973
15974 #[test]
15975 async fn expand_tilde_path_expands_tilde_when_home_set() {
15976 let path = expand_tilde_path("~/.zeroclaw");
15979 if std::env::var("HOME").is_ok() {
15982 assert!(
15983 !path.to_string_lossy().starts_with('~'),
15984 "Tilde should be expanded when HOME is set"
15985 );
15986 }
15987 }
15988
15989 fn has_test_table(raw: &str, table: &str) -> bool {
15992 let exact = format!("[{table}]");
15993 let nested = format!("[{table}.");
15994 raw.lines()
15995 .map(str::trim)
15996 .any(|line| line == exact || line.starts_with(&nested))
15997 }
15998
15999 fn parse_test_config(raw: &str) -> Config {
16000 let mut merged = raw.trim().to_string();
16001 for table in [
16002 "data_retention",
16003 "cloud_ops",
16004 "conversational_ai",
16005 "security",
16006 "security_ops",
16007 ] {
16008 if has_test_table(&merged, table) {
16009 continue;
16010 }
16011 if !merged.is_empty() {
16012 merged.push_str("\n\n");
16013 }
16014 merged.push('[');
16015 merged.push_str(table);
16016 merged.push(']');
16017 }
16018 merged.push('\n');
16019 let mut config: Config = toml::from_str(&merged).unwrap();
16026 config
16027 .risk_profiles
16028 .entry("default".to_string())
16029 .or_default()
16030 .ensure_default_auto_approve();
16031 config
16032 }
16033
16034 #[test]
16035 async fn http_request_config_default_has_correct_values() {
16036 let cfg = HttpRequestConfig::default();
16037 assert_eq!(cfg.timeout_secs, 30);
16038 assert_eq!(cfg.max_response_size, 1_000_000);
16039 assert!(cfg.enabled);
16040 assert_eq!(cfg.allowed_domains, vec!["*".to_string()]);
16041 }
16042
16043 #[test]
16044 async fn config_default_has_sane_values() {
16045 let c = Config::default();
16046 assert!(c.providers.models.is_empty());
16048 assert!(c.first_model_provider().is_none());
16049 assert!(!c.skills.open_skills_enabled);
16050 assert!(!c.skills.allow_scripts);
16051 assert!(!c.skills.install_suggestions.enabled);
16052 assert_eq!(
16053 c.skills.prompt_injection_mode,
16054 SkillsPromptInjectionMode::Full
16055 );
16056 assert!(c.data_dir.to_string_lossy().contains("data"));
16057 assert!(c.config_path.to_string_lossy().contains("config.toml"));
16058 }
16059
16060 #[test]
16061 async fn skills_install_suggestions_config_deserializes_enabled() {
16062 let c = parse_test_config(
16063 r#"
16064[skills.install_suggestions]
16065enabled = true
16066"#,
16067 );
16068
16069 assert!(c.skills.install_suggestions.enabled);
16070 }
16071
16072 #[test]
16073 async fn skills_install_suggestions_config_accepts_hyphen_alias() {
16074 let c = parse_test_config(
16075 r#"
16076[skills.install-suggestions]
16077enabled = true
16078"#,
16079 );
16080
16081 assert!(c.skills.install_suggestions.enabled);
16082 }
16083
16084 fn capture_log_events() -> tokio::sync::broadcast::Receiver<serde_json::Value> {
16085 ::zeroclaw_log::try_install_capture_subscriber();
16086 ::zeroclaw_log::subscribe_or_install()
16087 }
16088
16089 fn drain_captured(rx: &mut tokio::sync::broadcast::Receiver<serde_json::Value>) -> String {
16090 let mut buf = String::new();
16091 while let Ok(value) = rx.try_recv() {
16092 buf.push_str(&serde_json::to_string(&value).unwrap_or_default());
16093 buf.push('\n');
16094 }
16095 buf
16096 }
16097
16098 #[test]
16099 async fn config_dir_creation_error_mentions_openrc_and_path() {
16100 let msg = config_dir_creation_error(Path::new("/etc/zeroclaw"));
16101 assert!(msg.contains("/etc/zeroclaw"));
16102 assert!(msg.contains("OpenRC"));
16103 assert!(msg.contains("zeroclaw"));
16104 }
16105
16106 #[test]
16107 async fn config_schema_export_contains_expected_contract_shape() {
16108 #[cfg(feature = "schema-export")]
16109 let schema = schemars::schema_for!(Config);
16110 let schema_json = serde_json::to_value(&schema).expect("schema should serialize to json");
16111
16112 assert_eq!(
16113 schema_json
16114 .get("$schema")
16115 .and_then(serde_json::Value::as_str),
16116 Some("https://json-schema.org/draft/2020-12/schema")
16117 );
16118
16119 let properties = schema_json
16120 .get("properties")
16121 .and_then(serde_json::Value::as_object)
16122 .expect("schema should expose top-level properties");
16123
16124 assert!(properties.contains_key("providers"));
16125 assert!(properties.contains_key("skills"));
16126 assert!(properties.contains_key("gateway"));
16127 assert!(properties.contains_key("channels"));
16128 assert!(!properties.contains_key("workspace_dir"));
16129 assert!(!properties.contains_key("config_path"));
16130 assert!(!properties.contains_key("model_providers"));
16131 assert!(!properties.contains_key("tts_providers"));
16132 assert!(!properties.contains_key("transcription_providers"));
16133 assert!(!properties.contains_key("default_model_provider"));
16135 assert!(!properties.contains_key("api_key"));
16136 assert!(!properties.contains_key("default_model"));
16137
16138 assert!(
16139 schema_json
16140 .get("$defs")
16141 .and_then(serde_json::Value::as_object)
16142 .is_some(),
16143 "schema should include reusable type definitions"
16144 );
16145 }
16146
16147 #[cfg(unix)]
16148 #[test]
16149 async fn save_sets_config_permissions_on_new_file() {
16150 let temp = TempDir::new().expect("temp dir");
16151 let config_path = temp.path().join("config.toml");
16152 let workspace_dir = temp.path().join("workspace");
16153
16154 let config = Config {
16155 config_path: config_path.clone(),
16156 data_dir: workspace_dir,
16157 ..Default::default()
16158 };
16159
16160 config.save().await.expect("save config");
16161
16162 let mode = std::fs::metadata(&config_path)
16163 .expect("config metadata")
16164 .permissions()
16165 .mode()
16166 & 0o777;
16167 assert_eq!(mode, 0o600);
16168 }
16169
16170 #[test]
16171 async fn observability_config_default() {
16172 let o = ObservabilityConfig::default();
16173 assert_eq!(o.backend, "none");
16174 assert_eq!(o.log_persistence, "rolling");
16175 assert_eq!(o.log_persistence_path, "state/runtime-trace.jsonl");
16176 assert_eq!(o.log_persistence_max_entries, 200);
16177 assert_eq!(o.log_tool_io, "redacted");
16178 assert_eq!(o.log_tool_io_truncate_bytes, 8192);
16179 assert!(o.log_tool_io_denylist.is_empty());
16180 }
16181
16182 #[test]
16183 async fn risk_profile_default_mirrors_v2_autonomy_safety_defaults() {
16184 let a = RiskProfileConfig::default();
16185 assert_eq!(a.level, AutonomyLevel::Supervised);
16186 assert!(a.workspace_only);
16187 assert!(a.allowed_commands.contains(&"git".to_string()));
16188 assert!(a.allowed_commands.contains(&"cargo".to_string()));
16189 assert!(
16190 !a.forbidden_paths.is_empty(),
16191 "default forbidden_paths must not be empty"
16192 );
16193 #[cfg(not(target_os = "windows"))]
16194 assert!(
16195 a.forbidden_paths.iter().any(|p| p == "/etc"),
16196 "Default forbidden_paths must include /etc on Unix"
16197 );
16198 #[cfg(target_os = "windows")]
16199 assert!(
16200 a.forbidden_paths.iter().any(|p| p == "C:\\Windows"),
16201 "Default forbidden_paths must include C:\\Windows on Windows"
16202 );
16203 assert!(
16204 a.forbidden_paths.contains(&"~/.ssh".to_string()),
16205 "Default forbidden_paths must include ~/.ssh"
16206 );
16207 assert!(a.require_approval_for_medium_risk);
16208 assert!(a.block_high_risk_commands);
16209 assert!(a.shell_env_passthrough.is_empty());
16210 assert!(a.allowed_tools.is_empty());
16211 }
16212
16213 #[test]
16214 async fn runtime_config_default() {
16215 let r = RuntimeConfig::default();
16216 assert_eq!(r.kind, "native");
16217 assert_eq!(r.docker.image, "alpine:3.20");
16218 assert_eq!(r.docker.network, "none");
16219 assert_eq!(r.docker.memory_limit_mb, Some(512));
16220 assert_eq!(r.docker.cpu_limit, Some(1.0));
16221 assert!(r.docker.read_only_rootfs);
16222 assert!(r.docker.mount_workspace);
16223 }
16224
16225 #[test]
16226 async fn heartbeat_config_default() {
16227 let h = HeartbeatConfig::default();
16228 assert!(!h.enabled);
16232 assert!(h.agent.is_empty());
16233 assert_eq!(h.interval_minutes, 30);
16234 assert!(h.message.is_none());
16235 assert!(h.target.is_none());
16236 assert!(h.to.is_none());
16237 }
16238
16239 #[test]
16240 async fn heartbeat_config_parses_delivery_aliases() {
16241 let raw = r#"
16242enabled = true
16243interval_minutes = 10
16244message = "Ping"
16245channel = "telegram"
16246recipient = "42"
16247"#;
16248 let parsed: HeartbeatConfig = toml::from_str(raw).unwrap();
16249 assert!(parsed.enabled);
16250 assert_eq!(parsed.interval_minutes, 10);
16251 assert_eq!(parsed.message.as_deref(), Some("Ping"));
16252 assert_eq!(parsed.target.as_deref(), Some("telegram"));
16253 assert_eq!(parsed.to.as_deref(), Some("42"));
16254 }
16255
16256 #[test]
16257 async fn scheduler_config_default() {
16258 let s = SchedulerConfig::default();
16259 assert!(s.enabled);
16260 assert!(s.catch_up_on_startup);
16261 assert_eq!(s.max_run_history, 50);
16262 }
16263
16264 #[test]
16265 async fn scheduler_config_serde_roundtrip() {
16266 let s = SchedulerConfig {
16267 enabled: false,
16268 max_tasks: 16,
16269 max_concurrent: 2,
16270 catch_up_on_startup: false,
16271 max_run_history: 100,
16272 };
16273 let json = serde_json::to_string(&s).unwrap();
16274 let parsed: SchedulerConfig = serde_json::from_str(&json).unwrap();
16275 assert!(!parsed.enabled);
16276 assert!(!parsed.catch_up_on_startup);
16277 assert_eq!(parsed.max_run_history, 100);
16278 }
16279
16280 #[test]
16281 async fn config_defaults_scheduler_when_section_missing() {
16282 let toml_str = r#"
16283workspace_dir = "/tmp/workspace"
16284config_path = "/tmp/config.toml"
16285default_temperature = 0.7
16286"#;
16287
16288 let parsed = parse_test_config(toml_str);
16289 assert!(parsed.scheduler.enabled);
16290 assert!(parsed.scheduler.catch_up_on_startup);
16291 assert_eq!(parsed.scheduler.max_run_history, 50);
16292 assert!(parsed.cron.is_empty());
16293 }
16294
16295 #[test]
16296 async fn memory_config_default_hygiene_settings() {
16297 let m = MemoryConfig::default();
16298 assert_eq!(m.backend, "sqlite");
16299 assert!(m.auto_save);
16300 assert!(m.hygiene_enabled);
16301 assert_eq!(m.archive_after_days, 7);
16302 assert_eq!(m.purge_after_days, 30);
16303 assert_eq!(m.conversation_retention_days, 30);
16304 assert_eq!(m.search_mode, SearchMode::Hybrid);
16305 }
16306
16307 #[test]
16308 async fn search_mode_config_deserialization() {
16309 let toml_str = r#"
16310workspace_dir = "/tmp/workspace"
16311config_path = "/tmp/config.toml"
16312default_temperature = 0.7
16313
16314[memory]
16315backend = "sqlite"
16316auto_save = true
16317search_mode = "bm25"
16318"#;
16319 let parsed = parse_test_config(toml_str);
16320 assert_eq!(parsed.memory.search_mode, SearchMode::Bm25);
16321
16322 let toml_str_embedding = r#"
16323workspace_dir = "/tmp/workspace"
16324config_path = "/tmp/config.toml"
16325default_temperature = 0.7
16326
16327[memory]
16328backend = "sqlite"
16329auto_save = true
16330search_mode = "embedding"
16331"#;
16332 let parsed = parse_test_config(toml_str_embedding);
16333 assert_eq!(parsed.memory.search_mode, SearchMode::Embedding);
16334
16335 let toml_str_hybrid = r#"
16336workspace_dir = "/tmp/workspace"
16337config_path = "/tmp/config.toml"
16338default_temperature = 0.7
16339
16340[memory]
16341backend = "sqlite"
16342auto_save = true
16343search_mode = "hybrid"
16344"#;
16345 let parsed = parse_test_config(toml_str_hybrid);
16346 assert_eq!(parsed.memory.search_mode, SearchMode::Hybrid);
16347 }
16348
16349 #[test]
16350 async fn search_mode_defaults_to_hybrid_when_omitted() {
16351 let toml_str = r#"
16352workspace_dir = "/tmp/workspace"
16353config_path = "/tmp/config.toml"
16354default_temperature = 0.7
16355
16356[memory]
16357backend = "sqlite"
16358auto_save = true
16359"#;
16360 let parsed = parse_test_config(toml_str);
16361 assert_eq!(parsed.memory.search_mode, SearchMode::Hybrid);
16362 }
16363
16364 #[test]
16365 async fn search_mode_serde_roundtrip() {
16366 let json_bm25 = serde_json::to_string(&SearchMode::Bm25).unwrap();
16367 assert_eq!(json_bm25, "\"bm25\"");
16368 let parsed: SearchMode = serde_json::from_str(&json_bm25).unwrap();
16369 assert_eq!(parsed, SearchMode::Bm25);
16370
16371 let json_embedding = serde_json::to_string(&SearchMode::Embedding).unwrap();
16372 assert_eq!(json_embedding, "\"embedding\"");
16373 let parsed: SearchMode = serde_json::from_str(&json_embedding).unwrap();
16374 assert_eq!(parsed, SearchMode::Embedding);
16375
16376 let json_hybrid = serde_json::to_string(&SearchMode::Hybrid).unwrap();
16377 assert_eq!(json_hybrid, "\"hybrid\"");
16378 let parsed: SearchMode = serde_json::from_str(&json_hybrid).unwrap();
16379 assert_eq!(parsed, SearchMode::Hybrid);
16380 }
16381
16382 #[test]
16383 async fn storage_two_tier_defaults_empty() {
16384 let storage = StorageConfig::default();
16385 assert!(storage.sqlite.is_empty());
16386 assert!(storage.postgres.is_empty());
16387 assert!(storage.qdrant.is_empty());
16388 assert!(storage.markdown.is_empty());
16389 assert!(storage.lucid.is_empty());
16390 }
16391
16392 #[test]
16393 async fn storage_postgres_alias_pgvector_roundtrip() {
16394 let toml = r#"
16395 [postgres.default]
16396 db_url = "postgres://user:pw@host/db"
16397 vector_enabled = true
16398 vector_dimensions = 768
16399 "#;
16400 let parsed: StorageConfig = toml::from_str(toml).unwrap();
16401 let pg = parsed.postgres.get("default").expect("alias present");
16402 assert_eq!(pg.db_url.as_deref(), Some("postgres://user:pw@host/db"));
16403 assert!(pg.vector_enabled);
16404 assert_eq!(pg.vector_dimensions, 768);
16405 }
16406
16407 #[test]
16408 async fn storage_postgres_pgvector_defaults_when_omitted() {
16409 let toml = r#"
16410 [postgres.default]
16411 "#;
16412 let parsed: StorageConfig = toml::from_str(toml).unwrap();
16413 let pg = parsed.postgres.get("default").expect("alias present");
16414 assert!(!pg.vector_enabled);
16415 assert_eq!(pg.vector_dimensions, 1536);
16416 assert_eq!(pg.schema, "public");
16417 assert_eq!(pg.table, "memories");
16418 }
16419
16420 #[test]
16421 async fn ollama_alias_tuning_fields_roundtrip() {
16422 let toml = r#"
16428 num_ctx = 16384
16429 num_predict = 4096
16430 temperature_override = 0.5
16431 "#;
16432 let parsed: OllamaModelProviderConfig = toml::from_str(toml).unwrap();
16433 assert_eq!(parsed.num_ctx, Some(16384));
16434 assert_eq!(parsed.num_predict, Some(4096));
16435 assert_eq!(parsed.temperature_override, Some(0.5));
16436
16437 let serialized = toml::to_string(&parsed).unwrap();
16438 let reparsed: OllamaModelProviderConfig = toml::from_str(&serialized).unwrap();
16439 assert_eq!(reparsed.num_ctx, Some(16384));
16440 assert_eq!(reparsed.num_predict, Some(4096));
16441 assert_eq!(reparsed.temperature_override, Some(0.5));
16442 }
16443
16444 #[test]
16445 async fn ollama_alias_tuning_fields_default_to_none() {
16446 let toml = r#"
16447 api_key = "sk-test"
16448 "#;
16449 let parsed: OllamaModelProviderConfig = toml::from_str(toml).unwrap();
16450 assert!(parsed.num_ctx.is_none());
16451 assert!(parsed.num_predict.is_none());
16452 assert!(parsed.temperature_override.is_none());
16453 }
16454
16455 #[test]
16456 async fn channels_default() {
16457 let c = ChannelsConfig::default();
16458 assert!(c.cli);
16459 assert!(c.telegram.is_empty());
16460 assert!(c.discord.is_empty());
16461 assert!(c.wecom_ws.is_empty());
16462 assert!(!c.show_tool_calls);
16463 }
16464
16465 #[test]
16466 async fn wecom_ws_config_serde_defaults_and_secret_metadata() {
16467 let toml = r#"
16468 enabled = true
16469 bot_id = "bot-123"
16470 secret = "sk-test"
16471 allowed_users = ["zeroclaw_user"]
16472 allowed_groups = ["zeroclaw_group"]
16473 bot_name = "danya"
16474 proxy_url = "http://127.0.0.1:7890"
16475 "#;
16476 let parsed: WeComWsConfig = toml::from_str(toml).unwrap();
16477
16478 assert!(parsed.enabled);
16479 assert_eq!(parsed.bot_id, "bot-123");
16480 assert_eq!(parsed.secret, "sk-test");
16481 assert_eq!(parsed.allowed_users, vec!["zeroclaw_user"]);
16482 assert_eq!(parsed.allowed_groups, vec!["zeroclaw_group"]);
16483 assert_eq!(parsed.bot_name.as_deref(), Some("danya"));
16484 assert_eq!(parsed.file_retention_days, 7);
16485 assert_eq!(parsed.max_file_size_mb, 20);
16486 assert_eq!(parsed.stream_mode, StreamMode::Partial);
16487 assert_eq!(parsed.proxy_url.as_deref(), Some("http://127.0.0.1:7890"));
16488 assert!(parsed.excluded_tools.is_empty());
16489 assert_eq!(WeComWsConfig::default().file_retention_days, 7);
16490 assert_eq!(WeComWsConfig::default().max_file_size_mb, 20);
16491 assert_eq!(WeComWsConfig::default().stream_mode, StreamMode::Partial);
16492 assert!(WeComWsConfig::default().bot_name.is_none());
16493 assert!(WeComWsConfig::default().proxy_url.is_none());
16494 assert!(WeComWsConfig::prop_is_secret("channels.wecom_ws.secret"));
16495 }
16496
16497 #[test]
16498 async fn config_parses_wecom_ws_separate_from_wecom_webhook() {
16499 let toml = r#"
16500 [channels.wecom.default]
16501 enabled = true
16502 webhook_key = "webhook-key"
16503
16504 [channels.wecom_ws.default]
16505 enabled = true
16506 bot_id = "bot-123"
16507 secret = "sk-test"
16508 allowed_users = ["zeroclaw_user"]
16509 "#;
16510 let parsed: Config = toml::from_str(toml).unwrap();
16511
16512 assert_eq!(
16513 parsed.channels.wecom.get("default").unwrap().webhook_key,
16514 "webhook-key"
16515 );
16516 let ws = parsed.channels.wecom_ws.get("default").unwrap();
16517 assert_eq!(ws.bot_id, "bot-123");
16518 assert_eq!(ws.allowed_users, vec!["zeroclaw_user"]);
16519 assert_eq!(ws.stream_mode, StreamMode::Partial);
16520 }
16521
16522 #[test]
16525 async fn config_toml_roundtrip() {
16526 let config = Config {
16527 schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
16528 providers: {
16529 let mut p = crate::providers::Providers::default();
16530 p.models.openrouter.insert(
16531 "default".to_string(),
16532 OpenRouterModelProviderConfig {
16533 base: ModelProviderConfig {
16534 api_key: Some("sk-test-key".into()),
16535 model: Some("gpt-4o".into()),
16536 temperature: Some(0.5),
16537 timeout_secs: Some(120),
16538 ..Default::default()
16539 },
16540 },
16541 );
16542 p
16543 },
16544 model_routes: Vec::new(),
16545 embedding_routes: Vec::new(),
16546 data_dir: PathBuf::from("/tmp/test/workspace"),
16547 config_path: PathBuf::from("/tmp/test/config.toml"),
16548 observability: ObservabilityConfig {
16549 backend: "log".into(),
16550 ..ObservabilityConfig::default()
16551 },
16552 risk_profiles: {
16553 let mut m = HashMap::new();
16554 m.insert(
16555 "default".into(),
16556 RiskProfileConfig {
16557 level: AutonomyLevel::Full,
16558 workspace_only: false,
16559 allowed_commands: vec!["docker".into()],
16560 forbidden_paths: vec!["/secret".into()],
16561 require_approval_for_medium_risk: false,
16562 block_high_risk_commands: true,
16563 shell_env_passthrough: vec!["DATABASE_URL".into()],
16564 auto_approve: vec!["file_read".into()],
16565 always_ask: vec![],
16566 allowed_roots: vec![],
16567 allowed_tools: vec![],
16568 excluded_tools: vec![],
16569 ..RiskProfileConfig::default()
16570 },
16571 );
16572 m
16573 },
16574 trust: crate::scattered_types::TrustConfig::default(),
16575 backup: BackupConfig::default(),
16576 data_retention: DataRetentionConfig::default(),
16577 cloud_ops: CloudOpsConfig::default(),
16578 conversational_ai: ConversationalAiConfig::default(),
16579 security: SecurityConfig::default(),
16580 security_ops: SecurityOpsConfig::default(),
16581 runtime: RuntimeConfig {
16582 kind: "docker".into(),
16583 ..RuntimeConfig::default()
16584 },
16585 reliability: ReliabilityConfig::default(),
16586 scheduler: SchedulerConfig::default(),
16587 skills: SkillsConfig::default(),
16588 pipeline: PipelineConfig::default(),
16589 query_classification: QueryClassificationConfig::default(),
16590 heartbeat: HeartbeatConfig {
16591 enabled: true,
16592 interval_minutes: 15,
16593 two_phase: true,
16594 message: Some("Check London time".into()),
16595 target: Some("telegram".into()),
16596 to: Some("123456".into()),
16597 ..HeartbeatConfig::default()
16598 },
16599 cron: HashMap::new(),
16600 acp: AcpConfig::default(),
16601 channels: ChannelsConfig {
16602 cli: true,
16603 telegram: HashMap::from([(
16604 "default".to_string(),
16605 TelegramConfig {
16606 enabled: true,
16607 bot_token: "123:ABC".into(),
16608 stream_mode: StreamMode::default(),
16609 draft_update_interval_ms: default_draft_update_interval_ms(),
16610 interrupt_on_new_message: false,
16611 mention_only: false,
16612 ack_reactions: None,
16613 proxy_url: None,
16614 approval_timeout_secs: default_telegram_approval_timeout_secs(),
16615 excluded_tools: vec![],
16616 default_target: None,
16617 },
16618 )]),
16619 discord: HashMap::new(),
16620 slack: HashMap::new(),
16621 mattermost: HashMap::new(),
16622 webhook: HashMap::new(),
16623 imessage: HashMap::new(),
16624 matrix: HashMap::new(),
16625 signal: HashMap::new(),
16626 whatsapp: HashMap::new(),
16627 linq: HashMap::new(),
16628 wati: HashMap::new(),
16629 nextcloud_talk: HashMap::new(),
16630 email: HashMap::new(),
16631 gmail_push: HashMap::new(),
16632 irc: HashMap::new(),
16633 lark: HashMap::new(),
16634 line: HashMap::new(),
16635 dingtalk: HashMap::new(),
16636 wecom: HashMap::new(),
16637 wecom_ws: HashMap::new(),
16638 wechat: HashMap::new(),
16639 qq: HashMap::new(),
16640 twitter: HashMap::new(),
16641 mochat: HashMap::new(),
16642 nostr: HashMap::new(),
16643 clawdtalk: HashMap::new(),
16644 reddit: HashMap::new(),
16645 bluesky: HashMap::new(),
16646 voice_call: HashMap::new(),
16647 voice_duplex: HashMap::new(),
16648 voice_wake: HashMap::new(),
16649 mqtt: HashMap::new(),
16650 message_timeout_secs: 300,
16651 ack_reactions: true,
16652 show_tool_calls: true,
16653 session_persistence: true,
16654 session_backend: default_session_backend(),
16655 session_ttl_hours: 0,
16656 debounce_ms: 0,
16657 },
16658 memory: MemoryConfig::default(),
16659 storage: StorageConfig::default(),
16660 tunnel: TunnelConfig::default(),
16661 gateway: GatewayConfig::default(),
16662 composio: ComposioConfig::default(),
16663 microsoft365: Microsoft365Config::default(),
16664 secrets: SecretsConfig::default(),
16665 browser: BrowserConfig::default(),
16666 browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
16667 http_request: HttpRequestConfig::default(),
16668 multimodal: MultimodalConfig::default(),
16669 media_pipeline: MediaPipelineConfig::default(),
16670 web_fetch: WebFetchConfig::default(),
16671 link_enricher: LinkEnricherConfig::default(),
16672 text_browser: TextBrowserConfig::default(),
16673 web_search: WebSearchConfig::default(),
16674 project_intel: ProjectIntelConfig::default(),
16675 google_workspace: GoogleWorkspaceConfig::default(),
16676 proxy: ProxyConfig::default(),
16677 pacing: PacingConfig::default(),
16678 cost: CostConfig::default(),
16679 peripherals: PeripheralsConfig::default(),
16680 delegate: DelegateToolConfig::default(),
16681 agents: HashMap::new(),
16682 runtime_profiles: HashMap::new(),
16683 skill_bundles: HashMap::new(),
16684 knowledge_bundles: HashMap::new(),
16685 mcp_bundles: HashMap::new(),
16686 peer_groups: HashMap::new(),
16687 hooks: HooksConfig::default(),
16688 hardware: HardwareConfig::default(),
16689 transcription: TranscriptionConfig::default(),
16690 tts: TtsConfig::default(),
16691 mcp: McpConfig::default(),
16692 nodes: NodesConfig::default(),
16693 onboard_state: OnboardStateConfig::default(),
16694 notion: NotionConfig::default(),
16695 jira: JiraConfig::default(),
16696 node_transport: NodeTransportConfig::default(),
16697 knowledge: KnowledgeConfig::default(),
16698 linkedin: LinkedInConfig::default(),
16699 image_gen: ImageGenConfig::default(),
16700 file_upload: FileUploadConfig::default(),
16701 file_upload_bundle: FileUploadBundleConfig::default(),
16702 file_download: FileDownloadConfig::default(),
16703 plugins: PluginsConfig::default(),
16704 locale: None,
16705 verifiable_intent: VerifiableIntentConfig::default(),
16706 claude_code: ClaudeCodeConfig::default(),
16707 claude_code_runner: ClaudeCodeRunnerConfig::default(),
16708 codex_cli: CodexCliConfig::default(),
16709 gemini_cli: GeminiCliConfig::default(),
16710 opencode_cli: OpenCodeCliConfig::default(),
16711 sop: SopConfig::default(),
16712 shell_tool: ShellToolConfig::default(),
16713 escalation: EscalationConfig::default(),
16714 env_overridden_paths: std::collections::HashSet::new(),
16715 pre_override_snapshots: std::collections::HashMap::new(),
16716 dirty_paths: std::collections::HashSet::new(),
16717 };
16718 let toml_str = toml::to_string_pretty(&config).unwrap();
16721 let parsed = parse_test_config(&toml_str);
16722
16723 assert_eq!(parsed.providers.models.len(), config.providers.models.len());
16724 assert_eq!(parsed.observability.backend, "log");
16725 assert_eq!(parsed.observability.log_persistence, "rolling");
16726 let default_profile = parsed.risk_profiles.get("default").unwrap();
16727 assert_eq!(default_profile.level, AutonomyLevel::Full);
16728 assert!(!default_profile.workspace_only);
16729 assert_eq!(parsed.runtime.kind, "docker");
16730 assert!(parsed.heartbeat.enabled);
16731 assert_eq!(parsed.heartbeat.interval_minutes, 15);
16732 assert_eq!(
16733 parsed.heartbeat.message.as_deref(),
16734 Some("Check London time")
16735 );
16736 assert_eq!(parsed.heartbeat.target.as_deref(), Some("telegram"));
16737 assert_eq!(parsed.heartbeat.to.as_deref(), Some("123456"));
16738 assert!(!parsed.channels.telegram.is_empty());
16739 assert_eq!(
16740 parsed.channels.telegram.get("default").unwrap().bot_token,
16741 "123:ABC"
16742 );
16743 }
16744
16745 #[test]
16746 async fn config_minimal_toml_uses_defaults() {
16747 let minimal = r#"
16748workspace_dir = "/tmp/ws"
16749config_path = "/tmp/config.toml"
16750default_temperature = 0.7
16751"#;
16752 let parsed = parse_test_config(minimal);
16753 assert!(
16754 parsed
16755 .first_model_provider()
16756 .and_then(|e| e.api_key.as_deref())
16757 .is_none()
16758 );
16759 assert_eq!(parsed.observability.backend, "none");
16760 assert_eq!(parsed.observability.log_persistence, "rolling");
16761 assert_eq!(
16765 parsed
16766 .risk_profiles
16767 .get("default")
16768 .expect("migration synthesized risk_profiles.default")
16769 .level,
16770 AutonomyLevel::Supervised
16771 );
16772 assert_eq!(parsed.runtime.kind, "native");
16773 assert!(!parsed.heartbeat.enabled);
16775 assert!(parsed.channels.cli);
16776 assert!(parsed.memory.hygiene_enabled);
16777 assert_eq!(parsed.memory.archive_after_days, 7);
16778 assert_eq!(parsed.memory.purge_after_days, 30);
16779 assert_eq!(parsed.memory.conversation_retention_days, 30);
16780 assert!(
16782 (parsed
16783 .first_model_provider()
16784 .and_then(|e| e.temperature)
16785 .unwrap_or(0.7)
16786 - 0.7)
16787 .abs()
16788 < f64::EPSILON
16789 );
16790 assert_eq!(
16791 parsed
16792 .first_model_provider()
16793 .and_then(|e| e.timeout_secs)
16794 .unwrap_or(120),
16795 DEFAULT_DELEGATE_TIMEOUT_SECS
16796 );
16797 }
16798
16799 #[test]
16802 async fn v2_autonomy_section_migrates_onto_risk_profiles_default() {
16803 let raw = r#"
16804schema_version = 2
16805default_temperature = 0.7
16806
16807[autonomy]
16808level = "full"
16809max_actions_per_hour = 99
16810auto_approve = ["file_read", "memory_recall", "http_request"]
16811"#;
16812 let parsed = crate::migration::migrate_to_current(raw).unwrap();
16813 let profile = parsed
16814 .risk_profiles
16815 .get("default")
16816 .expect("default profile");
16817 assert_eq!(profile.level, AutonomyLevel::Full);
16818 assert!(profile.auto_approve.contains(&"http_request".to_string()));
16819 let runtime = parsed
16820 .runtime_profiles
16821 .get("default")
16822 .expect("default runtime profile");
16823 assert_eq!(runtime.max_actions_per_hour, 99);
16824 }
16825
16826 #[test]
16829 async fn auto_approve_merges_user_entries_with_defaults() {
16830 let raw = r#"
16831default_temperature = 0.7
16832
16833[risk_profiles.default]
16834auto_approve = ["my_custom_tool", "another_tool"]
16835"#;
16836 let parsed = parse_test_config(raw);
16837 let profile = parsed.risk_profiles.get("default").unwrap();
16838 assert!(profile.auto_approve.contains(&"my_custom_tool".to_string()));
16839 assert!(profile.auto_approve.contains(&"another_tool".to_string()));
16840 for default_tool in &[
16841 "file_read",
16842 "memory_recall",
16843 "weather",
16844 "calculator",
16845 "web_fetch",
16846 ] {
16847 assert!(
16848 profile.auto_approve.contains(&String::from(*default_tool)),
16849 "default tool '{default_tool}' must be present"
16850 );
16851 }
16852 }
16853
16854 #[test]
16856 async fn auto_approve_empty_list_gets_defaults() {
16857 let raw = r#"
16858default_temperature = 0.7
16859
16860[risk_profiles.default]
16861auto_approve = []
16862"#;
16863 let parsed = parse_test_config(raw);
16864 let profile = parsed.risk_profiles.get("default").unwrap();
16865 for tool in &default_auto_approve() {
16866 assert!(
16867 profile.auto_approve.contains(tool),
16868 "default tool '{tool}' must be present"
16869 );
16870 }
16871 }
16872
16873 #[test]
16876 async fn auto_approve_defaults_when_no_risk_profile_section() {
16877 let raw = r#"
16878default_temperature = 0.7
16879"#;
16880 let parsed = parse_test_config(raw);
16881 let profile = parsed.risk_profiles.get("default").unwrap();
16882 for tool in &default_auto_approve() {
16883 assert!(
16884 profile.auto_approve.contains(tool),
16885 "default tool '{tool}' must be present"
16886 );
16887 }
16888 }
16889
16890 #[test]
16893 async fn auto_approve_no_duplicates() {
16894 let raw = r#"
16895default_temperature = 0.7
16896
16897[risk_profiles.default]
16898auto_approve = ["weather", "file_read"]
16899"#;
16900 let parsed = parse_test_config(raw);
16901 let profile = parsed.risk_profiles.get("default").unwrap();
16902 assert_eq!(
16903 profile
16904 .auto_approve
16905 .iter()
16906 .filter(|t| *t == "weather")
16907 .count(),
16908 1
16909 );
16910 assert_eq!(
16911 profile
16912 .auto_approve
16913 .iter()
16914 .filter(|t| *t == "file_read")
16915 .count(),
16916 1
16917 );
16918 }
16919
16920 #[test]
16921 async fn provider_timeout_secs_parses_from_toml() {
16922 let raw = r#"
16925default_temperature = 0.7
16926provider_timeout_secs = 300
16927"#;
16928 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
16929 assert_eq!(
16930 parsed
16931 .first_model_provider()
16932 .and_then(|e| e.timeout_secs)
16933 .unwrap_or(120),
16934 300
16935 );
16936 }
16937
16938 #[test]
16939 async fn extra_headers_parses_from_toml() {
16940 let raw = r#"
16943default_temperature = 0.7
16944
16945[extra_headers]
16946User-Agent = "MyApp/1.0"
16947X-Title = "zeroclaw"
16948"#;
16949 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
16950 let headers = &parsed
16951 .first_model_provider()
16952 .expect("synthesized default model_provider")
16953 .extra_headers;
16954 assert_eq!(headers.len(), 2);
16955 assert_eq!(headers.get("User-Agent").unwrap(), "MyApp/1.0");
16956 assert_eq!(headers.get("X-Title").unwrap(), "zeroclaw");
16957 }
16958
16959 #[test]
16960 async fn extra_headers_defaults_to_empty() {
16961 let raw = r#"
16962default_temperature = 0.7
16963"#;
16964 let parsed = parse_test_config(raw);
16965 assert!(
16966 parsed
16967 .first_model_provider()
16968 .map(|e| e.extra_headers.is_empty())
16969 .unwrap_or(true)
16970 );
16971 }
16972
16973 #[test]
16974 async fn storage_postgres_dburl_alias_deserializes() {
16975 let raw = r#"
16976default_temperature = 0.7
16977
16978[storage.postgres.default]
16979dbURL = "postgres://user:pw@host/db"
16980schema = "public"
16981table = "memories"
16982connect_timeout_secs = 12
16983"#;
16984
16985 let parsed = parse_test_config(raw);
16986 let pg = parsed
16987 .storage
16988 .postgres
16989 .get("default")
16990 .expect("postgres.default present");
16991 assert_eq!(pg.db_url.as_deref(), Some("postgres://user:pw@host/db"));
16992 assert_eq!(pg.schema, "public");
16993 assert_eq!(pg.table, "memories");
16994 assert_eq!(pg.connect_timeout_secs, Some(12));
16995 }
16996
16997 #[test]
16998 async fn runtime_reasoning_enabled_deserializes() {
16999 let raw = r#"
17000default_temperature = 0.7
17001
17002[runtime]
17003reasoning_enabled = false
17004"#;
17005
17006 let parsed = parse_test_config(raw);
17007 assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
17008 }
17009
17010 #[test]
17011 async fn runtime_reasoning_effort_deserializes() {
17012 let raw = r#"
17013default_temperature = 0.7
17014
17015[runtime]
17016reasoning_effort = "HIGH"
17017"#;
17018
17019 let parsed: Config = toml::from_str(raw).unwrap();
17020 assert_eq!(parsed.runtime.reasoning_effort.as_deref(), Some("high"));
17021 }
17022
17023 #[test]
17024 async fn runtime_reasoning_effort_rejects_invalid_values() {
17025 let raw = r#"
17026default_temperature = 0.7
17027
17028[runtime]
17029reasoning_effort = "turbo"
17030"#;
17031
17032 let error = toml::from_str::<Config>(raw).expect_err("invalid value should fail");
17033 assert!(error.to_string().contains("reasoning_effort"));
17034 }
17035
17036 #[test]
17037 async fn agent_config_defaults() {
17038 let cfg = AliasedAgentConfig::default();
17039 assert!(cfg.compact_context);
17040 assert_eq!(cfg.max_tool_iterations, 10);
17041 assert_eq!(cfg.max_history_messages, 50);
17042 assert!(!cfg.parallel_tools);
17043 assert_eq!(cfg.tool_dispatcher, "auto");
17044 assert!(!cfg.strict_tool_parsing);
17045 }
17046
17047 #[test]
17048 async fn agent_config_deserializes() {
17049 let raw = r#"
17050default_temperature = 0.7
17051[agents.default]
17052compact_context = true
17053max_tool_iterations = 20
17054max_history_messages = 80
17055parallel_tools = true
17056tool_dispatcher = "xml"
17057strict_tool_parsing = true
17058"#;
17059 let parsed = parse_test_config(raw);
17060 let agent = parsed
17061 .agents
17062 .get("default")
17063 .expect("[agents.default] parses into agents map");
17064 assert!(agent.compact_context);
17065 assert_eq!(agent.max_tool_iterations, 20);
17066 assert_eq!(agent.max_history_messages, 80);
17067 assert!(agent.parallel_tools);
17068 assert_eq!(agent.tool_dispatcher, "xml");
17069 assert!(agent.strict_tool_parsing);
17070 }
17071
17072 #[test]
17073 async fn pacing_config_defaults_are_all_none_or_empty() {
17074 let cfg = PacingConfig::default();
17075 assert!(cfg.step_timeout_secs.is_none());
17076 assert!(cfg.loop_detection_min_elapsed_secs.is_none());
17077 assert!(cfg.loop_ignore_tools.is_empty());
17078 assert!(cfg.message_timeout_scale_max.is_none());
17079 }
17080
17081 #[test]
17082 async fn pacing_config_deserializes_from_toml() {
17083 let raw = r#"
17084default_temperature = 0.7
17085[pacing]
17086step_timeout_secs = 120
17087loop_detection_min_elapsed_secs = 60
17088loop_ignore_tools = ["browser_screenshot", "browser_navigate"]
17089message_timeout_scale_max = 8
17090"#;
17091 let parsed: Config = toml::from_str(raw).unwrap();
17092 assert_eq!(parsed.pacing.step_timeout_secs, Some(120));
17093 assert_eq!(parsed.pacing.loop_detection_min_elapsed_secs, Some(60));
17094 assert_eq!(
17095 parsed.pacing.loop_ignore_tools,
17096 vec!["browser_screenshot", "browser_navigate"]
17097 );
17098 assert_eq!(parsed.pacing.message_timeout_scale_max, Some(8));
17099 }
17100
17101 #[test]
17102 async fn pacing_config_absent_preserves_defaults() {
17103 let raw = r#"
17104default_temperature = 0.7
17105"#;
17106 let parsed: Config = toml::from_str(raw).unwrap();
17107 assert!(parsed.pacing.step_timeout_secs.is_none());
17108 assert!(parsed.pacing.loop_detection_min_elapsed_secs.is_none());
17109 assert!(parsed.pacing.loop_ignore_tools.is_empty());
17110 assert!(parsed.pacing.message_timeout_scale_max.is_none());
17111 }
17112
17113 #[tokio::test]
17114 async fn sync_directory_handles_existing_directory() {
17115 let dir = std::env::temp_dir().join(format!(
17116 "zeroclaw_test_sync_directory_{}",
17117 uuid::Uuid::new_v4()
17118 ));
17119 fs::create_dir_all(&dir).await.unwrap();
17120
17121 sync_directory(&dir).await.unwrap();
17122
17123 let _ = fs::remove_dir_all(&dir).await;
17124 }
17125
17126 #[tokio::test]
17127 async fn config_save_prunes_unchanged_default_blocks() {
17128 let dir =
17133 std::env::temp_dir().join(format!("zeroclaw_save_prune_test_{}", uuid::Uuid::new_v4()));
17134 fs::create_dir_all(&dir).await.unwrap();
17135 let config = Config {
17136 config_path: dir.join("config.toml"),
17137 data_dir: dir.join("data"),
17138 ..Default::default()
17139 };
17140 config.save().await.unwrap();
17141 let raw = fs::read_to_string(&config.config_path).await.unwrap();
17142
17143 assert!(
17146 raw.contains("schema_version"),
17147 "schema_version must survive pruning"
17148 );
17149
17150 for block in [
17153 "[memory]",
17154 "[linkedin",
17155 "[observability]",
17156 "[gateway]",
17157 "[cost]",
17158 ] {
17159 assert!(
17160 !raw.contains(block),
17161 "pruned config.toml must not emit defaulted block {block}; got:\n{raw}",
17162 );
17163 }
17164
17165 let _reloaded: Config = toml::from_str(&raw).expect("pruned config round-trips");
17168
17169 let _ = fs::remove_dir_all(&dir).await;
17170 }
17171
17172 #[tokio::test]
17173 async fn config_save_keeps_operator_set_non_default_fields() {
17174 let dir =
17175 std::env::temp_dir().join(format!("zeroclaw_save_keep_test_{}", uuid::Uuid::new_v4()));
17176 fs::create_dir_all(&dir).await.unwrap();
17177 let mut config = Config {
17178 config_path: dir.join("config.toml"),
17179 data_dir: dir.join("data"),
17180 ..Default::default()
17181 };
17182 config.locale = Some("ja-JP".into());
17184 config.providers.models.anthropic.insert(
17185 "claude_default".into(),
17186 AnthropicModelProviderConfig {
17187 base: ModelProviderConfig {
17188 model: Some("claude-sonnet-4".into()),
17189 ..Default::default()
17190 },
17191 },
17192 );
17193 config.save().await.unwrap();
17194 let raw = fs::read_to_string(&config.config_path).await.unwrap();
17195
17196 assert!(
17197 raw.contains("ja-JP"),
17198 "operator-set locale must survive pruning; got:\n{raw}",
17199 );
17200 assert!(
17201 raw.contains("claude_default"),
17202 "operator-added provider alias must survive pruning; got:\n{raw}",
17203 );
17204 assert!(
17205 raw.contains("claude-sonnet-4"),
17206 "operator-set model must survive pruning; got:\n{raw}",
17207 );
17208
17209 let _ = fs::remove_dir_all(&dir).await;
17210 }
17211
17212 #[tokio::test]
17213 async fn config_save_and_load_tmpdir() {
17214 let dir = std::env::temp_dir().join("zeroclaw_test_config");
17215 let _ = fs::remove_dir_all(&dir).await;
17216 fs::create_dir_all(&dir).await.unwrap();
17217
17218 let config_path = dir.join("config.toml");
17219 let mut providers = crate::providers::Providers::default();
17220 providers.models.openrouter.insert(
17221 "default".to_string(),
17222 OpenRouterModelProviderConfig {
17223 base: ModelProviderConfig {
17224 api_key: Some("sk-roundtrip".into()),
17225 model: Some("test-model".into()),
17226 temperature: Some(0.9),
17227 timeout_secs: Some(120),
17228 ..Default::default()
17229 },
17230 },
17231 );
17232 let config = Config {
17233 schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
17234 providers,
17235 model_routes: Vec::new(),
17236 embedding_routes: Vec::new(),
17237 data_dir: dir.join("workspace"),
17238 config_path: config_path.clone(),
17239 observability: ObservabilityConfig::default(),
17240 trust: crate::scattered_types::TrustConfig::default(),
17241 backup: BackupConfig::default(),
17242 data_retention: DataRetentionConfig::default(),
17243 cloud_ops: CloudOpsConfig::default(),
17244 conversational_ai: ConversationalAiConfig::default(),
17245 security: SecurityConfig::default(),
17246 security_ops: SecurityOpsConfig::default(),
17247 runtime: RuntimeConfig::default(),
17248 reliability: ReliabilityConfig::default(),
17249 scheduler: SchedulerConfig::default(),
17250 skills: SkillsConfig::default(),
17251 pipeline: PipelineConfig::default(),
17252 query_classification: QueryClassificationConfig::default(),
17253 heartbeat: HeartbeatConfig::default(),
17254 cron: HashMap::new(),
17255 acp: AcpConfig::default(),
17256 channels: ChannelsConfig::default(),
17257 memory: MemoryConfig::default(),
17258 storage: StorageConfig::default(),
17259 tunnel: TunnelConfig::default(),
17260 gateway: GatewayConfig::default(),
17261 composio: ComposioConfig::default(),
17262 microsoft365: Microsoft365Config::default(),
17263 secrets: SecretsConfig::default(),
17264 browser: BrowserConfig::default(),
17265 browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
17266 http_request: HttpRequestConfig::default(),
17267 multimodal: MultimodalConfig::default(),
17268 media_pipeline: MediaPipelineConfig::default(),
17269 web_fetch: WebFetchConfig::default(),
17270 link_enricher: LinkEnricherConfig::default(),
17271 text_browser: TextBrowserConfig::default(),
17272 web_search: WebSearchConfig::default(),
17273 project_intel: ProjectIntelConfig::default(),
17274 google_workspace: GoogleWorkspaceConfig::default(),
17275 proxy: ProxyConfig::default(),
17276 pacing: PacingConfig::default(),
17277 cost: CostConfig::default(),
17278 peripherals: PeripheralsConfig::default(),
17279 delegate: DelegateToolConfig::default(),
17280 agents: HashMap::new(),
17281 risk_profiles: HashMap::new(),
17282 runtime_profiles: HashMap::new(),
17283 skill_bundles: HashMap::new(),
17284 knowledge_bundles: HashMap::new(),
17285 mcp_bundles: HashMap::new(),
17286 peer_groups: HashMap::new(),
17287 hooks: HooksConfig::default(),
17288 hardware: HardwareConfig::default(),
17289 transcription: TranscriptionConfig::default(),
17290 tts: TtsConfig::default(),
17291 mcp: McpConfig::default(),
17292 nodes: NodesConfig::default(),
17293 onboard_state: OnboardStateConfig::default(),
17294 notion: NotionConfig::default(),
17295 jira: JiraConfig::default(),
17296 node_transport: NodeTransportConfig::default(),
17297 knowledge: KnowledgeConfig::default(),
17298 linkedin: LinkedInConfig::default(),
17299 image_gen: ImageGenConfig::default(),
17300 file_upload: FileUploadConfig::default(),
17301 file_upload_bundle: FileUploadBundleConfig::default(),
17302 file_download: FileDownloadConfig::default(),
17303 plugins: PluginsConfig::default(),
17304 locale: None,
17305 verifiable_intent: VerifiableIntentConfig::default(),
17306 claude_code: ClaudeCodeConfig::default(),
17307 claude_code_runner: ClaudeCodeRunnerConfig::default(),
17308 codex_cli: CodexCliConfig::default(),
17309 gemini_cli: GeminiCliConfig::default(),
17310 opencode_cli: OpenCodeCliConfig::default(),
17311 sop: SopConfig::default(),
17312 shell_tool: ShellToolConfig::default(),
17313 escalation: EscalationConfig::default(),
17314 env_overridden_paths: std::collections::HashSet::new(),
17315 pre_override_snapshots: std::collections::HashMap::new(),
17316 dirty_paths: std::collections::HashSet::new(),
17317 };
17318
17319 config.save().await.unwrap();
17321 assert!(config_path.exists());
17322
17323 let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
17324 let loaded = crate::migration::migrate_to_current(&contents).unwrap();
17325 let entry = &loaded
17326 .providers
17327 .models
17328 .find("openrouter", "default")
17329 .expect("entry exists");
17330 assert!(
17331 entry
17332 .api_key
17333 .as_deref()
17334 .is_some_and(crate::secrets::SecretStore::is_encrypted)
17335 );
17336 let store = crate::secrets::SecretStore::new(&dir, true);
17337 let decrypted = store.decrypt(entry.api_key.as_deref().unwrap()).unwrap();
17338 assert_eq!(decrypted, "sk-roundtrip");
17339 assert_eq!(entry.model.as_deref(), Some("test-model"));
17340 assert!(
17341 entry
17342 .temperature
17343 .is_some_and(|t| (t - 0.9).abs() < f64::EPSILON)
17344 );
17345
17346 let _ = fs::remove_dir_all(&dir).await;
17347 }
17348
17349 #[tokio::test]
17350 async fn config_save_encrypts_nested_credentials() {
17351 let dir = std::env::temp_dir().join(format!(
17352 "zeroclaw_test_nested_credentials_{}",
17353 uuid::Uuid::new_v4()
17354 ));
17355 fs::create_dir_all(&dir).await.unwrap();
17356
17357 let mut config = Config {
17358 data_dir: dir.join("workspace"),
17359 config_path: dir.join("config.toml"),
17360 ..Default::default()
17361 };
17362 config.providers.models.anthropic.insert(
17363 "default".to_string(),
17364 AnthropicModelProviderConfig {
17365 base: ModelProviderConfig {
17366 api_key: Some("root-credential".into()),
17367 ..Default::default()
17368 },
17369 },
17370 );
17371 config.composio.api_key = Some("composio-credential".into());
17373 config.browser.computer_use.api_key = Some("browser-credential".into());
17374 config.web_search.brave_api_key = Some("brave-credential".into());
17375 config.web_search.tavily_api_key = Some("tavily-credential".into());
17376 config.storage.postgres.insert(
17377 "default".to_string(),
17378 PostgresStorageConfig {
17379 db_url: Some("postgres://user:pw@host/db".into()),
17380 ..PostgresStorageConfig::default()
17381 },
17382 );
17383 config.channels.lark.insert(
17384 "feishu".to_string(),
17385 LarkConfig {
17386 enabled: true,
17387 app_id: "cli_feishu_123".into(),
17388 app_secret: "feishu-secret".into(),
17389 encrypt_key: Some("feishu-encrypt".into()),
17390 verification_token: Some("feishu-verify".into()),
17391 mention_only: false,
17392 use_feishu: true,
17393 receive_mode: LarkReceiveMode::Websocket,
17394 port: None,
17395 proxy_url: None,
17396 excluded_tools: vec![],
17397 default_target: None,
17398 },
17399 );
17400
17401 config.providers.models.openrouter.insert(
17402 "worker".into(),
17403 crate::schema::OpenRouterModelProviderConfig {
17404 base: ModelProviderConfig {
17405 api_key: Some("agent-credential".into()),
17406 model: Some("model-test".into()),
17407 ..Default::default()
17408 },
17409 },
17410 );
17411 config.agents.insert(
17412 "worker".into(),
17413 AliasedAgentConfig {
17414 model_provider: "openrouter.worker".into(),
17415 ..Default::default()
17416 },
17417 );
17418
17419 config.channels.webhook.insert(
17422 "primary".into(),
17423 WebhookConfig {
17424 enabled: true,
17425 port: 8080,
17426 auth_header: Some("Bearer webhook-cred".into()),
17427 secret: Some("webhook-shared-secret".into()),
17428 ..Default::default()
17429 },
17430 );
17431
17432 config.mcp.servers.push(McpServerConfig {
17436 name: "primary".into(),
17437 transport: McpTransport::Sse,
17438 url: Some("https://mcp.example.invalid/sse".into()),
17439 headers: HashMap::from([
17440 ("Authorization".to_string(), "Bearer mcp-cred".to_string()),
17441 ("X-Tenant".to_string(), "tenant-42".to_string()),
17442 ]),
17443 ..Default::default()
17444 });
17445
17446 config.save().await.unwrap();
17447
17448 let contents = tokio::fs::read_to_string(config.config_path.clone())
17449 .await
17450 .unwrap();
17451 let stored: Config = crate::migration::migrate_to_current(&contents).unwrap();
17452 let store = crate::secrets::SecretStore::new(&dir, true);
17453
17454 let root_encrypted = stored
17455 .providers
17456 .models
17457 .find("anthropic", "default")
17458 .and_then(|e| e.api_key.as_deref())
17459 .unwrap();
17460 assert!(crate::secrets::SecretStore::is_encrypted(root_encrypted));
17461 assert_eq!(store.decrypt(root_encrypted).unwrap(), "root-credential");
17462
17463 let composio_encrypted = stored.composio.api_key.as_deref().unwrap();
17464 assert!(crate::secrets::SecretStore::is_encrypted(
17465 composio_encrypted
17466 ));
17467 assert_eq!(
17468 store.decrypt(composio_encrypted).unwrap(),
17469 "composio-credential"
17470 );
17471
17472 let browser_encrypted = stored.browser.computer_use.api_key.as_deref().unwrap();
17473 assert!(crate::secrets::SecretStore::is_encrypted(browser_encrypted));
17474 assert_eq!(
17475 store.decrypt(browser_encrypted).unwrap(),
17476 "browser-credential"
17477 );
17478
17479 let web_search_encrypted = stored.web_search.brave_api_key.as_deref().unwrap();
17480 assert!(crate::secrets::SecretStore::is_encrypted(
17481 web_search_encrypted
17482 ));
17483 assert_eq!(
17484 store.decrypt(web_search_encrypted).unwrap(),
17485 "brave-credential"
17486 );
17487
17488 let tavily_encrypted = stored.web_search.tavily_api_key.as_deref().unwrap();
17489 assert!(crate::secrets::SecretStore::is_encrypted(tavily_encrypted));
17490 assert_eq!(
17491 store.decrypt(tavily_encrypted).unwrap(),
17492 "tavily-credential"
17493 );
17494
17495 let worker_provider = stored
17496 .providers
17497 .models
17498 .find("openrouter", "worker")
17499 .unwrap();
17500 let worker_encrypted = worker_provider.api_key.as_deref().unwrap();
17501 assert!(crate::secrets::SecretStore::is_encrypted(worker_encrypted));
17502 assert_eq!(store.decrypt(worker_encrypted).unwrap(), "agent-credential");
17503
17504 let storage_db_url = stored
17505 .storage
17506 .postgres
17507 .get("default")
17508 .and_then(|p| p.db_url.as_deref())
17509 .unwrap();
17510 assert!(crate::secrets::SecretStore::is_encrypted(storage_db_url));
17511 assert_eq!(
17512 store.decrypt(storage_db_url).unwrap(),
17513 "postgres://user:pw@host/db"
17514 );
17515
17516 let feishu = stored.channels.lark.get("feishu").unwrap();
17517 assert!(crate::secrets::SecretStore::is_encrypted(
17518 &feishu.app_secret
17519 ));
17520 assert_eq!(store.decrypt(&feishu.app_secret).unwrap(), "feishu-secret");
17521 assert!(
17522 feishu
17523 .encrypt_key
17524 .as_deref()
17525 .is_some_and(crate::secrets::SecretStore::is_encrypted)
17526 );
17527 assert_eq!(
17528 store
17529 .decrypt(feishu.encrypt_key.as_deref().unwrap())
17530 .unwrap(),
17531 "feishu-encrypt"
17532 );
17533 assert!(
17534 feishu
17535 .verification_token
17536 .as_deref()
17537 .is_some_and(crate::secrets::SecretStore::is_encrypted)
17538 );
17539 assert_eq!(
17540 store
17541 .decrypt(feishu.verification_token.as_deref().unwrap())
17542 .unwrap(),
17543 "feishu-verify"
17544 );
17545
17546 let webhook = stored.channels.webhook.get("primary").unwrap();
17548 let webhook_auth = webhook.auth_header.as_deref().unwrap();
17549 assert!(
17550 crate::secrets::SecretStore::is_encrypted(webhook_auth),
17551 "webhook auth_header must be encrypted on save"
17552 );
17553 assert_eq!(store.decrypt(webhook_auth).unwrap(), "Bearer webhook-cred");
17554 let webhook_secret = webhook.secret.as_deref().unwrap();
17557 assert!(crate::secrets::SecretStore::is_encrypted(webhook_secret));
17558 assert_eq!(
17559 store.decrypt(webhook_secret).unwrap(),
17560 "webhook-shared-secret"
17561 );
17562
17563 let mcp_server = stored
17566 .mcp
17567 .servers
17568 .iter()
17569 .find(|s| s.name == "primary")
17570 .expect("mcp server `primary` round-trips through save");
17571 for (key, value) in &mcp_server.headers {
17572 assert!(
17573 crate::secrets::SecretStore::is_encrypted(value),
17574 "mcp.servers.primary.headers.{key} must be encrypted on save"
17575 );
17576 }
17577 let auth = mcp_server.headers.get("Authorization").unwrap();
17578 let tenant = mcp_server.headers.get("X-Tenant").unwrap();
17579 assert_eq!(store.decrypt(auth).unwrap(), "Bearer mcp-cred");
17580 assert_eq!(store.decrypt(tenant).unwrap(), "tenant-42");
17581
17582 let _ = fs::remove_dir_all(&dir).await;
17583 }
17584
17585 #[tokio::test]
17586 async fn config_save_atomic_cleanup() {
17587 let dir =
17588 std::env::temp_dir().join(format!("zeroclaw_test_config_{}", uuid::Uuid::new_v4()));
17589 fs::create_dir_all(&dir).await.unwrap();
17590
17591 let config_path = dir.join("config.toml");
17592 let mut config = Config {
17593 data_dir: dir.join("workspace"),
17594 config_path: config_path.clone(),
17595 ..Default::default()
17596 };
17597 config.providers.models.openrouter.insert(
17598 "default".to_string(),
17599 OpenRouterModelProviderConfig {
17600 base: ModelProviderConfig {
17601 model: Some("model-a".into()),
17602 ..Default::default()
17603 },
17604 },
17605 );
17606 config.save().await.unwrap();
17607 assert!(config_path.exists());
17608
17609 config
17610 .providers
17611 .models
17612 .ensure("openrouter", "default")
17613 .unwrap()
17614 .model = Some("model-b".into());
17615 config.save().await.unwrap();
17616
17617 let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
17618 assert!(contents.contains("model-b"));
17619
17620 let mut names: Vec<String> = Vec::new();
17621 let mut read_dir = fs::read_dir(&dir).await.unwrap();
17622 while let Some(entry) = read_dir.next_entry().await.unwrap() {
17623 names.push(entry.file_name().to_string_lossy().to_string());
17624 }
17625 assert!(!names.iter().any(|name| name.contains(".tmp-")));
17626 assert!(!names.iter().any(|name| name.ends_with(".bak")));
17627
17628 let _ = fs::remove_dir_all(&dir).await;
17629 }
17630
17631 #[test]
17634 async fn telegram_config_serde() {
17635 let tc = TelegramConfig {
17636 enabled: true,
17637 bot_token: "123:XYZ".into(),
17638 stream_mode: StreamMode::Partial,
17639 draft_update_interval_ms: 500,
17640 interrupt_on_new_message: true,
17641 mention_only: false,
17642 ack_reactions: None,
17643 proxy_url: None,
17644 approval_timeout_secs: 120,
17645 excluded_tools: vec![],
17646 default_target: None,
17647 };
17648 let json = serde_json::to_string(&tc).unwrap();
17649 let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
17650 assert_eq!(parsed.bot_token, "123:XYZ");
17651 assert_eq!(parsed.stream_mode, StreamMode::Partial);
17652 assert_eq!(parsed.draft_update_interval_ms, 500);
17653 assert!(parsed.interrupt_on_new_message);
17654 }
17655
17656 #[test]
17657 async fn telegram_config_defaults_stream_off() {
17658 let json = r#"{"bot_token":"tok","allowed_users":[]}"#;
17659 let parsed: TelegramConfig = serde_json::from_str(json).unwrap();
17660 assert_eq!(parsed.stream_mode, StreamMode::Off);
17661 assert_eq!(parsed.draft_update_interval_ms, 1000);
17662 assert!(!parsed.interrupt_on_new_message);
17663 }
17664
17665 #[test]
17666 async fn discord_config_serde() {
17667 let dc = DiscordConfig {
17668 enabled: true,
17669 bot_token: "discord-token".into(),
17670 guild_ids: vec!["12345".into()],
17671 channel_ids: vec![],
17672 archive: false,
17673 listen_to_bots: false,
17674 interrupt_on_new_message: false,
17675 mention_only: false,
17676 proxy_url: None,
17677 stream_mode: StreamMode::default(),
17678 draft_update_interval_ms: 1000,
17679 multi_message_delay_ms: 800,
17680 stall_timeout_secs: 0,
17681 approval_timeout_secs: 300,
17682 excluded_tools: vec![],
17683 default_target: None,
17684 };
17685 let json = serde_json::to_string(&dc).unwrap();
17686 let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
17687 assert_eq!(parsed.bot_token, "discord-token");
17688 assert_eq!(parsed.guild_ids, vec!["12345".to_string()]);
17689 }
17690
17691 #[test]
17692 async fn discord_config_empty_guild_ids() {
17693 let dc = DiscordConfig {
17694 enabled: true,
17695 bot_token: "tok".into(),
17696 guild_ids: Vec::new(),
17697 channel_ids: vec![],
17698 archive: false,
17699 listen_to_bots: false,
17700 interrupt_on_new_message: false,
17701 mention_only: false,
17702 proxy_url: None,
17703 stream_mode: StreamMode::default(),
17704 draft_update_interval_ms: 1000,
17705 multi_message_delay_ms: 800,
17706 stall_timeout_secs: 0,
17707 approval_timeout_secs: 300,
17708 excluded_tools: vec![],
17709 default_target: None,
17710 };
17711 let json = serde_json::to_string(&dc).unwrap();
17712 let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
17713 assert!(parsed.guild_ids.is_empty());
17714 }
17715
17716 #[test]
17725 async fn imessage_v2_allowed_contacts_fold_into_peer_groups() {
17726 let raw = r#"
17730schema_version = 2
17731
17732[channels.imessage]
17733enabled = true
17734allowed_contacts = ["+1234567890", "user@icloud.com"]
17735"#;
17736 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
17737 let group = parsed
17738 .peer_groups
17739 .get("imessage_default")
17740 .expect("V2 imessage.allowed_contacts must fold into peer_groups.imessage_default");
17741 assert_eq!(group.channel, "imessage");
17742 let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
17743 assert_eq!(usernames, vec!["+1234567890", "user@icloud.com"]);
17744 }
17745
17746 #[test]
17747 async fn matrix_config_serde() {
17748 let mc = MatrixConfig {
17749 enabled: true,
17750 homeserver: "https://matrix.org".into(),
17751 access_token: Some("syt_token_abc".into()),
17752 user_id: Some("@bot:matrix.org".into()),
17753 device_id: Some("DEVICE123".into()),
17754 allowed_rooms: vec!["!room123:matrix.org".into()],
17755 interrupt_on_new_message: false,
17756 stream_mode: StreamMode::default(),
17757 draft_update_interval_ms: 1500,
17758 multi_message_delay_ms: 800,
17759 recovery_key: None,
17760 mention_only: false,
17761 password: None,
17762 approval_timeout_secs: 300,
17763 reply_in_thread: true,
17764 ack_reactions: Some(true),
17765 excluded_tools: vec![],
17766 default_target: None,
17767 };
17768 let json = serde_json::to_string(&mc).unwrap();
17769 let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
17770 assert_eq!(parsed.homeserver, "https://matrix.org");
17771 assert_eq!(parsed.access_token.as_deref(), Some("syt_token_abc"));
17772 assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org"));
17773 assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123"));
17774 assert_eq!(
17775 parsed.allowed_rooms.first().map(|s| s.as_str()),
17776 Some("!room123:matrix.org")
17777 );
17778 }
17779
17780 #[test]
17781 async fn matrix_config_toml_roundtrip() {
17782 let mc = MatrixConfig {
17783 enabled: true,
17784 homeserver: "https://synapse.local:8448".into(),
17785 access_token: Some("tok".into()),
17786 user_id: None,
17787 device_id: None,
17788 allowed_rooms: vec!["!abc:synapse.local".into()],
17789 interrupt_on_new_message: false,
17790 stream_mode: StreamMode::default(),
17791 draft_update_interval_ms: 1500,
17792 multi_message_delay_ms: 800,
17793 recovery_key: None,
17794 mention_only: false,
17795 password: None,
17796 approval_timeout_secs: 300,
17797 reply_in_thread: true,
17798 ack_reactions: Some(true),
17799 excluded_tools: vec![],
17800 default_target: None,
17801 };
17802 let toml_str = toml::to_string(&mc).unwrap();
17803 let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
17804 assert_eq!(parsed.homeserver, "https://synapse.local:8448");
17805 assert_eq!(parsed.allowed_rooms.len(), 1);
17806 }
17807
17808 #[test]
17809 async fn matrix_config_backward_compatible_without_session_hints() {
17810 let toml = r#"
17813homeserver = "https://matrix.org"
17814access_token = "tok"
17815allowed_users = ["@ops:matrix.org"]
17816allowed_rooms = ["!ops:matrix.org"]
17817"#;
17818
17819 let parsed: MatrixConfig = toml::from_str(toml).unwrap();
17820 assert_eq!(parsed.homeserver, "https://matrix.org");
17821 assert!(parsed.user_id.is_none());
17822 assert!(parsed.device_id.is_none());
17823 assert_eq!(parsed.allowed_rooms, vec!["!ops:matrix.org"]);
17824 }
17825
17826 #[test]
17827 async fn matrix_config_reply_in_thread_defaults_to_true() {
17828 let toml = r#"
17829homeserver = "https://matrix.org"
17830access_token = "tok"
17831allowed_users = ["@u:matrix.org"]
17832"#;
17833 let parsed: MatrixConfig = toml::from_str(toml).unwrap();
17834 assert!(parsed.reply_in_thread);
17835 }
17836
17837 #[test]
17838 async fn signal_config_serde() {
17839 let sc = SignalConfig {
17840 enabled: true,
17841 http_url: "http://127.0.0.1:8686".into(),
17842 account: "+1234567890".into(),
17843 group_ids: vec!["group123".into()],
17844 dm_only: false,
17845 ignore_attachments: true,
17846 ignore_stories: false,
17847 proxy_url: None,
17848 approval_timeout_secs: 300,
17849 excluded_tools: vec![],
17850 default_target: None,
17851 };
17852 let json = serde_json::to_string(&sc).unwrap();
17853 let parsed: SignalConfig = serde_json::from_str(&json).unwrap();
17854 assert_eq!(parsed.http_url, "http://127.0.0.1:8686");
17855 assert_eq!(parsed.account, "+1234567890");
17856 assert_eq!(parsed.group_ids, vec!["group123".to_string()]);
17857 assert!(!parsed.dm_only);
17858 assert!(parsed.ignore_attachments);
17859 assert!(!parsed.ignore_stories);
17860 }
17861
17862 #[test]
17863 async fn signal_config_toml_roundtrip() {
17864 let sc = SignalConfig {
17865 enabled: true,
17866 http_url: "http://localhost:8080".into(),
17867 account: "+9876543210".into(),
17868 group_ids: Vec::new(),
17869 dm_only: true,
17870 ignore_attachments: false,
17871 ignore_stories: true,
17872 proxy_url: None,
17873 approval_timeout_secs: 300,
17874 excluded_tools: vec![],
17875 default_target: None,
17876 };
17877 let toml_str = toml::to_string(&sc).unwrap();
17878 let parsed: SignalConfig = toml::from_str(&toml_str).unwrap();
17879 assert_eq!(parsed.http_url, "http://localhost:8080");
17880 assert_eq!(parsed.account, "+9876543210");
17881 assert!(parsed.group_ids.is_empty());
17882 assert!(parsed.dm_only);
17883 assert!(parsed.ignore_stories);
17884 }
17885
17886 #[test]
17887 async fn signal_config_defaults() {
17888 let json = r#"{"http_url":"http://127.0.0.1:8686","account":"+1234567890"}"#;
17889 let parsed: SignalConfig = serde_json::from_str(json).unwrap();
17890 assert!(parsed.group_ids.is_empty());
17891 assert!(!parsed.dm_only);
17892 assert!(!parsed.ignore_attachments);
17893 assert!(!parsed.ignore_stories);
17894 }
17895
17896 #[test]
17897 async fn channels_with_imessage_and_matrix() {
17898 let c = ChannelsConfig {
17899 cli: true,
17900 telegram: HashMap::new(),
17901 discord: HashMap::new(),
17902 slack: HashMap::new(),
17903 mattermost: HashMap::new(),
17904 webhook: HashMap::new(),
17905 imessage: HashMap::from([(
17906 "default".to_string(),
17907 IMessageConfig {
17908 enabled: true,
17909 excluded_tools: vec![],
17910 },
17911 )]),
17912 matrix: HashMap::from([(
17913 "default".to_string(),
17914 MatrixConfig {
17915 enabled: true,
17916 homeserver: "https://m.org".into(),
17917 access_token: Some("tok".into()),
17918 user_id: None,
17919 device_id: None,
17920 allowed_rooms: vec!["!r:m".into()],
17921 interrupt_on_new_message: false,
17922 stream_mode: StreamMode::default(),
17923 draft_update_interval_ms: 1500,
17924 multi_message_delay_ms: 800,
17925 recovery_key: None,
17926 mention_only: false,
17927 password: None,
17928 approval_timeout_secs: 300,
17929 reply_in_thread: true,
17930 ack_reactions: Some(true),
17931 excluded_tools: vec![],
17932 default_target: None,
17933 },
17934 )]),
17935 signal: HashMap::new(),
17936 whatsapp: HashMap::new(),
17937 linq: HashMap::new(),
17938 wati: HashMap::new(),
17939 nextcloud_talk: HashMap::new(),
17940 email: HashMap::new(),
17941 gmail_push: HashMap::new(),
17942 irc: HashMap::new(),
17943 lark: HashMap::new(),
17944 line: HashMap::new(),
17945 dingtalk: HashMap::new(),
17946 wecom: HashMap::new(),
17947 wecom_ws: HashMap::new(),
17948 wechat: HashMap::new(),
17949 qq: HashMap::new(),
17950 twitter: HashMap::new(),
17951 mochat: HashMap::new(),
17952 nostr: HashMap::new(),
17953 clawdtalk: HashMap::new(),
17954 reddit: HashMap::new(),
17955 bluesky: HashMap::new(),
17956 voice_call: HashMap::new(),
17957 voice_duplex: HashMap::new(),
17958 voice_wake: HashMap::new(),
17959 mqtt: HashMap::new(),
17960 message_timeout_secs: 300,
17961 ack_reactions: true,
17962 show_tool_calls: true,
17963 session_persistence: true,
17964 session_backend: default_session_backend(),
17965 session_ttl_hours: 0,
17966 debounce_ms: 0,
17967 };
17968 let toml_str = toml::to_string_pretty(&c).unwrap();
17969 let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
17970 assert!(!parsed.imessage.is_empty());
17971 assert!(!parsed.matrix.is_empty());
17972 assert_eq!(
17973 parsed.matrix.get("default").unwrap().homeserver,
17974 "https://m.org"
17975 );
17976 }
17977
17978 #[test]
17979 async fn channels_default_has_no_imessage_matrix() {
17980 let c = ChannelsConfig::default();
17981 assert!(c.imessage.is_empty());
17982 assert!(c.matrix.is_empty());
17983 }
17984
17985 #[test]
17993 async fn discord_v2_allowed_users_fold_into_peer_groups() {
17994 let raw = r#"
17995schema_version = 2
17996
17997[channels.discord]
17998enabled = true
17999bot_token = "tok"
18000guild_id = "123"
18001allowed_users = ["111", "222"]
18002"#;
18003 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
18004 let group = parsed
18005 .peer_groups
18006 .get("discord_default")
18007 .expect("V2 discord.allowed_users must fold into peer_groups.discord_default");
18008 assert_eq!(group.channel, "discord");
18009 let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
18010 assert_eq!(usernames, vec!["111", "222"]);
18011 }
18012
18013 #[test]
18014 async fn slack_v2_allowed_users_fold_into_peer_groups() {
18015 let raw = r#"
18016schema_version = 2
18017
18018[channels.slack]
18019enabled = true
18020bot_token = "xoxb-tok"
18021allowed_users = ["U111"]
18022"#;
18023 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
18024 let group = parsed
18025 .peer_groups
18026 .get("slack_default")
18027 .expect("V2 slack.allowed_users must fold into peer_groups.slack_default");
18028 assert_eq!(group.channel, "slack");
18029 let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
18030 assert_eq!(usernames, vec!["U111"]);
18031 }
18032
18033 #[test]
18034 async fn slack_config_deserializes_with_channel_ids() {
18035 let json = r#"{"bot_token":"xoxb-tok","channel_ids":["C111","D222"]}"#;
18036 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
18037 assert_eq!(parsed.channel_ids, vec!["C111", "D222"]);
18038 assert!(!parsed.interrupt_on_new_message);
18039 assert_eq!(parsed.thread_replies, None);
18040 assert!(!parsed.mention_only);
18041 }
18042
18043 #[test]
18044 async fn slack_config_deserializes_with_mention_only() {
18045 let json = r#"{"bot_token":"xoxb-tok","mention_only":true}"#;
18046 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
18047 assert!(parsed.mention_only);
18048 assert!(!parsed.interrupt_on_new_message);
18049 assert_eq!(parsed.thread_replies, None);
18050 }
18051
18052 #[test]
18053 async fn slack_config_deserializes_interrupt_on_new_message() {
18054 let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
18055 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
18056 assert!(parsed.interrupt_on_new_message);
18057 assert_eq!(parsed.thread_replies, None);
18058 assert!(!parsed.mention_only);
18059 }
18060
18061 #[test]
18062 async fn slack_config_deserializes_thread_replies() {
18063 let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#;
18064 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
18065 assert_eq!(parsed.thread_replies, Some(false));
18066 assert!(!parsed.interrupt_on_new_message);
18067 assert!(!parsed.mention_only);
18068 }
18069
18070 #[test]
18071 async fn discord_config_default_interrupt_on_new_message_is_false() {
18072 let json = r#"{"bot_token":"tok"}"#;
18073 let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
18074 assert!(!parsed.interrupt_on_new_message);
18075 }
18076
18077 #[test]
18078 async fn discord_config_deserializes_interrupt_on_new_message_true() {
18079 let json = r#"{"bot_token":"tok","interrupt_on_new_message":true}"#;
18080 let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
18081 assert!(parsed.interrupt_on_new_message);
18082 }
18083
18084 #[test]
18085 async fn discord_config_toml_backward_compat() {
18086 let toml_str = r#"
18087bot_token = "tok"
18088guild_id = "123"
18089"#;
18090 let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
18091 assert_eq!(parsed.bot_token, "tok");
18092 }
18093
18094 #[test]
18095 async fn slack_config_toml_with_channel_ids() {
18096 let toml_str = r#"
18097bot_token = "xoxb-tok"
18098channel_ids = ["C123", "D456"]
18099"#;
18100 let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
18101 assert_eq!(parsed.channel_ids, vec!["C123", "D456"]);
18102 assert!(!parsed.interrupt_on_new_message);
18103 assert_eq!(parsed.thread_replies, None);
18104 assert!(!parsed.mention_only);
18105 }
18106
18107 #[test]
18108 async fn slack_config_toml_without_channel_ids_defaults_empty() {
18109 let toml_str = r#"
18110bot_token = "xoxb-tok"
18111"#;
18112 let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
18113 assert!(parsed.channel_ids.is_empty());
18114 }
18115
18116 #[test]
18117 async fn mattermost_config_default_interrupt_on_new_message_is_false() {
18118 let json = r#"{"url":"https://mm.example.com","bot_token":"tok"}"#;
18119 let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
18120 assert!(!parsed.interrupt_on_new_message);
18121 }
18122
18123 #[test]
18124 async fn mattermost_config_deserializes_interrupt_on_new_message_true() {
18125 let json =
18126 r#"{"url":"https://mm.example.com","bot_token":"tok","interrupt_on_new_message":true}"#;
18127 let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
18128 assert!(parsed.interrupt_on_new_message);
18129 }
18130
18131 #[test]
18132 async fn webhook_config_with_secret() {
18133 let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
18134 let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
18135 assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
18136 }
18137
18138 #[test]
18139 async fn webhook_config_without_secret() {
18140 let json = r#"{"port":8080}"#;
18141 let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
18142 assert!(parsed.secret.is_none());
18143 assert_eq!(parsed.port, 8080);
18144 }
18145
18146 #[test]
18147 async fn webhook_config_retry_fields_default_to_none() {
18148 let json = r#"{"port":8080}"#;
18149 let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
18150 assert!(parsed.max_retries.is_none());
18151 assert!(parsed.retry_base_delay_ms.is_none());
18152 assert!(parsed.retry_max_delay_ms.is_none());
18153 }
18154
18155 #[test]
18156 async fn webhook_config_retry_fields_roundtrip() {
18157 let wc = WebhookConfig {
18158 enabled: true,
18159 port: 8080,
18160 listen_path: None,
18161 send_url: Some("https://example.com/cb".into()),
18162 send_method: None,
18163 auth_header: None,
18164 secret: None,
18165 excluded_tools: vec![],
18166 max_retries: Some(5),
18167 retry_base_delay_ms: Some(250),
18168 retry_max_delay_ms: Some(10_000),
18169 };
18170
18171 let json = serde_json::to_string(&wc).unwrap();
18172 let parsed: WebhookConfig = serde_json::from_str(&json).unwrap();
18173 assert_eq!(parsed.max_retries, Some(5));
18174 assert_eq!(parsed.retry_base_delay_ms, Some(250));
18175 assert_eq!(parsed.retry_max_delay_ms, Some(10_000));
18176
18177 let toml_str = toml::to_string(&wc).unwrap();
18178 let parsed: WebhookConfig = toml::from_str(&toml_str).unwrap();
18179 assert_eq!(parsed.max_retries, Some(5));
18180 assert_eq!(parsed.retry_base_delay_ms, Some(250));
18181 assert_eq!(parsed.retry_max_delay_ms, Some(10_000));
18182 }
18183
18184 #[test]
18187 async fn whatsapp_config_serde() {
18188 let wc = WhatsAppConfig {
18189 enabled: true,
18190 access_token: Some("EAABx...".into()),
18191 phone_number_id: Some("123456789".into()),
18192 verify_token: Some("my-verify-token".into()),
18193 app_secret: None,
18194 session_path: None,
18195 pair_phone: None,
18196 pair_code: None,
18197 ws_url: None,
18198 mention_only: false,
18199 mode: WhatsAppWebMode::default(),
18200 dm_policy: WhatsAppChatPolicy::default(),
18201 group_policy: WhatsAppChatPolicy::default(),
18202 self_chat_mode: false,
18203 dm_mention_patterns: vec![],
18204 group_mention_patterns: vec![],
18205 proxy_url: None,
18206 approval_timeout_secs: 300,
18207 excluded_tools: vec![],
18208 default_target: None,
18209 };
18210 let json = serde_json::to_string(&wc).unwrap();
18211 let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
18212 assert_eq!(parsed.access_token, Some("EAABx...".into()));
18213 assert_eq!(parsed.phone_number_id, Some("123456789".into()));
18214 assert_eq!(parsed.verify_token, Some("my-verify-token".into()));
18215 }
18216
18217 #[test]
18218 async fn whatsapp_config_toml_roundtrip() {
18219 let wc = WhatsAppConfig {
18220 enabled: true,
18221 access_token: Some("tok".into()),
18222 phone_number_id: Some("12345".into()),
18223 verify_token: Some("verify".into()),
18224 app_secret: Some("secret123".into()),
18225 session_path: None,
18226 pair_phone: None,
18227 pair_code: None,
18228 ws_url: None,
18229 mention_only: false,
18230 mode: WhatsAppWebMode::default(),
18231 dm_policy: WhatsAppChatPolicy::default(),
18232 group_policy: WhatsAppChatPolicy::default(),
18233 self_chat_mode: false,
18234 dm_mention_patterns: vec![],
18235 group_mention_patterns: vec![],
18236 proxy_url: None,
18237 approval_timeout_secs: 300,
18238 excluded_tools: vec![],
18239 default_target: None,
18240 };
18241 let toml_str = toml::to_string(&wc).unwrap();
18242 let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
18243 assert_eq!(parsed.phone_number_id, Some("12345".into()));
18244 }
18245
18246 #[test]
18247 async fn whatsapp_v2_allowed_numbers_fold_into_peer_groups() {
18248 let raw = r#"
18252schema_version = 2
18253
18254[channels.whatsapp]
18255enabled = true
18256access_token = "tok"
18257phone_number_id = "123"
18258verify_token = "ver"
18259allowed_numbers = ["+1", "+2"]
18260"#;
18261 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
18262 let group = parsed
18263 .peer_groups
18264 .get("whatsapp_default")
18265 .expect("V2 whatsapp.allowed_numbers must fold into peer_groups.whatsapp_default");
18266 assert_eq!(group.channel, "whatsapp");
18267 let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
18268 assert_eq!(usernames, vec!["+1", "+2"]);
18269 }
18270
18271 #[test]
18272 async fn whatsapp_config_backend_type_cloud_precedence_when_ambiguous() {
18273 let wc = WhatsAppConfig {
18274 enabled: true,
18275 access_token: Some("tok".into()),
18276 phone_number_id: Some("123".into()),
18277 verify_token: Some("ver".into()),
18278 app_secret: None,
18279 session_path: Some("~/.zeroclaw/state/whatsapp-web/session.db".into()),
18280 pair_phone: None,
18281 pair_code: None,
18282 ws_url: None,
18283 mention_only: false,
18284 mode: WhatsAppWebMode::default(),
18285 dm_policy: WhatsAppChatPolicy::default(),
18286 group_policy: WhatsAppChatPolicy::default(),
18287 self_chat_mode: false,
18288 dm_mention_patterns: vec![],
18289 group_mention_patterns: vec![],
18290 proxy_url: None,
18291 approval_timeout_secs: 300,
18292 excluded_tools: vec![],
18293 default_target: None,
18294 };
18295 assert!(wc.is_ambiguous_config());
18296 assert_eq!(wc.backend_type(), "cloud");
18297 }
18298
18299 #[test]
18300 async fn whatsapp_config_backend_type_web() {
18301 let wc = WhatsAppConfig {
18302 enabled: true,
18303 access_token: None,
18304 phone_number_id: None,
18305 verify_token: None,
18306 app_secret: None,
18307 session_path: Some("~/.zeroclaw/state/whatsapp-web/session.db".into()),
18308 pair_phone: None,
18309 pair_code: None,
18310 ws_url: None,
18311 mention_only: false,
18312 mode: WhatsAppWebMode::default(),
18313 dm_policy: WhatsAppChatPolicy::default(),
18314 group_policy: WhatsAppChatPolicy::default(),
18315 self_chat_mode: false,
18316 dm_mention_patterns: vec![],
18317 group_mention_patterns: vec![],
18318 proxy_url: None,
18319 approval_timeout_secs: 300,
18320 excluded_tools: vec![],
18321 default_target: None,
18322 };
18323 assert!(!wc.is_ambiguous_config());
18324 assert_eq!(wc.backend_type(), "web");
18325 }
18326
18327 #[test]
18328 async fn channels_with_whatsapp() {
18329 let c = ChannelsConfig {
18330 cli: true,
18331 telegram: HashMap::new(),
18332 discord: HashMap::new(),
18333 slack: HashMap::new(),
18334 mattermost: HashMap::new(),
18335 webhook: HashMap::new(),
18336 imessage: HashMap::new(),
18337 matrix: HashMap::new(),
18338 signal: HashMap::new(),
18339 whatsapp: HashMap::from([(
18340 "default".to_string(),
18341 WhatsAppConfig {
18342 enabled: true,
18343 access_token: Some("tok".into()),
18344 phone_number_id: Some("123".into()),
18345 verify_token: Some("ver".into()),
18346 app_secret: None,
18347 session_path: None,
18348 pair_phone: None,
18349 pair_code: None,
18350 ws_url: None,
18351 mention_only: false,
18352 mode: WhatsAppWebMode::default(),
18353 dm_policy: WhatsAppChatPolicy::default(),
18354 group_policy: WhatsAppChatPolicy::default(),
18355 self_chat_mode: false,
18356 dm_mention_patterns: vec![],
18357 group_mention_patterns: vec![],
18358 proxy_url: None,
18359 approval_timeout_secs: 300,
18360 excluded_tools: vec![],
18361 default_target: None,
18362 },
18363 )]),
18364 linq: HashMap::new(),
18365 wati: HashMap::new(),
18366 nextcloud_talk: HashMap::new(),
18367 email: HashMap::new(),
18368 gmail_push: HashMap::new(),
18369 irc: HashMap::new(),
18370 lark: HashMap::new(),
18371 line: HashMap::new(),
18372 dingtalk: HashMap::new(),
18373 wecom: HashMap::new(),
18374 wecom_ws: HashMap::new(),
18375 wechat: HashMap::new(),
18376 qq: HashMap::new(),
18377 twitter: HashMap::new(),
18378 mochat: HashMap::new(),
18379 nostr: HashMap::new(),
18380 clawdtalk: HashMap::new(),
18381 reddit: HashMap::new(),
18382 bluesky: HashMap::new(),
18383 voice_call: HashMap::new(),
18384 voice_duplex: HashMap::new(),
18385 voice_wake: HashMap::new(),
18386 mqtt: HashMap::new(),
18387 message_timeout_secs: 300,
18388 ack_reactions: true,
18389 show_tool_calls: true,
18390 session_persistence: true,
18391 session_backend: default_session_backend(),
18392 session_ttl_hours: 0,
18393 debounce_ms: 0,
18394 };
18395 let toml_str = toml::to_string_pretty(&c).unwrap();
18396 let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
18397 assert!(!parsed.whatsapp.is_empty());
18398 let wa = parsed.whatsapp.get("default").unwrap();
18399 assert_eq!(wa.phone_number_id, Some("123".into()));
18400 }
18401
18402 #[test]
18403 async fn channels_default_has_no_whatsapp() {
18404 let c = ChannelsConfig::default();
18405 assert!(c.whatsapp.is_empty());
18406 }
18407
18408 #[test]
18409 async fn channels_default_has_no_nextcloud_talk() {
18410 let c = ChannelsConfig::default();
18411 assert!(c.nextcloud_talk.is_empty());
18412 }
18413
18414 #[test]
18419 async fn checklist_gateway_default_requires_pairing() {
18420 let g = GatewayConfig::default();
18421 assert!(g.require_pairing, "Pairing must be required by default");
18422 }
18423
18424 #[test]
18425 async fn checklist_gateway_default_blocks_public_bind() {
18426 let g = GatewayConfig::default();
18427 assert!(
18428 !g.allow_public_bind,
18429 "Public bind must be blocked by default"
18430 );
18431 }
18432
18433 #[test]
18434 async fn checklist_gateway_default_no_tokens() {
18435 let g = GatewayConfig::default();
18436 assert!(
18437 g.paired_tokens.is_empty(),
18438 "No pre-paired tokens by default"
18439 );
18440 assert_eq!(g.pair_rate_limit_per_minute, 10);
18441 assert_eq!(g.webhook_rate_limit_per_minute, 60);
18442 assert!(!g.trust_forwarded_headers);
18443 assert_eq!(g.rate_limit_max_keys, 10_000);
18444 assert_eq!(g.idempotency_ttl_secs, 300);
18445 assert_eq!(g.idempotency_max_keys, 10_000);
18446 }
18447
18448 #[test]
18449 async fn checklist_gateway_cli_default_host_is_localhost() {
18450 let c = Config::default();
18453 assert!(
18454 c.gateway.require_pairing,
18455 "Config default must require pairing"
18456 );
18457 assert!(
18458 !c.gateway.allow_public_bind,
18459 "Config default must block public bind"
18460 );
18461 }
18462
18463 #[test]
18464 async fn checklist_gateway_serde_roundtrip() {
18465 let g = GatewayConfig {
18466 port: 42617,
18467 host: "127.0.0.1".into(),
18468 require_pairing: true,
18469 allow_public_bind: false,
18470 paired_tokens: vec!["zc_test_token".into()],
18471 pair_rate_limit_per_minute: 12,
18472 webhook_rate_limit_per_minute: 80,
18473 trust_forwarded_headers: true,
18474 path_prefix: Some("/zeroclaw".into()),
18475 rate_limit_max_keys: 2048,
18476 idempotency_ttl_secs: 600,
18477 idempotency_max_keys: 4096,
18478 session_persistence: true,
18479 session_ttl_hours: 0,
18480 pairing_dashboard: PairingDashboardConfig::default(),
18481 web_dist_dir: None,
18482 tls: None,
18483 request_timeout_secs: 30,
18484 long_running_request_timeout_secs: 600,
18485 };
18486 let toml_str = toml::to_string(&g).unwrap();
18487 let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
18488 assert!(parsed.require_pairing);
18489 assert!(parsed.session_persistence);
18490 assert_eq!(parsed.session_ttl_hours, 0);
18491 assert!(!parsed.allow_public_bind);
18492 assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
18493 assert_eq!(parsed.pair_rate_limit_per_minute, 12);
18494 assert_eq!(parsed.webhook_rate_limit_per_minute, 80);
18495 assert!(parsed.trust_forwarded_headers);
18496 assert_eq!(parsed.path_prefix.as_deref(), Some("/zeroclaw"));
18497 assert_eq!(parsed.rate_limit_max_keys, 2048);
18498 assert_eq!(parsed.idempotency_ttl_secs, 600);
18499 assert_eq!(parsed.idempotency_max_keys, 4096);
18500 }
18501
18502 #[test]
18503 async fn checklist_gateway_backward_compat_no_gateway_section() {
18504 let minimal = r#"
18506workspace_dir = "/tmp/ws"
18507config_path = "/tmp/config.toml"
18508default_temperature = 0.7
18509"#;
18510 let parsed = parse_test_config(minimal);
18511 assert!(
18512 parsed.gateway.require_pairing,
18513 "Missing [gateway] must default to require_pairing=true"
18514 );
18515 assert!(
18516 !parsed.gateway.allow_public_bind,
18517 "Missing [gateway] must default to allow_public_bind=false"
18518 );
18519 }
18520
18521 #[test]
18522 async fn checklist_risk_profile_default_is_workspace_scoped() {
18523 let a = RiskProfileConfig::default();
18524 assert!(a.workspace_only, "Default profile must be workspace_only");
18525 assert!(
18526 !a.forbidden_paths.is_empty(),
18527 "Default forbidden_paths must not be empty"
18528 );
18529 #[cfg(not(target_os = "windows"))]
18530 {
18531 assert!(
18532 a.forbidden_paths.iter().any(|p| p == "/etc"),
18533 "Must block /etc on Unix"
18534 );
18535 assert!(
18536 a.forbidden_paths.iter().any(|p| p == "/proc"),
18537 "Must block /proc on Unix"
18538 );
18539 }
18540 #[cfg(target_os = "windows")]
18541 {
18542 assert!(
18543 a.forbidden_paths.iter().any(|p| p == "C:\\Windows"),
18544 "Must block C:\\Windows on Windows"
18545 );
18546 assert!(
18547 a.forbidden_paths.iter().any(|p| p == "C:\\Program Files"),
18548 "Must block C:\\Program Files on Windows"
18549 );
18550 }
18551 assert!(
18552 a.forbidden_paths.contains(&"~/.ssh".to_string()),
18553 "Must block ~/.ssh"
18554 );
18555 }
18556
18557 #[test]
18562 async fn composio_config_default_disabled() {
18563 let c = ComposioConfig::default();
18564 assert!(!c.enabled, "Composio must be disabled by default");
18565 assert!(c.api_key.is_none(), "No API key by default");
18566 assert_eq!(c.entity_id, "default");
18567 }
18568
18569 #[test]
18570 async fn composio_config_serde_roundtrip() {
18571 let c = ComposioConfig {
18572 enabled: true,
18573 api_key: Some("comp-key-123".into()),
18574 entity_id: "user42".into(),
18575 };
18576 let toml_str = toml::to_string(&c).unwrap();
18577 let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
18578 assert!(parsed.enabled);
18579 assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
18580 assert_eq!(parsed.entity_id, "user42");
18581 }
18582
18583 #[test]
18584 async fn composio_config_backward_compat_missing_section() {
18585 let minimal = r#"
18586workspace_dir = "/tmp/ws"
18587config_path = "/tmp/config.toml"
18588default_temperature = 0.7
18589"#;
18590 let parsed = parse_test_config(minimal);
18591 assert!(
18592 !parsed.composio.enabled,
18593 "Missing [composio] must default to disabled"
18594 );
18595 assert!(parsed.composio.api_key.is_none());
18596 }
18597
18598 #[test]
18599 async fn composio_config_partial_toml() {
18600 let toml_str = r"
18601enabled = true
18602";
18603 let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
18604 assert!(parsed.enabled);
18605 assert!(parsed.api_key.is_none());
18606 assert_eq!(parsed.entity_id, "default");
18607 }
18608
18609 #[test]
18610 async fn composio_config_enable_alias_supported() {
18611 let toml_str = r"
18612enable = true
18613";
18614 let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
18615 assert!(parsed.enabled);
18616 assert!(parsed.api_key.is_none());
18617 assert_eq!(parsed.entity_id, "default");
18618 }
18619
18620 #[test]
18625 async fn secrets_config_default_encrypts() {
18626 let s = SecretsConfig::default();
18627 assert!(s.encrypt, "Encryption must be enabled by default");
18628 }
18629
18630 #[test]
18631 async fn secrets_config_serde_roundtrip() {
18632 let s = SecretsConfig { encrypt: false };
18633 let toml_str = toml::to_string(&s).unwrap();
18634 let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
18635 assert!(!parsed.encrypt);
18636 }
18637
18638 #[test]
18639 async fn secrets_config_backward_compat_missing_section() {
18640 let minimal = r#"
18641workspace_dir = "/tmp/ws"
18642config_path = "/tmp/config.toml"
18643default_temperature = 0.7
18644"#;
18645 let parsed = parse_test_config(minimal);
18646 assert!(
18647 parsed.secrets.encrypt,
18648 "Missing [secrets] must default to encrypt=true"
18649 );
18650 }
18651
18652 #[test]
18653 async fn config_default_has_composio_and_secrets() {
18654 let c = Config::default();
18655 assert!(!c.composio.enabled);
18656 assert!(c.composio.api_key.is_none());
18657 assert!(c.secrets.encrypt);
18658 assert!(c.browser.enabled);
18659 assert_eq!(c.browser.allowed_domains, vec!["*".to_string()]);
18660 }
18661
18662 #[test]
18663 async fn browser_config_default_enabled() {
18664 let b = BrowserConfig::default();
18665 assert!(b.enabled);
18666 assert_eq!(b.allowed_domains, vec!["*".to_string()]);
18667 assert_eq!(b.backend, "agent_browser");
18668 assert_eq!(b.headed, None);
18669 assert!(b.native_headless);
18670 assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
18671 assert!(b.native_chrome_path.is_none());
18672 assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions");
18673 assert_eq!(b.computer_use.timeout_ms, 15_000);
18674 assert!(!b.computer_use.allow_remote_endpoint);
18675 assert!(b.computer_use.window_allowlist.is_empty());
18676 assert!(b.computer_use.max_coordinate_x.is_none());
18677 assert!(b.computer_use.max_coordinate_y.is_none());
18678 }
18679
18680 #[test]
18681 async fn browser_config_serde_roundtrip() {
18682 let b = BrowserConfig {
18683 enabled: true,
18684 allowed_domains: vec!["example.com".into(), "docs.example.com".into()],
18685 session_name: None,
18686 backend: "auto".into(),
18687 headed: Some(true),
18688 native_headless: false,
18689 native_webdriver_url: "http://localhost:4444".into(),
18690 native_chrome_path: Some("/usr/bin/chromium".into()),
18691 computer_use: BrowserComputerUseConfig {
18692 endpoint: "https://computer-use.example.com/v1/actions".into(),
18693 api_key: Some("test-token".into()),
18694 timeout_ms: 8_000,
18695 allow_remote_endpoint: true,
18696 window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()],
18697 max_coordinate_x: Some(3840),
18698 max_coordinate_y: Some(2160),
18699 },
18700 };
18701 let toml_str = toml::to_string(&b).unwrap();
18702 let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap();
18703 assert!(parsed.enabled);
18704 assert_eq!(parsed.allowed_domains.len(), 2);
18705 assert_eq!(parsed.allowed_domains[0], "example.com");
18706 assert_eq!(parsed.backend, "auto");
18707 assert_eq!(parsed.headed, Some(true));
18708 assert!(!parsed.native_headless);
18709 assert_eq!(parsed.native_webdriver_url, "http://localhost:4444");
18710 assert_eq!(
18711 parsed.native_chrome_path.as_deref(),
18712 Some("/usr/bin/chromium")
18713 );
18714 assert_eq!(
18715 parsed.computer_use.endpoint,
18716 "https://computer-use.example.com/v1/actions"
18717 );
18718 assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token"));
18719 assert_eq!(parsed.computer_use.timeout_ms, 8_000);
18720 assert!(parsed.computer_use.allow_remote_endpoint);
18721 assert_eq!(parsed.computer_use.window_allowlist.len(), 2);
18722 assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840));
18723 assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160));
18724 }
18725
18726 #[test]
18727 async fn browser_config_parses_headed_true() {
18728 let parsed: BrowserConfig = toml::from_str(
18729 r#"
18730backend = "agent_browser"
18731headed = true
18732"#,
18733 )
18734 .unwrap();
18735
18736 assert_eq!(parsed.backend, "agent_browser");
18737 assert_eq!(parsed.headed, Some(true));
18738 assert!(parsed.native_headless);
18739 }
18740
18741 #[test]
18742 async fn browser_config_backward_compat_missing_section() {
18743 let minimal = r#"
18744workspace_dir = "/tmp/ws"
18745config_path = "/tmp/config.toml"
18746default_temperature = 0.7
18747"#;
18748 let parsed = parse_test_config(minimal);
18749 assert!(parsed.browser.enabled);
18750 assert_eq!(parsed.browser.allowed_domains, vec!["*".to_string()]);
18751 }
18752
18753 async fn env_override_lock() -> MutexGuard<'static, ()> {
18754 crate::env_overrides::env_test_lock().await
18758 }
18759
18760 #[test]
18761 async fn v1_known_provider_migrates_with_globals_folded_onto_typed_slot() {
18762 let raw = r#"
18771default_temperature = 0.7
18772model_provider = "openai"
18773model = "gpt-5.3-codex"
18774
18775[model_providers.openai]
18776api_key = "sk-test"
18777uri = "https://api.openai.com/v1"
18778wire_api = "responses"
18779requires_openai_auth = true
18780"#;
18781
18782 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
18783 assert!(
18784 parsed
18785 .providers
18786 .models
18787 .contains_model_provider_type("openai"),
18788 "vendor-canonical V1 provider should land in its typed slot",
18789 );
18790 let profile = parsed
18791 .providers
18792 .models
18793 .find("openai", "default")
18794 .expect("openai.default entry");
18795 assert_eq!(profile.api_key.as_deref(), Some("sk-test"));
18796 assert_eq!(profile.uri.as_deref(), Some("https://api.openai.com/v1"));
18797 assert_eq!(profile.model.as_deref(), Some("gpt-5.3-codex"));
18798 assert_eq!(profile.wire_api, Some(WireApi::Responses));
18799 assert!(profile.requires_openai_auth);
18800 }
18801
18802 #[test]
18803 async fn typed_custom_slot_routes_uri_through_find() {
18804 let _env_guard = env_override_lock().await;
18805 let mut config = Config::default();
18806 config.providers.models.custom.insert(
18807 "default".to_string(),
18808 CustomModelProviderConfig {
18809 base: ModelProviderConfig {
18810 uri: Some("https://api.tonsof.blue/v1".to_string()),
18811 ..Default::default()
18812 },
18813 },
18814 );
18815
18816 assert_eq!(
18817 config
18818 .providers
18819 .models
18820 .find("custom", "default")
18821 .and_then(|e| e.uri.as_deref()),
18822 Some("https://api.tonsof.blue/v1")
18823 );
18824 assert!(config.first_model_provider().is_some());
18825 }
18826
18827 #[test]
18828 async fn openai_codex_alias_carries_responses_wire_api_and_requires_openai_auth() {
18829 let _env_guard = env_override_lock().await;
18830 let mut config = Config::default();
18831 config.providers.models.openai.insert(
18832 "codex".to_string(),
18833 OpenAIModelProviderConfig {
18834 base: ModelProviderConfig {
18835 uri: Some("https://api.tonsof.blue".to_string()),
18836 wire_api: Some(WireApi::Responses),
18837 requires_openai_auth: true,
18838 ..Default::default()
18839 },
18840 },
18841 );
18842
18843 let entry = config
18844 .providers
18845 .models
18846 .find("openai", "codex")
18847 .expect("openai.codex entry");
18848 assert_eq!(entry.uri.as_deref(), Some("https://api.tonsof.blue"));
18849 assert_eq!(entry.wire_api, Some(WireApi::Responses));
18850 assert!(entry.requires_openai_auth);
18851 }
18852
18853 #[test]
18857 async fn provider_models_round_trips_through_load_apply_serialize() {
18858 let _env_guard = env_override_lock().await;
18859 let toml_in = r#"
18860schema_version = 3
18861
18862[providers.models.openrouter.default]
18863uri = "https://example.invalid/v1"
18864model = "primary-model"
18865"#;
18866
18867 let config: Config = toml::from_str(toml_in).expect("parse toml");
18868
18869 assert_eq!(
18870 config
18871 .providers
18872 .models
18873 .find("openrouter", "default")
18874 .and_then(|e| e.model.as_deref()),
18875 Some("primary-model"),
18876 );
18877
18878 let toml_out = toml::to_string(&config).expect("serialize toml");
18880 assert!(
18881 toml_out.contains("primary-model"),
18882 "serialized config must keep model value; got:\n{toml_out}",
18883 );
18884 }
18885
18886 #[test]
18891 async fn resolve_default_model_picks_first_available() {
18892 let _env_guard = env_override_lock().await;
18893 let mut config = Config::default();
18894 assert_eq!(config.resolve_default_model(), None);
18896
18897 config
18899 .providers
18900 .models
18901 .anthropic
18902 .insert("default".into(), AnthropicModelProviderConfig::default());
18903 assert_eq!(config.resolve_default_model(), None);
18904
18905 config.providers.models.together.insert(
18907 "default".to_string(),
18908 TogetherModelProviderConfig {
18909 base: ModelProviderConfig {
18910 model: Some("tertiary-model".to_string()),
18911 ..Default::default()
18912 },
18913 },
18914 );
18915 assert_eq!(
18916 config.resolve_default_model().as_deref(),
18917 Some("tertiary-model"),
18918 );
18919
18920 config.providers.models.openrouter.insert(
18922 "default".to_string(),
18923 OpenRouterModelProviderConfig {
18924 base: ModelProviderConfig {
18925 model: Some("primary-model".to_string()),
18926 ..Default::default()
18927 },
18928 },
18929 );
18930 assert!(config.resolve_default_model().is_some());
18932 }
18933
18934 #[test]
18935 async fn save_repairs_bare_config_filename_using_runtime_resolution() {
18936 let _env_guard = env_override_lock().await;
18937 let temp_home =
18938 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
18939 let workspace_dir = temp_home.join("workspace");
18940 let resolved_config_path = temp_home.join(".zeroclaw").join("config.toml");
18941
18942 let original_home = std::env::var("HOME").ok();
18943 unsafe { std::env::set_var("HOME", &temp_home) };
18945 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
18947
18948 let mut config = Config {
18949 data_dir: workspace_dir,
18950 config_path: PathBuf::from("config.toml"),
18951 ..Default::default()
18952 };
18953 config.providers.models.anthropic.insert(
18954 "default".to_string(),
18955 AnthropicModelProviderConfig {
18956 base: ModelProviderConfig {
18957 temperature: Some(0.5),
18958 ..Default::default()
18959 },
18960 },
18961 );
18962 config.save().await.unwrap();
18964
18965 assert!(resolved_config_path.exists());
18966 let saved = tokio::fs::read_to_string(&resolved_config_path)
18967 .await
18968 .unwrap();
18969 let parsed = parse_test_config(&saved);
18970 assert!(
18971 (parsed
18972 .providers
18973 .models
18974 .find("anthropic", "default")
18975 .and_then(|e| e.temperature)
18976 .unwrap_or(0.7)
18977 - 0.5)
18978 .abs()
18979 < f64::EPSILON
18980 );
18981
18982 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
18984 if let Some(home) = original_home {
18985 unsafe { std::env::set_var("HOME", home) };
18987 } else {
18988 unsafe { std::env::remove_var("HOME") };
18990 }
18991 let _ = tokio::fs::remove_dir_all(temp_home).await;
18992 }
18993
18994 #[test]
18995 async fn validate_ollama_cloud_model_requires_remote_api_url() {
18996 let _env_guard = env_override_lock().await;
18997 let mut config = Config::default();
18998 config.providers.models.ollama.insert(
18999 "default".to_string(),
19000 OllamaModelProviderConfig {
19001 base: ModelProviderConfig {
19002 model: Some("glm-5:cloud".to_string()),
19003 uri: None,
19004 api_key: Some("ollama-key".to_string()),
19005 ..Default::default()
19006 },
19007 ..OllamaModelProviderConfig::default()
19008 },
19009 );
19010
19011 let error = config.validate().expect_err("expected validation to fail");
19012 assert!(error.to_string().contains(
19013 "providers.models.ollama.default.model uses ':cloud', but uri is local or unset"
19014 ));
19015 }
19016
19017 #[test]
19018 async fn validate_ollama_cloud_model_accepts_private_remote_without_api_key() {
19019 let _env_guard = env_override_lock().await;
19020 let mut config = Config::default();
19021 config.providers.models.ollama.insert(
19022 "default".to_string(),
19023 OllamaModelProviderConfig {
19024 base: ModelProviderConfig {
19025 model: Some("glm-5:cloud".to_string()),
19026 uri: Some("http://192.168.1.100:11434".to_string()),
19027 api_key: None,
19028 ..Default::default()
19029 },
19030 ..OllamaModelProviderConfig::default()
19031 },
19032 );
19033
19034 let result = config.validate();
19035 assert!(result.is_ok(), "expected validation to pass: {result:?}");
19036 }
19037
19038 #[test]
19039 async fn validate_ollama_cloud_model_requires_api_key_for_official_endpoint() {
19040 let _env_guard = env_override_lock().await;
19041 let mut config = Config::default();
19042 config.providers.models.ollama.insert(
19043 "default".to_string(),
19044 OllamaModelProviderConfig {
19045 base: ModelProviderConfig {
19046 model: Some("glm-5:cloud".to_string()),
19047 uri: Some("https://ollama.com/api".to_string()),
19048 api_key: None,
19049 ..Default::default()
19050 },
19051 ..OllamaModelProviderConfig::default()
19052 },
19053 );
19054
19055 let error = config.validate().expect_err("expected validation to fail");
19056 assert!(error.to_string().contains(
19057 "providers.models.ollama.default.model uses ':cloud', but no API key is configured"
19058 ));
19059 }
19060
19061 #[test]
19062 async fn validate_ollama_cloud_model_accepts_remote_endpoint_with_typed_api_key() {
19063 let _env_guard = env_override_lock().await;
19066 let mut config = Config::default();
19067 config.providers.models.ollama.insert(
19068 "default".to_string(),
19069 OllamaModelProviderConfig {
19070 base: ModelProviderConfig {
19071 model: Some("glm-5:cloud".to_string()),
19072 uri: Some("https://ollama.com/api".to_string()),
19073 api_key: Some("ollama-typed-key".to_string()),
19074 ..Default::default()
19075 },
19076 ..OllamaModelProviderConfig::default()
19077 },
19078 );
19079
19080 let result = config.validate();
19081 assert!(result.is_ok(), "expected validation to pass: {result:?}");
19082 }
19083
19084 #[test]
19085 async fn validate_ollama_cloud_model_checks_each_alias_for_official_key() {
19086 let _env_guard = env_override_lock().await;
19087 let mut config = Config::default();
19088 config.providers.models.ollama.insert(
19089 "local".to_string(),
19090 OllamaModelProviderConfig {
19091 base: ModelProviderConfig {
19092 model: Some("llama3".to_string()),
19093 uri: Some("http://192.168.1.100:11434".to_string()),
19094 ..Default::default()
19095 },
19096 ..OllamaModelProviderConfig::default()
19097 },
19098 );
19099 config.providers.models.ollama.insert(
19100 "cloud".to_string(),
19101 OllamaModelProviderConfig {
19102 base: ModelProviderConfig {
19103 model: Some("glm-5:cloud".to_string()),
19104 uri: Some("https://ollama.com/api".to_string()),
19105 api_key: None,
19106 ..Default::default()
19107 },
19108 ..OllamaModelProviderConfig::default()
19109 },
19110 );
19111
19112 let error = config.validate().expect_err("expected validation to fail");
19113 assert!(error.to_string().contains(
19114 "providers.models.ollama.cloud.model uses ':cloud', but no API key is configured"
19115 ));
19116 }
19117
19118 #[test]
19119 async fn deserialize_rejects_unknown_model_provider_wire_api() {
19120 let toml = r#"
19121schema_version = 3
19122
19123[providers.models.openrouter.default]
19124uri = "https://api.tonsof.blue/v1"
19125wire_api = "ws"
19126"#;
19127 let err = toml::from_str::<Config>(toml).expect_err("expected deserialize failure");
19128 let msg = err.to_string();
19129 assert!(
19130 msg.contains("wire_api") || msg.contains("ws"),
19131 "error should reference the invalid wire_api value, got: {msg}"
19132 );
19133 }
19134
19135 #[test]
19136 async fn resolve_runtime_config_dirs_accepts_legacy_zeroclaw_workspace() {
19137 let _env_guard = env_override_lock().await;
19138 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
19139 let default_workspace_dir = default_config_dir.join("workspace");
19140 let workspace_dir = default_config_dir.join("profile-a");
19141
19142 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
19144 let (config_dir, resolved_workspace_dir, source) =
19145 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
19146 .await
19147 .unwrap();
19148
19149 assert_eq!(source, ConfigResolutionSource::EnvWorkspaceLegacy);
19153 assert_eq!(config_dir, workspace_dir);
19154 assert_eq!(resolved_workspace_dir, workspace_dir.join("data"));
19155
19156 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19158 let _ = fs::remove_dir_all(default_config_dir).await;
19159 }
19160
19161 #[test]
19162 async fn resolve_runtime_config_dirs_uses_env_config_dir_first() {
19163 let _env_guard = env_override_lock().await;
19164 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
19165 let default_workspace_dir = default_config_dir.join("workspace");
19166 let explicit_config_dir = default_config_dir.join("explicit-config");
19167
19168 fs::create_dir_all(&default_config_dir).await.unwrap();
19169
19170 unsafe { std::env::set_var("ZEROCLAW_CONFIG_DIR", &explicit_config_dir) };
19172 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19174
19175 let (config_dir, resolved_workspace_dir, source) =
19176 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
19177 .await
19178 .unwrap();
19179
19180 assert_eq!(source, ConfigResolutionSource::EnvConfigDir);
19181 assert_eq!(config_dir, explicit_config_dir);
19182 assert_eq!(resolved_workspace_dir, explicit_config_dir.join("data"));
19183
19184 unsafe { std::env::remove_var("ZEROCLAW_CONFIG_DIR") };
19186 let _ = fs::remove_dir_all(default_config_dir).await;
19187 }
19188
19189 #[test]
19190 async fn resolve_runtime_config_dirs_falls_back_to_default_layout() {
19191 let _env_guard = env_override_lock().await;
19192 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
19193 let default_workspace_dir = default_config_dir.join("workspace");
19194
19195 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19197 let (config_dir, resolved_workspace_dir, source) =
19198 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
19199 .await
19200 .unwrap();
19201
19202 assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
19203 assert_eq!(config_dir, default_config_dir);
19204 assert_eq!(resolved_workspace_dir, default_workspace_dir);
19205
19206 let _ = fs::remove_dir_all(default_config_dir).await;
19207 }
19208
19209 async fn create_homebrew_prefix() -> TempDir {
19210 let prefix = TempDir::new().expect("homebrew prefix temp dir");
19211 fs::create_dir_all(prefix.path().join("Cellar"))
19212 .await
19213 .expect("create Cellar marker");
19214 prefix
19215 }
19216
19217 #[test]
19218 async fn try_resolve_macos_homebrew_config_dir_detects_cellar_layout() {
19219 let prefix = create_homebrew_prefix().await;
19220 let exe = prefix
19221 .path()
19222 .join("Cellar")
19223 .join("zeroclaw")
19224 .join("0.7.0")
19225 .join("bin")
19226 .join("zeroclaw");
19227
19228 let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
19229 .await
19230 .expect("expected Homebrew layout");
19231
19232 assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
19233 }
19234
19235 #[test]
19236 async fn try_resolve_macos_homebrew_config_dir_detects_prefix_bin_layout() {
19237 let prefix = create_homebrew_prefix().await;
19238 let exe = prefix.path().join("bin").join("zeroclaw");
19239
19240 let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
19241 .await
19242 .expect("expected Homebrew layout");
19243
19244 assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
19245 }
19246
19247 #[test]
19248 async fn try_resolve_macos_homebrew_config_dir_detects_opt_bin_layout() {
19249 let prefix = create_homebrew_prefix().await;
19250 let exe = prefix
19251 .path()
19252 .join("opt")
19253 .join("zeroclaw")
19254 .join("bin")
19255 .join("zeroclaw");
19256
19257 let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
19258 .await
19259 .expect("expected Homebrew layout");
19260
19261 assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
19262 }
19263
19264 #[test]
19265 async fn try_resolve_macos_homebrew_config_dir_rejects_non_homebrew_layout() {
19266 let prefix = TempDir::new().expect("non-homebrew temp dir");
19267 let exe = prefix.path().join("bin").join("zeroclaw");
19268
19269 assert!(try_resolve_macos_homebrew_config_dir(&exe).await.is_none());
19270 }
19271
19272 #[test]
19273 async fn default_path_under_config_dir_respects_zeroclaw_config_dir() {
19274 let _env_guard = env_override_lock().await;
19275 let custom_dir = std::env::temp_dir().join("zeroclaw-test-profile");
19276 unsafe { std::env::set_var("ZEROCLAW_CONFIG_DIR", &custom_dir) };
19278
19279 let result = default_path_under_config_dir("knowledge.db");
19280
19281 unsafe { std::env::remove_var("ZEROCLAW_CONFIG_DIR") };
19283
19284 assert_eq!(
19285 result,
19286 custom_dir.join("knowledge.db").to_string_lossy().as_ref(),
19287 "expected path under ZEROCLAW_CONFIG_DIR, got: {result}"
19288 );
19289 }
19290
19291 #[test]
19292 async fn load_or_init_workspace_override_uses_workspace_root_for_config() {
19293 let _env_guard = env_override_lock().await;
19294 let temp_home =
19295 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
19296 let workspace_dir = temp_home.join("profile-a");
19297
19298 let original_home = std::env::var("HOME").ok();
19299 unsafe { std::env::set_var("HOME", &temp_home) };
19301 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
19303
19304 let config = Box::pin(Config::load_or_init()).await.unwrap();
19305
19306 assert_eq!(config.data_dir, workspace_dir.join("data"));
19312 assert_eq!(config.config_path, workspace_dir.join("config.toml"));
19313 assert!(workspace_dir.join("config.toml").exists());
19314 assert!(
19315 !workspace_dir.join("agents").exists(),
19316 "fresh init must not create agents/ tree"
19317 );
19318
19319 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19321 if let Some(home) = original_home {
19322 unsafe { std::env::set_var("HOME", home) };
19324 } else {
19325 unsafe { std::env::remove_var("HOME") };
19327 }
19328 let _ = fs::remove_dir_all(temp_home).await;
19329 }
19330
19331 #[test]
19332 async fn load_or_init_workspace_suffix_uses_legacy_config_layout() {
19333 let _env_guard = env_override_lock().await;
19334 let temp_home =
19335 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
19336 let workspace_dir = temp_home.join("workspace");
19337 let legacy_config_dir = temp_home.join(".zeroclaw");
19338 let legacy_config_path = legacy_config_dir.join("config.toml");
19339
19340 let original_home = std::env::var("HOME").ok();
19341 unsafe { std::env::set_var("HOME", &temp_home) };
19343 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
19345
19346 let config = Box::pin(Config::load_or_init()).await.unwrap();
19347
19348 assert_eq!(config.data_dir, legacy_config_dir.join("data"));
19353 assert_eq!(config.config_path, legacy_config_path);
19354 assert!(config.config_path.exists());
19355
19356 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19358 if let Some(home) = original_home {
19359 unsafe { std::env::set_var("HOME", home) };
19361 } else {
19362 unsafe { std::env::remove_var("HOME") };
19364 }
19365 let _ = fs::remove_dir_all(temp_home).await;
19366 }
19367
19368 #[test]
19369 async fn load_or_init_workspace_override_keeps_existing_legacy_config() {
19370 let _env_guard = env_override_lock().await;
19371 let temp_home =
19372 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
19373 let workspace_dir = temp_home.join("custom-workspace");
19374 let legacy_config_dir = temp_home.join(".zeroclaw");
19375 let legacy_config_path = legacy_config_dir.join("config.toml");
19376
19377 fs::create_dir_all(&legacy_config_dir).await.unwrap();
19378 fs::write(
19379 &legacy_config_path,
19380 r#"default_temperature = 0.7
19381default_model = "legacy-model"
19382"#,
19383 )
19384 .await
19385 .unwrap();
19386
19387 let original_home = std::env::var("HOME").ok();
19388 unsafe { std::env::set_var("HOME", &temp_home) };
19390 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
19392
19393 let config = Box::pin(Config::load_or_init()).await.unwrap();
19394
19395 assert_eq!(config.data_dir, legacy_config_dir.join("data"));
19400 assert_eq!(config.config_path, legacy_config_path);
19401 assert_eq!(
19402 config
19403 .first_model_provider()
19404 .and_then(|e| e.model.as_deref()),
19405 Some("legacy-model")
19406 );
19407
19408 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19410 if let Some(home) = original_home {
19411 unsafe { std::env::set_var("HOME", home) };
19413 } else {
19414 unsafe { std::env::remove_var("HOME") };
19416 }
19417 let _ = fs::remove_dir_all(temp_home).await;
19418 }
19419
19420 #[test]
19421 async fn load_or_init_decrypts_feishu_channel_secrets() {
19422 let _env_guard = env_override_lock().await;
19423 let temp_home =
19424 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
19425 let config_dir = temp_home.join(".zeroclaw");
19426 let config_path = config_dir.join("config.toml");
19427
19428 fs::create_dir_all(&config_dir).await.unwrap();
19429
19430 let original_home = std::env::var("HOME").ok();
19431 unsafe { std::env::set_var("HOME", &temp_home) };
19433 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19435
19436 let mut config = Config {
19437 config_path: config_path.clone(),
19438 data_dir: config_dir.join("workspace"),
19439 ..Default::default()
19440 };
19441 config.secrets.encrypt = true;
19442 config.channels.lark.insert(
19443 "feishu".to_string(),
19444 LarkConfig {
19445 enabled: true,
19446 app_id: "cli_feishu_123".into(),
19447 app_secret: "feishu-secret".into(),
19448 encrypt_key: Some("feishu-encrypt".into()),
19449 verification_token: Some("feishu-verify".into()),
19450 mention_only: false,
19451 use_feishu: true,
19452 receive_mode: LarkReceiveMode::Websocket,
19453 port: None,
19454 proxy_url: None,
19455 excluded_tools: vec![],
19456 default_target: None,
19457 },
19458 );
19459 config.save().await.unwrap();
19460
19461 let loaded = Box::pin(Config::load_or_init()).await.unwrap();
19462 let feishu = loaded.channels.lark.get("feishu").unwrap();
19463 assert_eq!(feishu.app_secret, "feishu-secret");
19464 assert_eq!(feishu.encrypt_key.as_deref(), Some("feishu-encrypt"));
19465 assert_eq!(feishu.verification_token.as_deref(), Some("feishu-verify"));
19466
19467 if let Some(home) = original_home {
19468 unsafe { std::env::set_var("HOME", home) };
19470 } else {
19471 unsafe { std::env::remove_var("HOME") };
19473 }
19474 let _ = fs::remove_dir_all(temp_home).await;
19475 }
19476
19477 #[test]
19478 #[allow(clippy::large_futures)]
19479 async fn load_or_init_logs_existing_config_as_initialized() {
19480 let _env_guard = env_override_lock().await;
19481 let temp_home =
19482 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
19483 let workspace_dir = temp_home.join("profile-a");
19484 let config_path = workspace_dir.join("config.toml");
19485
19486 fs::create_dir_all(&workspace_dir).await.unwrap();
19487 fs::write(
19488 &config_path,
19489 r#"default_temperature = 0.7
19490default_model = "persisted-profile"
19491"#,
19492 )
19493 .await
19494 .unwrap();
19495
19496 let original_home = std::env::var("HOME").ok();
19497 unsafe { std::env::set_var("HOME", &temp_home) };
19499 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
19501
19502 let mut rx = capture_log_events();
19503
19504 let config = Box::pin(Config::load_or_init()).await.unwrap();
19505
19506 let logs = drain_captured(&mut rx);
19507
19508 assert_eq!(config.data_dir, workspace_dir.join("data"));
19514 assert_eq!(config.config_path, config_path);
19515 assert_eq!(
19516 config
19517 .first_model_provider()
19518 .and_then(|e| e.model.as_deref()),
19519 Some("persisted-profile")
19520 );
19521 assert!(logs.contains("Config loaded"), "{logs}");
19522 assert!(logs.contains("\"initialized\":true"), "{logs}");
19523 assert!(!logs.contains("\"initialized\":false"), "{logs}");
19524
19525 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19527 if let Some(home) = original_home {
19528 unsafe { std::env::set_var("HOME", home) };
19530 } else {
19531 unsafe { std::env::remove_var("HOME") };
19533 }
19534 let _ = fs::remove_dir_all(temp_home).await;
19535 }
19536
19537 #[test]
19538 async fn validate_rejects_out_of_range_temperature() {
19539 let mut config = Config::default();
19540 config.providers.models.openrouter.insert(
19541 "default".to_string(),
19542 OpenRouterModelProviderConfig {
19543 base: ModelProviderConfig {
19544 api_key: Some("sk-test".into()),
19545 temperature: Some(99.0),
19546 ..Default::default()
19547 },
19548 },
19549 );
19550 let err = config.validate().unwrap_err();
19551 assert!(
19552 err.to_string().contains("temperature"),
19553 "expected temperature validation error, got: {err}"
19554 );
19555 }
19556
19557 #[test]
19558 async fn validate_rejects_negative_temperature() {
19559 let mut config = Config::default();
19560 config.providers.models.openrouter.insert(
19561 "default".to_string(),
19562 OpenRouterModelProviderConfig {
19563 base: ModelProviderConfig {
19564 api_key: Some("sk-test".into()),
19565 temperature: Some(-0.5),
19566 ..Default::default()
19567 },
19568 },
19569 );
19570 let err = config.validate().unwrap_err();
19571 assert!(
19572 err.to_string().contains("temperature"),
19573 "expected temperature validation error, got: {err}"
19574 );
19575 }
19576
19577 #[test]
19578 async fn validate_accepts_valid_temperature() {
19579 let mut config = Config::default();
19580 config.providers.models.openrouter.insert(
19581 "default".to_string(),
19582 OpenRouterModelProviderConfig {
19583 base: ModelProviderConfig {
19584 temperature: Some(0.7),
19585 ..Default::default()
19586 },
19587 },
19588 );
19589 assert!(config.validate().is_ok());
19590 }
19591
19592 #[test]
19593 async fn validate_rejects_unknown_jira_actions() {
19594 for action in ["delete_ticket", "drop_database", ""] {
19595 let mut config = Config::default();
19596 config.jira.enabled = true;
19597 config.jira.base_url = "https://jira.example.test".into();
19598 config.jira.api_token = "token".into();
19599 config.jira.allowed_actions = vec![action.into()];
19600
19601 let err = config
19602 .validate()
19603 .expect_err("unknown Jira action should be rejected")
19604 .to_string();
19605 assert!(
19606 err.contains("jira.allowed_actions contains unknown action"),
19607 "expected Jira allowed action error for {action:?}, got: {err}"
19608 );
19609 }
19610 }
19611
19612 #[test]
19613 async fn validate_accepts_all_published_jira_actions() {
19614 for action in [
19615 "get_ticket",
19616 "search_tickets",
19617 "comment_ticket",
19618 "list_projects",
19619 "myself",
19620 "list_transitions",
19621 "transition_ticket",
19622 "create_ticket",
19623 ] {
19624 let mut config = Config::default();
19625 config.jira.enabled = true;
19626 config.jira.base_url = "https://jira.example.test".into();
19627 config.jira.api_token = "token".into();
19628 config.jira.allowed_actions = vec![action.into()];
19629
19630 assert!(
19631 config.validate().is_ok(),
19632 "published Jira action {action:?} should validate"
19633 );
19634 }
19635 }
19636
19637 #[test]
19638 async fn jira_email_empty_string_deserializes_as_none() {
19639 let toml_input = r#"
19647enabled = true
19648base_url = "https://jira.example.test"
19649email = ""
19650api_token = "tok"
19651"#;
19652 let cfg: JiraConfig = toml::from_str(toml_input).expect("parses with empty email");
19653 assert!(
19654 cfg.email.is_none(),
19655 "empty `email = \"\"` must deserialize as None, got {:?}",
19656 cfg.email
19657 );
19658 let toml_input_ws = r#"
19660enabled = true
19661base_url = "https://jira.example.test"
19662email = " "
19663api_token = "tok"
19664"#;
19665 let cfg_ws: JiraConfig =
19666 toml::from_str(toml_input_ws).expect("parses with whitespace email");
19667 assert!(
19668 cfg_ws.email.is_none(),
19669 "whitespace-only email must deserialize as None, got {:?}",
19670 cfg_ws.email
19671 );
19672 let toml_input_real = r#"
19674enabled = true
19675base_url = "https://jira.example.test"
19676email = "ops@example.com"
19677api_token = "tok"
19678"#;
19679 let cfg_real: JiraConfig = toml::from_str(toml_input_real).expect("parses with real email");
19680 assert_eq!(
19681 cfg_real.email.as_deref(),
19682 Some("ops@example.com"),
19683 "non-empty email must round-trip unchanged"
19684 );
19685 }
19686
19687 #[test]
19688 async fn proxy_config_scope_services_requires_entries_when_enabled() {
19689 let proxy = ProxyConfig {
19690 enabled: true,
19691 http_proxy: Some("http://127.0.0.1:7890".into()),
19692 https_proxy: None,
19693 all_proxy: None,
19694 no_proxy: Vec::new(),
19695 scope: ProxyScope::Services,
19696 services: Vec::new(),
19697 };
19698
19699 let error = proxy.validate().unwrap_err().to_string();
19700 assert!(error.contains("proxy.scope='services'"));
19701 }
19702
19703 #[test]
19704 async fn google_workspace_allowed_operations_require_methods() {
19705 let mut config = Config::default();
19706 config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
19707 service: "gmail".into(),
19708 resource: "users".into(),
19709 sub_resource: Some("drafts".into()),
19710 methods: Vec::new(),
19711 }];
19712
19713 let err = config.validate().unwrap_err().to_string();
19714 assert!(err.contains("google_workspace.allowed_operations[0].methods"));
19715 }
19716
19717 #[test]
19718 async fn google_workspace_allowed_operations_reject_duplicate_service_resource_sub_resource_entries()
19719 {
19720 let mut config = Config::default();
19721 config.google_workspace.allowed_operations = vec![
19722 GoogleWorkspaceAllowedOperation {
19723 service: "gmail".into(),
19724 resource: "users".into(),
19725 sub_resource: Some("drafts".into()),
19726 methods: vec!["create".into()],
19727 },
19728 GoogleWorkspaceAllowedOperation {
19729 service: "gmail".into(),
19730 resource: "users".into(),
19731 sub_resource: Some("drafts".into()),
19732 methods: vec!["update".into()],
19733 },
19734 ];
19735
19736 let err = config.validate().unwrap_err().to_string();
19737 assert!(err.contains("duplicate service/resource/sub_resource entry"));
19738 }
19739
19740 #[test]
19741 async fn google_workspace_allowed_operations_allow_same_resource_different_sub_resource() {
19742 let mut config = Config::default();
19743 config.google_workspace.allowed_operations = vec![
19744 GoogleWorkspaceAllowedOperation {
19745 service: "gmail".into(),
19746 resource: "users".into(),
19747 sub_resource: Some("messages".into()),
19748 methods: vec!["list".into(), "get".into()],
19749 },
19750 GoogleWorkspaceAllowedOperation {
19751 service: "gmail".into(),
19752 resource: "users".into(),
19753 sub_resource: Some("drafts".into()),
19754 methods: vec!["create".into(), "update".into()],
19755 },
19756 ];
19757
19758 assert!(config.validate().is_ok());
19759 }
19760
19761 #[test]
19762 async fn google_workspace_allowed_operations_reject_duplicate_methods_within_entry() {
19763 let mut config = Config::default();
19764 config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
19765 service: "gmail".into(),
19766 resource: "users".into(),
19767 sub_resource: Some("drafts".into()),
19768 methods: vec!["create".into(), "create".into()],
19769 }];
19770
19771 let err = config.validate().unwrap_err().to_string();
19772 assert!(
19773 err.contains("duplicate entry"),
19774 "expected duplicate entry error, got: {err}"
19775 );
19776 }
19777
19778 #[test]
19779 async fn google_workspace_allowed_operations_accept_valid_entries() {
19780 let mut config = Config::default();
19781 config.google_workspace.allowed_operations = vec![
19782 GoogleWorkspaceAllowedOperation {
19783 service: "gmail".into(),
19784 resource: "users".into(),
19785 sub_resource: Some("messages".into()),
19786 methods: vec!["list".into(), "get".into()],
19787 },
19788 GoogleWorkspaceAllowedOperation {
19789 service: "drive".into(),
19790 resource: "files".into(),
19791 sub_resource: None,
19792 methods: vec!["list".into(), "get".into()],
19793 },
19794 ];
19795
19796 assert!(config.validate().is_ok());
19797 }
19798
19799 #[test]
19800 async fn google_workspace_allowed_operations_reject_invalid_sub_resource_characters() {
19801 let mut config = Config::default();
19802 config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
19803 service: "gmail".into(),
19804 resource: "users".into(),
19805 sub_resource: Some("bad resource!".into()),
19806 methods: vec!["list".into()],
19807 }];
19808
19809 let err = config.validate().unwrap_err().to_string();
19810 assert!(err.contains("sub_resource contains invalid characters"));
19811 }
19812
19813 fn runtime_proxy_cache_contains(cache_key: &str) -> bool {
19814 match runtime_proxy_client_cache().read() {
19815 Ok(guard) => guard.contains_key(cache_key),
19816 Err(poisoned) => poisoned.into_inner().contains_key(cache_key),
19817 }
19818 }
19819
19820 #[test]
19821 async fn runtime_proxy_client_cache_reuses_default_profile_key() {
19822 let service_key = format!(
19823 "model_provider.cache_test.{}",
19824 std::time::SystemTime::now()
19825 .duration_since(std::time::UNIX_EPOCH)
19826 .expect("system clock should be after unix epoch")
19827 .as_nanos()
19828 );
19829 let cache_key = runtime_proxy_cache_key(&service_key, None, None);
19830
19831 clear_runtime_proxy_client_cache();
19832 assert!(!runtime_proxy_cache_contains(&cache_key));
19833
19834 let _ = build_runtime_proxy_client(&service_key);
19835 assert!(runtime_proxy_cache_contains(&cache_key));
19836
19837 let _ = build_runtime_proxy_client(&service_key);
19838 assert!(runtime_proxy_cache_contains(&cache_key));
19839 }
19840
19841 #[test]
19842 async fn set_runtime_proxy_config_clears_runtime_proxy_client_cache() {
19843 let service_key = format!(
19844 "model_provider.cache_timeout_test.{}",
19845 std::time::SystemTime::now()
19846 .duration_since(std::time::UNIX_EPOCH)
19847 .expect("system clock should be after unix epoch")
19848 .as_nanos()
19849 );
19850 let cache_key = runtime_proxy_cache_key(&service_key, Some(30), Some(5));
19851
19852 clear_runtime_proxy_client_cache();
19853 let _ = build_runtime_proxy_client_with_timeouts(&service_key, 30, 5);
19854 assert!(runtime_proxy_cache_contains(&cache_key));
19855
19856 set_runtime_proxy_config(ProxyConfig::default());
19857 assert!(!runtime_proxy_cache_contains(&cache_key));
19858 }
19859
19860 #[test]
19861 async fn gateway_config_default_values() {
19862 let g = GatewayConfig::default();
19863 assert_eq!(g.port, 42617);
19864 assert_eq!(g.host, "127.0.0.1");
19865 assert!(g.require_pairing);
19866 assert!(!g.allow_public_bind);
19867 assert!(g.paired_tokens.is_empty());
19868 assert!(!g.trust_forwarded_headers);
19869 assert_eq!(g.rate_limit_max_keys, 10_000);
19870 assert_eq!(g.idempotency_max_keys, 10_000);
19871 }
19872
19873 #[test]
19876 async fn peripherals_config_default_disabled() {
19877 let p = PeripheralsConfig::default();
19878 assert!(!p.enabled);
19879 assert!(p.boards.is_empty());
19880 }
19881
19882 #[test]
19883 async fn peripheral_board_config_defaults() {
19884 let b = PeripheralBoardConfig::default();
19885 assert!(b.board.is_empty());
19886 assert_eq!(b.transport, "serial");
19887 assert!(b.path.is_none());
19888 assert_eq!(b.baud, 115_200);
19889 }
19890
19891 #[test]
19892 async fn peripherals_config_toml_roundtrip() {
19893 let p = PeripheralsConfig {
19894 enabled: true,
19895 boards: vec![PeripheralBoardConfig {
19896 board: "nucleo-f401re".into(),
19897 transport: "serial".into(),
19898 path: Some("/dev/ttyACM0".into()),
19899 baud: 115_200,
19900 }],
19901 datasheet_dir: None,
19902 };
19903 let toml_str = toml::to_string(&p).unwrap();
19904 let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
19905 assert!(parsed.enabled);
19906 assert_eq!(parsed.boards.len(), 1);
19907 assert_eq!(parsed.boards[0].board, "nucleo-f401re");
19908 assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
19909 }
19910
19911 #[test]
19912 async fn lark_config_serde() {
19913 let lc = LarkConfig {
19914 enabled: true,
19915 app_id: "cli_123456".into(),
19916 app_secret: "secret_abc".into(),
19917 encrypt_key: Some("encrypt_key".into()),
19918 verification_token: Some("verify_token".into()),
19919 mention_only: false,
19920 use_feishu: true,
19921 receive_mode: LarkReceiveMode::Websocket,
19922 port: None,
19923 proxy_url: None,
19924 excluded_tools: vec![],
19925 default_target: None,
19926 };
19927 let json = serde_json::to_string(&lc).unwrap();
19928 let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
19929 assert_eq!(parsed.app_id, "cli_123456");
19930 assert_eq!(parsed.app_secret, "secret_abc");
19931 assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
19932 assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
19933 assert!(parsed.use_feishu);
19934 }
19935
19936 #[test]
19937 async fn lark_config_toml_roundtrip() {
19938 let lc = LarkConfig {
19939 enabled: true,
19940 app_id: "cli_123456".into(),
19941 app_secret: "secret_abc".into(),
19942 encrypt_key: Some("encrypt_key".into()),
19943 verification_token: Some("verify_token".into()),
19944 mention_only: false,
19945 use_feishu: false,
19946 receive_mode: LarkReceiveMode::Webhook,
19947 port: Some(9898),
19948 proxy_url: None,
19949 excluded_tools: vec![],
19950 default_target: None,
19951 };
19952 let toml_str = toml::to_string(&lc).unwrap();
19953 let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
19954 assert_eq!(parsed.app_id, "cli_123456");
19955 assert_eq!(parsed.app_secret, "secret_abc");
19956 assert!(!parsed.use_feishu);
19957 }
19958
19959 #[test]
19960 async fn lark_config_deserializes_without_optional_fields() {
19961 let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
19962 let parsed: LarkConfig = serde_json::from_str(json).unwrap();
19963 assert!(parsed.encrypt_key.is_none());
19964 assert!(parsed.verification_token.is_none());
19965 assert!(!parsed.mention_only);
19966 assert!(!parsed.use_feishu);
19967 }
19968
19969 #[test]
19970 async fn lark_config_defaults_to_lark_endpoint() {
19971 let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
19972 let parsed: LarkConfig = serde_json::from_str(json).unwrap();
19973 assert!(
19974 !parsed.use_feishu,
19975 "use_feishu should default to false (Lark)"
19976 );
19977 }
19978
19979 #[test]
19980 async fn lark_v2_allowed_users_fold_into_peer_groups() {
19981 let raw = r#"
19986schema_version = 2
19987
19988[channels.lark]
19989enabled = true
19990app_id = "cli_123"
19991app_secret = "secret"
19992allowed_users = ["user_alpha", "user_beta"]
19993"#;
19994 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
19995 let group = parsed
19996 .peer_groups
19997 .get("lark_default")
19998 .expect("V2 lark.allowed_users must fold into peer_groups.lark_default");
19999 assert_eq!(group.channel, "lark");
20000 let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
20001 assert_eq!(usernames, vec!["user_alpha", "user_beta"]);
20002 }
20003
20004 #[test]
20007 async fn line_config_toml_roundtrip() {
20008 let toml = r#"
20014[channels_config.line.default]
20015enabled = true
20016channel_access_token = "ChannelAccessToken=="
20017channel_secret = "abc123secret"
20018dm_policy = "pairing"
20019group_policy = "mention"
20020allowed_users = []
20021webhook_port = 8443
20022"#;
20023 let config: Config = toml::from_str(toml).unwrap();
20024 let ln = config.channels.line.get("default").unwrap();
20025 assert_eq!(ln.channel_access_token, "ChannelAccessToken==");
20026 assert_eq!(ln.channel_secret, "abc123secret");
20027 assert_eq!(ln.dm_policy, LineDmPolicy::Pairing);
20028 assert_eq!(ln.group_policy, LineGroupPolicy::Mention);
20029 assert_eq!(ln.webhook_port, 8443);
20030 assert!(ln.proxy_url.is_none());
20031 }
20032
20033 #[test]
20034 async fn line_config_defaults() {
20035 let toml = r#"
20038[channels_config.line.default]
20039channel_access_token = "tok"
20040channel_secret = "sec"
20041"#;
20042 let config: Config = toml::from_str(toml).unwrap();
20043 let ln = config.channels.line.get("default").unwrap();
20044 assert_eq!(
20045 ln.dm_policy,
20046 LineDmPolicy::Pairing,
20047 "dm_policy default is pairing"
20048 );
20049 assert_eq!(
20050 ln.group_policy,
20051 LineGroupPolicy::Mention,
20052 "group_policy default is mention"
20053 );
20054 assert_eq!(ln.webhook_port, 8443, "webhook_port default is 8443");
20055 assert!(ln.proxy_url.is_none());
20056 }
20057
20058 #[test]
20059 async fn line_config_allowlist_policy() {
20060 let toml = r#"
20064schema_version = 2
20065
20066[channels.line]
20067enabled = true
20068channel_access_token = "tok"
20069channel_secret = "sec"
20070dm_policy = "allowlist"
20071allowed_users = ["Uabc123", "Udef456"]
20072"#;
20073 let config = crate::migration::migrate_to_current(toml).expect("migration succeeds");
20074 let ln = config.channels.line.get("default").unwrap();
20075 assert_eq!(ln.dm_policy, LineDmPolicy::Allowlist);
20076 let group = config
20077 .peer_groups
20078 .get("line_default")
20079 .expect("V2 line.allowed_users must fold into peer_groups.line_default");
20080 let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
20081 assert_eq!(usernames, vec!["Uabc123", "Udef456"]);
20082 }
20083
20084 #[test]
20085 async fn line_config_open_policies() {
20086 let toml = r#"
20088[channels_config.line.default]
20089channel_access_token = "tok"
20090channel_secret = "sec"
20091dm_policy = "open"
20092group_policy = "open"
20093"#;
20094 let config: Config = toml::from_str(toml).unwrap();
20095 let ln = config.channels.line.get("default").unwrap();
20096 assert_eq!(ln.dm_policy, LineDmPolicy::Open);
20097 assert_eq!(ln.group_policy, LineGroupPolicy::Open);
20098 }
20099
20100 #[test]
20101 async fn line_config_group_disabled() {
20102 let toml = r#"
20104[channels_config.line.default]
20105channel_access_token = "tok"
20106channel_secret = "sec"
20107group_policy = "disabled"
20108"#;
20109 let config: Config = toml::from_str(toml).unwrap();
20110 let ln = config.channels.line.get("default").unwrap();
20111 assert_eq!(ln.group_policy, LineGroupPolicy::Disabled);
20112 }
20113
20114 #[test]
20115 async fn nextcloud_talk_config_serde() {
20116 let nc = NextcloudTalkConfig {
20117 enabled: true,
20118 base_url: "https://cloud.example.com".into(),
20119 app_token: "app-token".into(),
20120 webhook_secret: Some("webhook-secret".into()),
20121 proxy_url: None,
20122 bot_name: None,
20123 excluded_tools: vec![],
20124 stream_mode: StreamMode::default(),
20125 draft_update_interval_ms: 1000,
20126 };
20127
20128 let json = serde_json::to_string(&nc).unwrap();
20129 let parsed: NextcloudTalkConfig = serde_json::from_str(&json).unwrap();
20130 assert_eq!(parsed.base_url, "https://cloud.example.com");
20131 assert_eq!(parsed.app_token, "app-token");
20132 assert_eq!(parsed.webhook_secret.as_deref(), Some("webhook-secret"));
20133 }
20134
20135 #[test]
20136 async fn nextcloud_talk_config_defaults_optional_fields() {
20137 let json = r#"{"base_url":"https://cloud.example.com","app_token":"app-token"}"#;
20138 let parsed: NextcloudTalkConfig = serde_json::from_str(json).unwrap();
20139 assert!(parsed.webhook_secret.is_none());
20140 }
20141
20142 #[cfg(unix)]
20145 #[test]
20146 async fn new_config_file_has_restricted_permissions() {
20147 let tmp = tempfile::TempDir::new().unwrap();
20148 let config_path = tmp.path().join("config.toml");
20149
20150 let config = Config {
20152 config_path: config_path.clone(),
20153 ..Default::default()
20154 };
20155 config.save().await.unwrap();
20156
20157 let meta = fs::metadata(&config_path).await.unwrap();
20158 let mode = meta.permissions().mode() & 0o777;
20159 assert_eq!(
20160 mode, 0o600,
20161 "New config file should be owner-only (0600), got {mode:o}"
20162 );
20163 }
20164
20165 #[cfg(unix)]
20166 #[test]
20167 async fn save_restricts_existing_world_readable_config_to_owner_only() {
20168 let tmp = tempfile::TempDir::new().unwrap();
20169 let config_path = tmp.path().join("config.toml");
20170
20171 let mut config = Config {
20172 config_path: config_path.clone(),
20173 ..Default::default()
20174 };
20175 config.save().await.unwrap();
20176
20177 std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
20179 let loose_mode = std::fs::metadata(&config_path)
20180 .unwrap()
20181 .permissions()
20182 .mode()
20183 & 0o777;
20184 assert_eq!(
20185 loose_mode, 0o644,
20186 "test setup requires world-readable config"
20187 );
20188
20189 if let Some(entry) = config.first_model_provider_mut() {
20190 entry.temperature = Some(0.6);
20191 }
20192 config.save().await.unwrap();
20193
20194 let hardened_mode = std::fs::metadata(&config_path)
20195 .unwrap()
20196 .permissions()
20197 .mode()
20198 & 0o777;
20199 assert_eq!(
20200 hardened_mode, 0o600,
20201 "Saving config should restore owner-only permissions (0600)"
20202 );
20203 }
20204
20205 #[cfg(unix)]
20206 #[test]
20207 async fn world_readable_config_is_detectable() {
20208 use std::os::unix::fs::PermissionsExt;
20209
20210 let tmp = tempfile::TempDir::new().unwrap();
20211 let config_path = tmp.path().join("config.toml");
20212
20213 std::fs::write(&config_path, "# test config").unwrap();
20215 std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
20216
20217 let meta = std::fs::metadata(&config_path).unwrap();
20218 let mode = meta.permissions().mode();
20219 assert!(
20220 mode & 0o004 != 0,
20221 "Test setup: file should be world-readable (mode {mode:o})"
20222 );
20223 }
20224
20225 #[test]
20226 async fn transcription_config_defaults() {
20227 let tc = TranscriptionConfig::default();
20228 assert!(!tc.enabled);
20229 assert!(tc.api_url.contains("groq.com"));
20230 assert_eq!(tc.model, "whisper-large-v3-turbo");
20231 assert!(tc.language.is_none());
20232 assert_eq!(tc.max_duration_secs, 120);
20233 assert!(!tc.transcribe_non_ptt_audio);
20234 }
20235
20236 #[test]
20237 async fn config_roundtrip_with_transcription() {
20238 let mut config = Config::default();
20239 config.transcription.enabled = true;
20240 config.transcription.language = Some("en".into());
20241
20242 let toml_str = toml::to_string_pretty(&config).unwrap();
20243 let parsed = parse_test_config(&toml_str);
20244
20245 assert!(parsed.transcription.enabled);
20246 assert_eq!(parsed.transcription.language.as_deref(), Some("en"));
20247 assert_eq!(parsed.transcription.model, "whisper-large-v3-turbo");
20248 }
20249
20250 #[test]
20251 async fn config_without_transcription_uses_defaults() {
20252 let toml_str = r#"
20253 default_model_provider = "openrouter"
20254 default_model = "test-model"
20255 default_temperature = 0.7
20256 "#;
20257 let parsed = parse_test_config(toml_str);
20258 assert!(!parsed.transcription.enabled);
20259 assert_eq!(parsed.transcription.max_duration_secs, 120);
20260 }
20261
20262 #[test]
20263 async fn security_defaults_are_backward_compatible() {
20264 let parsed = parse_test_config(
20265 r#"
20266default_model_provider = "openrouter"
20267default_model = "anthropic/claude-sonnet-4.6"
20268default_temperature = 0.7
20269"#,
20270 );
20271
20272 assert!(!parsed.security.otp.enabled);
20273 assert_eq!(parsed.security.otp.method, OtpMethod::Totp);
20274 assert!(!parsed.security.estop.enabled);
20275 assert!(parsed.security.estop.require_otp_to_resume);
20276 }
20277
20278 #[test]
20279 async fn security_toml_parses_otp_and_estop_sections() {
20280 let parsed = parse_test_config(
20281 r#"
20282default_model_provider = "openrouter"
20283default_model = "anthropic/claude-sonnet-4.6"
20284default_temperature = 0.7
20285
20286[security.otp]
20287enabled = true
20288method = "totp"
20289token_ttl_secs = 30
20290cache_valid_secs = 120
20291gated_actions = ["shell", "browser_open"]
20292gated_domains = ["*.chase.com", "accounts.google.com"]
20293gated_domain_categories = ["banking"]
20294
20295[security.estop]
20296enabled = true
20297state_file = "~/.zeroclaw/estop-state.json"
20298require_otp_to_resume = true
20299"#,
20300 );
20301
20302 assert!(parsed.security.otp.enabled);
20303 assert!(parsed.security.estop.enabled);
20304 assert_eq!(parsed.security.otp.gated_actions.len(), 2);
20305 assert_eq!(parsed.security.otp.gated_domains.len(), 2);
20306 parsed.validate().unwrap();
20307 }
20308
20309 #[test]
20310 async fn security_validation_rejects_invalid_domain_glob() {
20311 let mut config = Config::default();
20312 config.security.otp.gated_domains = vec!["bad domain.com".into()];
20313
20314 let err = config.validate().expect_err("expected invalid domain glob");
20315 assert!(err.to_string().contains("gated_domains"));
20316 }
20317
20318 #[tokio::test]
20326 async fn channel_secret_telegram_bot_token_roundtrip() {
20327 let dir = std::env::temp_dir().join(format!(
20328 "zeroclaw_test_tg_bot_token_{}",
20329 uuid::Uuid::new_v4()
20330 ));
20331 fs::create_dir_all(&dir).await.unwrap();
20332
20333 let plaintext_token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";
20334
20335 let mut config = Config {
20336 data_dir: dir.join("workspace"),
20337 config_path: dir.join("config.toml"),
20338 ..Default::default()
20339 };
20340 config.channels.telegram.insert(
20341 "default".to_string(),
20342 TelegramConfig {
20343 enabled: true,
20344 bot_token: plaintext_token.into(),
20345 stream_mode: StreamMode::default(),
20346 draft_update_interval_ms: default_draft_update_interval_ms(),
20347 interrupt_on_new_message: false,
20348 mention_only: false,
20349 ack_reactions: None,
20350 proxy_url: None,
20351 approval_timeout_secs: default_telegram_approval_timeout_secs(),
20352 excluded_tools: vec![],
20353 default_target: None,
20354 },
20355 );
20356
20357 config.save().await.unwrap();
20359
20360 let raw_toml = tokio::fs::read_to_string(&config.config_path)
20362 .await
20363 .unwrap();
20364 assert!(
20365 !raw_toml.contains(plaintext_token),
20366 "Saved TOML must not contain the plaintext bot_token"
20367 );
20368
20369 let stored: Config = toml::from_str(&raw_toml).unwrap();
20371 let stored_token = &stored.channels.telegram.get("default").unwrap().bot_token;
20372 assert!(
20373 crate::secrets::SecretStore::is_encrypted(stored_token),
20374 "Stored bot_token must be marked as encrypted"
20375 );
20376
20377 let store = crate::secrets::SecretStore::new(&dir, true);
20379 assert_eq!(store.decrypt(stored_token).unwrap(), plaintext_token);
20380
20381 let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
20383 loaded.config_path = dir.join("config.toml");
20384 let load_store = crate::secrets::SecretStore::new(&dir, loaded.secrets.encrypt);
20385 loaded.decrypt_secrets(&load_store).unwrap();
20386 assert_eq!(
20387 loaded.channels.telegram.get("default").unwrap().bot_token,
20388 plaintext_token,
20389 "Loaded bot_token must match the original plaintext after decryption"
20390 );
20391
20392 let _ = fs::remove_dir_all(&dir).await;
20393 }
20394
20395 #[test]
20396 async fn security_validation_rejects_unknown_domain_category() {
20397 let mut config = Config::default();
20398 config.security.otp.gated_domain_categories = vec!["not_real".into()];
20399
20400 let err = config
20401 .validate()
20402 .expect_err("expected unknown domain category");
20403 assert!(err.to_string().contains("gated_domain_categories"));
20404 }
20405
20406 #[test]
20407 async fn security_validation_rejects_zero_token_ttl() {
20408 let mut config = Config::default();
20409 config.security.otp.token_ttl_secs = 0;
20410
20411 let err = config
20412 .validate()
20413 .expect_err("expected ttl validation failure");
20414 assert!(err.to_string().contains("token_ttl_secs"));
20415 }
20416
20417 fn stdio_server(name: &str, command: &str) -> McpServerConfig {
20420 McpServerConfig {
20421 name: name.to_string(),
20422 transport: McpTransport::Stdio,
20423 command: command.to_string(),
20424 ..Default::default()
20425 }
20426 }
20427
20428 fn http_server(name: &str, url: &str) -> McpServerConfig {
20429 McpServerConfig {
20430 name: name.to_string(),
20431 transport: McpTransport::Http,
20432 url: Some(url.to_string()),
20433 ..Default::default()
20434 }
20435 }
20436
20437 fn sse_server(name: &str, url: &str) -> McpServerConfig {
20438 McpServerConfig {
20439 name: name.to_string(),
20440 transport: McpTransport::Sse,
20441 url: Some(url.to_string()),
20442 ..Default::default()
20443 }
20444 }
20445
20446 #[test]
20447 async fn validate_mcp_config_empty_servers_ok() {
20448 let cfg = McpConfig::default();
20449 assert!(validate_mcp_config(&cfg).is_ok());
20450 }
20451
20452 #[test]
20453 async fn validate_mcp_config_valid_stdio_ok() {
20454 let cfg = McpConfig {
20455 enabled: true,
20456 servers: vec![stdio_server("fs", "/usr/bin/mcp-fs")],
20457 ..Default::default()
20458 };
20459 assert!(validate_mcp_config(&cfg).is_ok());
20460 }
20461
20462 #[test]
20463 async fn validate_mcp_config_valid_http_ok() {
20464 let cfg = McpConfig {
20465 enabled: true,
20466 servers: vec![http_server("svc", "http://localhost:8080/mcp")],
20467 ..Default::default()
20468 };
20469 assert!(validate_mcp_config(&cfg).is_ok());
20470 }
20471
20472 #[test]
20473 async fn validate_mcp_config_valid_sse_ok() {
20474 let cfg = McpConfig {
20475 enabled: true,
20476 servers: vec![sse_server("svc", "https://example.com/events")],
20477 ..Default::default()
20478 };
20479 assert!(validate_mcp_config(&cfg).is_ok());
20480 }
20481
20482 #[test]
20483 async fn validate_mcp_config_rejects_empty_name() {
20484 let cfg = McpConfig {
20485 enabled: true,
20486 servers: vec![stdio_server("", "/usr/bin/tool")],
20487 ..Default::default()
20488 };
20489 let err = validate_mcp_config(&cfg).expect_err("empty name should fail");
20490 assert!(
20491 err.to_string().contains("name must not be empty"),
20492 "got: {err}"
20493 );
20494 }
20495
20496 #[test]
20497 async fn validate_mcp_config_rejects_whitespace_name() {
20498 let cfg = McpConfig {
20499 enabled: true,
20500 servers: vec![stdio_server(" ", "/usr/bin/tool")],
20501 ..Default::default()
20502 };
20503 let err = validate_mcp_config(&cfg).expect_err("whitespace name should fail");
20504 assert!(
20505 err.to_string().contains("name must not be empty"),
20506 "got: {err}"
20507 );
20508 }
20509
20510 #[test]
20511 async fn validate_mcp_config_rejects_duplicate_names() {
20512 let cfg = McpConfig {
20513 enabled: true,
20514 servers: vec![
20515 stdio_server("fs", "/usr/bin/mcp-a"),
20516 stdio_server("fs", "/usr/bin/mcp-b"),
20517 ],
20518 ..Default::default()
20519 };
20520 let err = validate_mcp_config(&cfg).expect_err("duplicate name should fail");
20521 assert!(err.to_string().contains("duplicate name"), "got: {err}");
20522 }
20523
20524 #[test]
20525 async fn validate_mcp_config_rejects_zero_timeout() {
20526 let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
20527 server.tool_timeout_secs = Some(0);
20528 let cfg = McpConfig {
20529 enabled: true,
20530 servers: vec![server],
20531 ..Default::default()
20532 };
20533 let err = validate_mcp_config(&cfg).expect_err("zero timeout should fail");
20534 assert!(err.to_string().contains("greater than 0"), "got: {err}");
20535 }
20536
20537 #[test]
20538 async fn validate_mcp_config_rejects_timeout_exceeding_max() {
20539 let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
20540 server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS + 1);
20541 let cfg = McpConfig {
20542 enabled: true,
20543 servers: vec![server],
20544 ..Default::default()
20545 };
20546 let err = validate_mcp_config(&cfg).expect_err("oversized timeout should fail");
20547 assert!(err.to_string().contains("exceeds max"), "got: {err}");
20548 }
20549
20550 #[test]
20551 async fn validate_mcp_config_allows_max_timeout_exactly() {
20552 let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
20553 server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS);
20554 let cfg = McpConfig {
20555 enabled: true,
20556 servers: vec![server],
20557 ..Default::default()
20558 };
20559 assert!(validate_mcp_config(&cfg).is_ok());
20560 }
20561
20562 #[test]
20563 async fn validate_mcp_config_rejects_stdio_with_empty_command() {
20564 let cfg = McpConfig {
20565 enabled: true,
20566 servers: vec![stdio_server("fs", "")],
20567 ..Default::default()
20568 };
20569 let err = validate_mcp_config(&cfg).expect_err("empty command should fail");
20570 assert!(
20571 err.to_string().contains("requires non-empty command"),
20572 "got: {err}"
20573 );
20574 }
20575
20576 #[test]
20577 async fn validate_mcp_config_rejects_http_without_url() {
20578 let cfg = McpConfig {
20579 enabled: true,
20580 servers: vec![McpServerConfig {
20581 name: "svc".to_string(),
20582 transport: McpTransport::Http,
20583 url: None,
20584 ..Default::default()
20585 }],
20586 ..Default::default()
20587 };
20588 let err = validate_mcp_config(&cfg).expect_err("http without url should fail");
20589 assert!(err.to_string().contains("requires url"), "got: {err}");
20590 }
20591
20592 #[test]
20593 async fn validate_mcp_config_rejects_sse_without_url() {
20594 let cfg = McpConfig {
20595 enabled: true,
20596 servers: vec![McpServerConfig {
20597 name: "svc".to_string(),
20598 transport: McpTransport::Sse,
20599 url: None,
20600 ..Default::default()
20601 }],
20602 ..Default::default()
20603 };
20604 let err = validate_mcp_config(&cfg).expect_err("sse without url should fail");
20605 assert!(err.to_string().contains("requires url"), "got: {err}");
20606 }
20607
20608 #[test]
20609 async fn validate_mcp_config_rejects_non_http_scheme() {
20610 let cfg = McpConfig {
20611 enabled: true,
20612 servers: vec![http_server("svc", "ftp://example.com/mcp")],
20613 ..Default::default()
20614 };
20615 let err = validate_mcp_config(&cfg).expect_err("non-http scheme should fail");
20616 assert!(err.to_string().contains("http/https"), "got: {err}");
20617 }
20618
20619 #[test]
20620 async fn validate_mcp_config_rejects_invalid_url() {
20621 let cfg = McpConfig {
20622 enabled: true,
20623 servers: vec![http_server("svc", "not a url at all !!!")],
20624 ..Default::default()
20625 };
20626 let err = validate_mcp_config(&cfg).expect_err("invalid url should fail");
20627 assert!(err.to_string().contains("valid URL"), "got: {err}");
20628 }
20629
20630 #[test]
20631 async fn mcp_config_default_disabled_with_empty_servers() {
20632 let cfg = McpConfig::default();
20633 assert!(!cfg.enabled);
20634 assert!(cfg.servers.is_empty());
20635 }
20636
20637 #[test]
20638 async fn mcp_transport_serde_roundtrip_lowercase() {
20639 let cases = [
20640 (McpTransport::Stdio, "\"stdio\""),
20641 (McpTransport::Http, "\"http\""),
20642 (McpTransport::Sse, "\"sse\""),
20643 ];
20644 for (variant, expected_json) in &cases {
20645 let serialized = serde_json::to_string(variant).expect("serialize");
20646 assert_eq!(&serialized, expected_json, "variant: {variant:?}");
20647 let deserialized: McpTransport =
20648 serde_json::from_str(expected_json).expect("deserialize");
20649 assert_eq!(&deserialized, variant);
20650 }
20651 }
20652
20653 #[tokio::test]
20654 async fn nevis_client_secret_encrypt_decrypt_roundtrip() {
20655 let dir = std::env::temp_dir().join(format!(
20656 "zeroclaw_test_nevis_secret_{}",
20657 uuid::Uuid::new_v4()
20658 ));
20659 fs::create_dir_all(&dir).await.unwrap();
20660
20661 let plaintext_secret = "nevis-test-client-secret-value";
20662
20663 let mut config = Config {
20664 data_dir: dir.join("workspace"),
20665 config_path: dir.join("config.toml"),
20666 ..Default::default()
20667 };
20668 config.security.nevis.client_secret = Some(plaintext_secret.into());
20669
20670 config.save().await.unwrap();
20672
20673 let raw_toml = tokio::fs::read_to_string(&config.config_path)
20675 .await
20676 .unwrap();
20677 assert!(
20678 !raw_toml.contains(plaintext_secret),
20679 "Saved TOML must not contain the plaintext client_secret"
20680 );
20681
20682 let stored: Config = toml::from_str(&raw_toml).unwrap();
20684 let stored_secret = stored.security.nevis.client_secret.as_ref().unwrap();
20685 assert!(
20686 crate::secrets::SecretStore::is_encrypted(stored_secret),
20687 "Stored client_secret must be marked as encrypted"
20688 );
20689
20690 let store = crate::secrets::SecretStore::new(&dir, true);
20692 assert_eq!(store.decrypt(stored_secret).unwrap(), plaintext_secret);
20693
20694 let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
20696 loaded.config_path = dir.join("config.toml");
20697 let load_store = crate::secrets::SecretStore::new(&dir, loaded.secrets.encrypt);
20698 loaded.decrypt_secrets(&load_store).unwrap();
20699 assert_eq!(
20700 loaded.security.nevis.client_secret.as_deref().unwrap(),
20701 plaintext_secret,
20702 "Loaded client_secret must match the original plaintext after decryption"
20703 );
20704
20705 let _ = fs::remove_dir_all(&dir).await;
20706 }
20707
20708 #[test]
20713 async fn nevis_config_validate_disabled_accepts_empty_fields() {
20714 let cfg = NevisConfig::default();
20715 assert!(!cfg.enabled);
20716 assert!(cfg.validate().is_ok());
20717 }
20718
20719 #[test]
20720 async fn nevis_config_validate_rejects_empty_instance_url() {
20721 let cfg = NevisConfig {
20722 enabled: true,
20723 instance_url: String::new(),
20724 client_id: "test-client".into(),
20725 ..NevisConfig::default()
20726 };
20727 let err = cfg.validate().unwrap_err();
20728 assert!(err.contains("instance_url"));
20729 }
20730
20731 #[test]
20732 async fn nevis_config_validate_rejects_empty_client_id() {
20733 let cfg = NevisConfig {
20734 enabled: true,
20735 instance_url: "https://nevis.example.com".into(),
20736 client_id: String::new(),
20737 ..NevisConfig::default()
20738 };
20739 let err = cfg.validate().unwrap_err();
20740 assert!(err.contains("client_id"));
20741 }
20742
20743 #[test]
20744 async fn nevis_config_validate_rejects_empty_realm() {
20745 let cfg = NevisConfig {
20746 enabled: true,
20747 instance_url: "https://nevis.example.com".into(),
20748 client_id: "test-client".into(),
20749 realm: String::new(),
20750 ..NevisConfig::default()
20751 };
20752 let err = cfg.validate().unwrap_err();
20753 assert!(err.contains("realm"));
20754 }
20755
20756 #[test]
20757 async fn nevis_config_validate_rejects_local_without_jwks() {
20758 let cfg = NevisConfig {
20759 enabled: true,
20760 instance_url: "https://nevis.example.com".into(),
20761 client_id: "test-client".into(),
20762 token_validation: "local".into(),
20763 jwks_url: None,
20764 ..NevisConfig::default()
20765 };
20766 let err = cfg.validate().unwrap_err();
20767 assert!(err.contains("jwks_url"));
20768 }
20769
20770 #[test]
20771 async fn nevis_config_validate_rejects_zero_session_timeout() {
20772 let cfg = NevisConfig {
20773 enabled: true,
20774 instance_url: "https://nevis.example.com".into(),
20775 client_id: "test-client".into(),
20776 token_validation: "remote".into(),
20777 session_timeout_secs: 0,
20778 ..NevisConfig::default()
20779 };
20780 let err = cfg.validate().unwrap_err();
20781 assert!(err.contains("session_timeout_secs"));
20782 }
20783
20784 #[test]
20785 async fn nevis_config_validate_accepts_valid_enabled_config() {
20786 let cfg = NevisConfig {
20787 enabled: true,
20788 instance_url: "https://nevis.example.com".into(),
20789 realm: "master".into(),
20790 client_id: "test-client".into(),
20791 token_validation: "remote".into(),
20792 session_timeout_secs: 3600,
20793 ..NevisConfig::default()
20794 };
20795 assert!(cfg.validate().is_ok());
20796 }
20797
20798 #[test]
20799 async fn nevis_config_validate_rejects_invalid_token_validation() {
20800 let cfg = NevisConfig {
20801 enabled: true,
20802 instance_url: "https://nevis.example.com".into(),
20803 realm: "master".into(),
20804 client_id: "test-client".into(),
20805 token_validation: "invalid_mode".into(),
20806 session_timeout_secs: 3600,
20807 ..NevisConfig::default()
20808 };
20809 let err = cfg.validate().unwrap_err();
20810 assert!(
20811 err.contains("invalid value 'invalid_mode'"),
20812 "Expected invalid token_validation error, got: {err}"
20813 );
20814 }
20815
20816 #[test]
20817 async fn nevis_config_debug_redacts_client_secret() {
20818 let cfg = NevisConfig {
20819 client_secret: Some("super-secret".into()),
20820 ..NevisConfig::default()
20821 };
20822 let debug_output = format!("{:?}", cfg);
20823 assert!(
20824 !debug_output.contains("super-secret"),
20825 "Debug output must not contain the raw client_secret"
20826 );
20827 assert!(
20828 debug_output.contains("[REDACTED]"),
20829 "Debug output must show [REDACTED] for client_secret"
20830 );
20831 }
20832
20833 #[test]
20834 async fn telegram_config_ack_reactions_false_deserializes() {
20835 let toml_str = r#"
20836 bot_token = "123:ABC"
20837 allowed_users = ["alice"]
20838 ack_reactions = false
20839 "#;
20840 let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
20841 assert_eq!(cfg.ack_reactions, Some(false));
20842 }
20843
20844 #[test]
20845 async fn telegram_config_ack_reactions_true_deserializes() {
20846 let toml_str = r#"
20847 bot_token = "123:ABC"
20848 allowed_users = ["alice"]
20849 ack_reactions = true
20850 "#;
20851 let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
20852 assert_eq!(cfg.ack_reactions, Some(true));
20853 }
20854
20855 #[test]
20856 async fn telegram_config_ack_reactions_missing_defaults_to_none() {
20857 let toml_str = r#"
20858 bot_token = "123:ABC"
20859 allowed_users = ["alice"]
20860 "#;
20861 let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
20862 assert_eq!(cfg.ack_reactions, None);
20863 }
20864
20865 #[test]
20866 async fn telegram_config_ack_reactions_channel_overrides_top_level() {
20867 let tg_toml = r#"
20868 bot_token = "123:ABC"
20869 allowed_users = ["alice"]
20870 ack_reactions = false
20871 "#;
20872 let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
20873 let top_level_ack = true;
20874 let effective = tg.ack_reactions.unwrap_or(top_level_ack);
20875 assert!(
20876 !effective,
20877 "channel-level false must override top-level true"
20878 );
20879 }
20880
20881 #[test]
20882 async fn telegram_config_ack_reactions_falls_back_to_top_level() {
20883 let tg_toml = r#"
20884 bot_token = "123:ABC"
20885 allowed_users = ["alice"]
20886 "#;
20887 let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
20888 let top_level_ack = false;
20889 let effective = tg.ack_reactions.unwrap_or(top_level_ack);
20890 assert!(
20891 !effective,
20892 "must fall back to top-level false when channel omits field"
20893 );
20894 }
20895
20896 #[test]
20897 async fn google_workspace_allowed_operations_deserialize_from_toml() {
20898 let toml_str = r#"
20899 enabled = true
20900
20901 [[allowed_operations]]
20902 service = "gmail"
20903 resource = "users"
20904 sub_resource = "drafts"
20905 methods = ["create", "update"]
20906 "#;
20907
20908 let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
20909 assert_eq!(cfg.allowed_operations.len(), 1);
20910 assert_eq!(cfg.allowed_operations[0].service, "gmail");
20911 assert_eq!(cfg.allowed_operations[0].resource, "users");
20912 assert_eq!(
20913 cfg.allowed_operations[0].sub_resource.as_deref(),
20914 Some("drafts")
20915 );
20916 assert_eq!(
20917 cfg.allowed_operations[0].methods,
20918 vec!["create".to_string(), "update".to_string()]
20919 );
20920 }
20921
20922 #[test]
20923 async fn google_workspace_allowed_operations_deserialize_without_sub_resource() {
20924 let toml_str = r#"
20925 enabled = true
20926
20927 [[allowed_operations]]
20928 service = "drive"
20929 resource = "files"
20930 methods = ["list", "get"]
20931 "#;
20932
20933 let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
20934 assert_eq!(cfg.allowed_operations[0].sub_resource, None);
20935 }
20936
20937 #[test]
20938 async fn config_validate_accepts_google_workspace_allowed_operations() {
20939 let mut cfg = Config::default();
20940 cfg.google_workspace.enabled = true;
20941 cfg.google_workspace.allowed_services = vec!["gmail".into()];
20942 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
20943 service: "gmail".into(),
20944 resource: "users".into(),
20945 sub_resource: Some("drafts".into()),
20946 methods: vec!["create".into(), "update".into()],
20947 }];
20948
20949 cfg.validate().unwrap();
20950 }
20951
20952 #[test]
20953 async fn config_validate_rejects_duplicate_google_workspace_allowed_operations() {
20954 let mut cfg = Config::default();
20955 cfg.google_workspace.enabled = true;
20956 cfg.google_workspace.allowed_services = vec!["gmail".into()];
20957 cfg.google_workspace.allowed_operations = vec![
20958 GoogleWorkspaceAllowedOperation {
20959 service: "gmail".into(),
20960 resource: "users".into(),
20961 sub_resource: Some("drafts".into()),
20962 methods: vec!["create".into()],
20963 },
20964 GoogleWorkspaceAllowedOperation {
20965 service: "gmail".into(),
20966 resource: "users".into(),
20967 sub_resource: Some("drafts".into()),
20968 methods: vec!["update".into()],
20969 },
20970 ];
20971
20972 let err = cfg.validate().unwrap_err().to_string();
20973 assert!(err.contains("duplicate service/resource/sub_resource entry"));
20974 }
20975
20976 #[test]
20977 async fn config_validate_rejects_operation_service_not_in_allowed_services() {
20978 let mut cfg = Config::default();
20979 cfg.google_workspace.enabled = true;
20980 cfg.google_workspace.allowed_services = vec!["gmail".into()];
20981 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
20982 service: "drive".into(), resource: "files".into(),
20984 sub_resource: None,
20985 methods: vec!["list".into()],
20986 }];
20987
20988 let err = cfg.validate().unwrap_err().to_string();
20989 assert!(
20990 err.contains("not in the effective allowed_services"),
20991 "expected not-in-allowed_services error, got: {err}"
20992 );
20993 }
20994
20995 #[test]
20996 async fn config_validate_accepts_default_service_when_allowed_services_empty() {
20997 let mut cfg = Config::default();
21000 cfg.google_workspace.enabled = true;
21001 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
21003 service: "drive".into(),
21004 resource: "files".into(),
21005 sub_resource: None,
21006 methods: vec!["list".into()],
21007 }];
21008
21009 assert!(cfg.validate().is_ok());
21010 }
21011
21012 #[test]
21013 async fn config_validate_rejects_unknown_service_when_allowed_services_empty() {
21014 let mut cfg = Config::default();
21018 cfg.google_workspace.enabled = true;
21019 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
21021 service: "not_a_real_service".into(),
21022 resource: "files".into(),
21023 sub_resource: None,
21024 methods: vec!["list".into()],
21025 }];
21026
21027 let err = cfg.validate().unwrap_err().to_string();
21028 assert!(
21029 err.contains("not in the effective allowed_services"),
21030 "expected effective-allowed_services error, got: {err}"
21031 );
21032 }
21033
21034 #[tokio::test]
21037 async fn ensure_bootstrap_files_creates_missing_files() {
21038 let tmp = tempfile::TempDir::new().unwrap();
21039 let ws = tmp.path().join("workspace");
21040 let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
21041
21042 ensure_bootstrap_files(&ws).await.unwrap();
21043
21044 let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
21045 let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
21046 .await
21047 .unwrap();
21048 assert!(soul.contains("SOUL.md"));
21049 assert!(identity.contains("IDENTITY.md"));
21050 }
21051
21052 #[tokio::test]
21053 async fn ensure_bootstrap_files_does_not_overwrite_existing() {
21054 let tmp = tempfile::TempDir::new().unwrap();
21055 let ws = tmp.path().join("workspace");
21056 let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
21057
21058 let custom = "# My custom SOUL";
21059 let _: () = tokio::fs::write(ws.join("SOUL.md"), custom).await.unwrap();
21060
21061 ensure_bootstrap_files(&ws).await.unwrap();
21062
21063 let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
21064 assert_eq!(
21065 soul, custom,
21066 "ensure_bootstrap_files must not overwrite existing files"
21067 );
21068
21069 let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
21071 .await
21072 .unwrap();
21073 assert!(identity.contains("IDENTITY.md"));
21074 }
21075
21076 #[test]
21079 async fn pacing_config_serde_defaults_match_manual_default() {
21080 let from_toml: PacingConfig = toml::from_str("").unwrap();
21083 let manual = PacingConfig::default();
21084
21085 assert_eq!(
21086 from_toml.loop_detection_enabled,
21087 manual.loop_detection_enabled
21088 );
21089 assert_eq!(
21090 from_toml.loop_detection_window_size,
21091 manual.loop_detection_window_size
21092 );
21093 assert_eq!(
21094 from_toml.loop_detection_max_repeats,
21095 manual.loop_detection_max_repeats
21096 );
21097
21098 assert!(from_toml.loop_detection_enabled, "default should be true");
21100 assert_eq!(from_toml.loop_detection_window_size, 20);
21101 assert_eq!(from_toml.loop_detection_max_repeats, 3);
21102 }
21103
21104 const DOCKER_CONFIG_TEMPLATE: &str = r#"
21109schema_version = 3
21110workspace_dir = "/zeroclaw-data/workspace"
21111config_path = "/zeroclaw-data/.zeroclaw/config.toml"
21112api_key = ""
21113default_model_provider = "openrouter"
21114default_model = "anthropic/claude-sonnet-4-20250514"
21115default_temperature = 0.7
21116
21117[gateway]
21118port = 42617
21119host = "[::]"
21120allow_public_bind = true
21121
21122[risk_profiles.default]
21123level = "supervised"
21124auto_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"]
21125"#;
21126
21127 #[test]
21128 async fn docker_config_template_is_parseable() {
21129 let cfg: Config = toml::from_str(DOCKER_CONFIG_TEMPLATE)
21130 .expect("Docker baked config.toml must be valid TOML that deserialises into Config");
21131
21132 let auto = &cfg
21133 .risk_profiles
21134 .get("default")
21135 .expect("Docker config must define [risk_profiles.default]")
21136 .auto_approve;
21137 for tool in &[
21138 "file_read",
21139 "file_write",
21140 "file_edit",
21141 "memory_recall",
21142 "memory_store",
21143 "web_search_tool",
21144 "web_fetch",
21145 "calculator",
21146 "glob_search",
21147 "content_search",
21148 "image_info",
21149 "weather",
21150 "git_operations",
21151 ] {
21152 assert!(
21153 auto.iter().any(|t| t == tool),
21154 "Docker config risk_profiles.default.auto_approve missing expected tool: {tool}"
21155 );
21156 }
21157 }
21158
21159 #[test]
21160 async fn cost_enforcement_config_defaults() {
21161 let config = CostEnforcementConfig::default();
21162 assert_eq!(config.mode, "warn");
21163 assert_eq!(config.route_down_model, None);
21164 assert_eq!(config.reserve_percent, 10);
21165 }
21166
21167 #[test]
21168 async fn cost_config_includes_enforcement() {
21169 let config = CostConfig::default();
21170 assert_eq!(config.enforcement.mode, "warn");
21171 assert_eq!(config.enforcement.reserve_percent, 10);
21172 }
21173
21174 #[test]
21177 async fn matrix_secret_fields_discovered() {
21178 let mx = MatrixConfig {
21179 enabled: true,
21180 homeserver: "https://m.org".into(),
21181 access_token: Some("tok".into()),
21182 user_id: None,
21183 device_id: None,
21184 allowed_rooms: vec!["!r:m".into()],
21185 interrupt_on_new_message: false,
21186 stream_mode: StreamMode::default(),
21187 draft_update_interval_ms: 1500,
21188 multi_message_delay_ms: 800,
21189 recovery_key: None,
21190 mention_only: false,
21191 password: None,
21192 approval_timeout_secs: 300,
21193 reply_in_thread: true,
21194 ack_reactions: Some(true),
21195 excluded_tools: vec![],
21196 default_target: None,
21197 };
21198 let fields = mx.secret_fields();
21199 assert_eq!(fields.len(), 3);
21200 assert_eq!(fields[0].name, "channels.matrix.access-token");
21201 assert_eq!(fields[0].category, "Channels");
21202 assert!(fields[0].is_set);
21203 assert_eq!(fields[1].name, "channels.matrix.recovery-key");
21204 assert!(!fields[1].is_set);
21205 assert_eq!(fields[2].name, "channels.matrix.password");
21206 assert!(!fields[2].is_set);
21207 }
21208
21209 #[test]
21210 async fn matrix_secret_fields_empty_not_set() {
21211 let mx = MatrixConfig {
21212 enabled: true,
21213 homeserver: "https://m.org".into(),
21214 access_token: None,
21215 user_id: None,
21216 device_id: None,
21217 allowed_rooms: vec!["!r:m".into()],
21218 interrupt_on_new_message: false,
21219 stream_mode: StreamMode::default(),
21220 draft_update_interval_ms: 1500,
21221 multi_message_delay_ms: 800,
21222 recovery_key: None,
21223 mention_only: false,
21224 password: None,
21225 approval_timeout_secs: 300,
21226 reply_in_thread: true,
21227 ack_reactions: Some(true),
21228 excluded_tools: vec![],
21229 default_target: None,
21230 };
21231 let fields = mx.secret_fields();
21232 assert!(!fields[0].is_set);
21233 }
21234
21235 #[test]
21236 async fn set_secret_updates_field() {
21237 let mut mx = MatrixConfig {
21238 enabled: true,
21239 homeserver: "https://m.org".into(),
21240 access_token: Some("old".into()),
21241 user_id: None,
21242 device_id: None,
21243 allowed_rooms: vec!["!r:m".into()],
21244 interrupt_on_new_message: false,
21245 stream_mode: StreamMode::default(),
21246 draft_update_interval_ms: 1500,
21247 multi_message_delay_ms: 800,
21248 recovery_key: None,
21249 mention_only: false,
21250 password: None,
21251 approval_timeout_secs: 300,
21252 reply_in_thread: true,
21253 ack_reactions: Some(true),
21254 excluded_tools: vec![],
21255 default_target: None,
21256 };
21257 mx.set_secret("channels.matrix.access-token", "new-token".into())
21258 .unwrap();
21259 assert_eq!(mx.access_token.as_deref(), Some("new-token"));
21260 }
21261
21262 #[test]
21263 async fn set_secret_unknown_name_fails() {
21264 let mut mx = MatrixConfig {
21265 enabled: true,
21266 homeserver: "https://m.org".into(),
21267 access_token: Some("tok".into()),
21268 user_id: None,
21269 device_id: None,
21270 allowed_rooms: vec!["!r:m".into()],
21271 interrupt_on_new_message: false,
21272 stream_mode: StreamMode::default(),
21273 draft_update_interval_ms: 1500,
21274 multi_message_delay_ms: 800,
21275 recovery_key: None,
21276 mention_only: false,
21277 password: None,
21278 approval_timeout_secs: 300,
21279 reply_in_thread: true,
21280 ack_reactions: Some(true),
21281 excluded_tools: vec![],
21282 default_target: None,
21283 };
21284 assert!(
21285 mx.set_secret("channels.matrix.nonexistent", "val".into())
21286 .is_err()
21287 );
21288 }
21289
21290 #[test]
21291 async fn config_tree_traversal_discovers_nested_secrets() {
21292 let mut config = Config::default();
21293 config
21295 .providers
21296 .models
21297 .ensure("anthropic", "default")
21298 .expect("anthropic typed slot")
21299 .api_key = Some("test-key".into());
21300 config.channels.matrix.insert(
21301 "default".to_string(),
21302 MatrixConfig {
21303 enabled: true,
21304 homeserver: "https://m.org".into(),
21305 access_token: Some("mx-tok".into()),
21306 user_id: None,
21307 device_id: None,
21308 allowed_rooms: vec!["!r:m".into()],
21309 interrupt_on_new_message: false,
21310 stream_mode: StreamMode::default(),
21311 draft_update_interval_ms: 1500,
21312 multi_message_delay_ms: 800,
21313 recovery_key: None,
21314 mention_only: false,
21315 password: None,
21316 approval_timeout_secs: 300,
21317 reply_in_thread: true,
21318 ack_reactions: Some(true),
21319 excluded_tools: vec![],
21320 default_target: None,
21321 },
21322 );
21323
21324 let fields = config.secret_fields();
21325 let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
21326 assert!(names.contains(&"channels.matrix.access-token"));
21327 assert!(names.contains(&"channels.matrix.recovery-key"));
21328 }
21329
21330 #[test]
21331 async fn config_set_secret_dispatches_to_child() {
21332 let mut config = Config::default();
21333 config.channels.matrix.insert(
21334 "default".to_string(),
21335 MatrixConfig {
21336 enabled: true,
21337 homeserver: "https://m.org".into(),
21338 access_token: Some("old".into()),
21339 user_id: None,
21340 device_id: None,
21341 allowed_rooms: vec!["!r:m".into()],
21342 interrupt_on_new_message: false,
21343 stream_mode: StreamMode::default(),
21344 draft_update_interval_ms: 1500,
21345 multi_message_delay_ms: 800,
21346 recovery_key: None,
21347 mention_only: false,
21348 password: None,
21349 approval_timeout_secs: 300,
21350 reply_in_thread: true,
21351 ack_reactions: Some(true),
21352 excluded_tools: vec![],
21353 default_target: None,
21354 },
21355 );
21356
21357 config
21358 .set_secret("channels.matrix.access-token", "new".into())
21359 .unwrap();
21360 assert_eq!(
21361 config
21362 .channels
21363 .matrix
21364 .get("default")
21365 .unwrap()
21366 .access_token
21367 .as_deref(),
21368 Some("new")
21369 );
21370 }
21371
21372 #[test]
21373 async fn config_set_secret_dispatches_to_matrix_child() {
21374 let mut config = Config::default();
21375 config.channels.matrix.insert(
21376 "default".to_string(),
21377 MatrixConfig {
21378 enabled: true,
21379 homeserver: "https://m.org".into(),
21380 access_token: Some("old".into()),
21381 user_id: None,
21382 device_id: None,
21383 allowed_rooms: vec!["!r:m".into()],
21384 interrupt_on_new_message: false,
21385 stream_mode: StreamMode::default(),
21386 draft_update_interval_ms: 1500,
21387 multi_message_delay_ms: 800,
21388 mention_only: false,
21389 recovery_key: None,
21390 password: None,
21391 approval_timeout_secs: 300,
21392 reply_in_thread: true,
21393 ack_reactions: Some(true),
21394 excluded_tools: vec![],
21395 default_target: None,
21396 },
21397 );
21398 config
21399 .set_secret("channels.matrix.access-token", "sk-test".into())
21400 .unwrap();
21401 assert_eq!(
21402 config
21403 .channels
21404 .matrix
21405 .get("default")
21406 .unwrap()
21407 .access_token
21408 .as_deref(),
21409 Some("sk-test")
21410 );
21411 }
21412
21413 #[test]
21414 async fn config_set_secret_unknown_fails() {
21415 let mut config = Config::default();
21416 assert!(
21417 config
21418 .set_secret("nonexistent.field", "val".into())
21419 .is_err()
21420 );
21421 }
21422
21423 #[test]
21424 async fn encrypt_decrypt_roundtrip_via_macro() {
21425 let dir = TempDir::new().unwrap();
21426 let store = crate::secrets::SecretStore::new(dir.path(), true);
21427
21428 let mut mx = MatrixConfig {
21429 enabled: true,
21430 homeserver: "https://m.org".into(),
21431 access_token: Some("plaintext-token".into()),
21432 user_id: None,
21433 device_id: None,
21434 allowed_rooms: vec!["!r:m".into()],
21435 interrupt_on_new_message: false,
21436 stream_mode: StreamMode::default(),
21437 draft_update_interval_ms: 1500,
21438 multi_message_delay_ms: 800,
21439 recovery_key: None,
21440 mention_only: false,
21441 password: None,
21442 approval_timeout_secs: 300,
21443 reply_in_thread: true,
21444 ack_reactions: Some(true),
21445 excluded_tools: vec![],
21446 default_target: None,
21447 };
21448
21449 mx.encrypt_secrets(&store).unwrap();
21451 assert!(crate::secrets::SecretStore::is_encrypted(
21452 mx.access_token.as_deref().unwrap_or_default()
21453 ));
21454 assert_ne!(mx.access_token.as_deref(), Some("plaintext-token"));
21455
21456 mx.decrypt_secrets(&store).unwrap();
21458 assert_eq!(mx.access_token.as_deref(), Some("plaintext-token"));
21459 }
21460
21461 #[test]
21462 async fn encrypt_skips_already_encrypted() {
21463 let dir = TempDir::new().unwrap();
21464 let store = crate::secrets::SecretStore::new(dir.path(), true);
21465
21466 let mut mx = MatrixConfig {
21467 enabled: true,
21468 homeserver: "https://m.org".into(),
21469 access_token: Some("plaintext-token".into()),
21470 user_id: None,
21471 device_id: None,
21472 allowed_rooms: vec!["!r:m".into()],
21473 interrupt_on_new_message: false,
21474 stream_mode: StreamMode::default(),
21475 draft_update_interval_ms: 1500,
21476 multi_message_delay_ms: 800,
21477 recovery_key: None,
21478 mention_only: false,
21479 password: None,
21480 approval_timeout_secs: 300,
21481 reply_in_thread: true,
21482 ack_reactions: Some(true),
21483 excluded_tools: vec![],
21484 default_target: None,
21485 };
21486
21487 mx.encrypt_secrets(&store).unwrap();
21488 let first_encrypted = mx.access_token.clone();
21489
21490 mx.encrypt_secrets(&store).unwrap();
21492 assert_eq!(mx.access_token, first_encrypted);
21493 }
21494
21495 #[test]
21496 async fn encrypt_no_op_on_disabled_store() {
21497 let dir = TempDir::new().unwrap();
21498 let store = crate::secrets::SecretStore::new(dir.path(), false);
21499
21500 let mut mx = MatrixConfig {
21501 enabled: true,
21502 homeserver: "https://m.org".into(),
21503 access_token: Some("plaintext-token".into()),
21504 user_id: None,
21505 device_id: None,
21506 allowed_rooms: vec!["!r:m".into()],
21507 interrupt_on_new_message: false,
21508 stream_mode: StreamMode::default(),
21509 draft_update_interval_ms: 1500,
21510 multi_message_delay_ms: 800,
21511 recovery_key: None,
21512 mention_only: false,
21513 password: None,
21514 approval_timeout_secs: 300,
21515 reply_in_thread: true,
21516 ack_reactions: Some(true),
21517 excluded_tools: vec![],
21518 default_target: None,
21519 };
21520
21521 mx.encrypt_secrets(&store).unwrap();
21522 assert_eq!(mx.access_token.as_deref(), Some("plaintext-token"));
21524 }
21525
21526 fn test_matrix_config() -> MatrixConfig {
21529 MatrixConfig {
21530 enabled: true,
21531 homeserver: "https://m.org".into(),
21532 access_token: Some("tok".into()),
21533 user_id: Some("@bot:m.org".into()),
21534 device_id: None,
21535 allowed_rooms: vec!["!r:m".into()],
21536 interrupt_on_new_message: false,
21537 stream_mode: StreamMode::default(),
21538 draft_update_interval_ms: 1500,
21539 multi_message_delay_ms: 800,
21540 recovery_key: None,
21541 mention_only: false,
21542 password: None,
21543 approval_timeout_secs: 300,
21544 reply_in_thread: true,
21545 ack_reactions: Some(true),
21546 excluded_tools: vec![],
21547 default_target: None,
21548 }
21549 }
21550
21551 #[test]
21552 async fn prop_fields_returns_typed_entries() {
21553 let mx = test_matrix_config();
21554 let fields = mx.prop_fields();
21555 let by_name: std::collections::HashMap<&str, &crate::traits::PropFieldInfo> =
21556 fields.iter().map(|f| (f.name.as_str(), f)).collect();
21557
21558 let homeserver = by_name["channels.matrix.homeserver"];
21560 assert_eq!(homeserver.type_hint, "String");
21561 assert_eq!(homeserver.display_value, "https://m.org");
21562
21563 let user_id = by_name["channels.matrix.user-id"];
21565 assert_eq!(user_id.type_hint, "Option<String>");
21566 assert_eq!(user_id.display_value, "@bot:m.org");
21567
21568 let device_id = by_name["channels.matrix.device-id"];
21570 assert_eq!(device_id.display_value, "<unset>");
21571
21572 let interval = by_name["channels.matrix.draft-update-interval-ms"];
21574 assert_eq!(interval.type_hint, "u64");
21575 assert_eq!(interval.display_value, "1500");
21576
21577 let stream = by_name["channels.matrix.stream-mode"];
21579 assert!(stream.is_enum());
21580 assert!(stream.enum_variants.is_some());
21581
21582 let token = by_name["channels.matrix.access-token"];
21584 assert!(token.is_secret);
21585 assert_eq!(token.display_value, "****");
21586
21587 for field in &fields {
21589 assert_eq!(field.category, "Channels");
21590 }
21591 }
21592
21593 #[test]
21594 async fn get_prop_returns_values_by_path() {
21595 let mx = test_matrix_config();
21596
21597 assert_eq!(
21598 mx.get_prop("channels.matrix.homeserver").unwrap(),
21599 "https://m.org"
21600 );
21601 assert_eq!(
21602 mx.get_prop("channels.matrix.draft-update-interval-ms")
21603 .unwrap(),
21604 "1500"
21605 );
21606 assert_eq!(
21607 mx.get_prop("channels.matrix.user-id").unwrap(),
21608 "@bot:m.org"
21609 );
21610 assert_eq!(mx.get_prop("channels.matrix.device-id").unwrap(), "<unset>");
21611 assert_eq!(
21613 mx.get_prop("channels.matrix.access-token").unwrap(),
21614 "**** (encrypted)"
21615 );
21616 }
21617
21618 #[test]
21619 async fn get_prop_unknown_path_fails() {
21620 let mx = test_matrix_config();
21621 assert!(mx.get_prop("channels.matrix.nonexistent").is_err());
21622 }
21623
21624 #[test]
21625 async fn set_prop_string() {
21626 let mut mx = test_matrix_config();
21627 mx.set_prop("channels.matrix.homeserver", "https://new.org")
21628 .unwrap();
21629 assert_eq!(mx.homeserver, "https://new.org");
21630 }
21631
21632 #[test]
21633 async fn set_prop_bool() {
21634 let mut mx = test_matrix_config();
21635 mx.set_prop("channels.matrix.interrupt-on-new-message", "true")
21636 .unwrap();
21637 assert!(mx.interrupt_on_new_message);
21638 }
21639
21640 #[test]
21641 async fn set_prop_bool_rejects_invalid() {
21642 let mut mx = test_matrix_config();
21643 let err = mx
21644 .set_prop("channels.matrix.interrupt-on-new-message", "yes")
21645 .unwrap_err();
21646 assert!(err.to_string().contains("bool"));
21647 }
21648
21649 #[test]
21650 async fn set_prop_u64() {
21651 let mut mx = test_matrix_config();
21652 mx.set_prop("channels.matrix.draft-update-interval-ms", "3000")
21653 .unwrap();
21654 assert_eq!(mx.draft_update_interval_ms, 3000);
21655 }
21656
21657 #[test]
21658 async fn set_prop_u64_rejects_invalid() {
21659 let mut mx = test_matrix_config();
21660 assert!(
21661 mx.set_prop("channels.matrix.draft-update-interval-ms", "abc")
21662 .is_err()
21663 );
21664 }
21665
21666 #[test]
21667 async fn set_prop_option_string_set_and_clear() {
21668 let mut mx = test_matrix_config();
21669 mx.set_prop("channels.matrix.user-id", "@new:m.org")
21670 .unwrap();
21671 assert_eq!(mx.user_id.as_deref(), Some("@new:m.org"));
21672
21673 mx.set_prop("channels.matrix.user-id", "").unwrap();
21675 assert!(mx.user_id.is_none());
21676 }
21677
21678 #[test]
21679 async fn set_prop_enum() {
21680 let mut mx = test_matrix_config();
21681 mx.set_prop("channels.matrix.stream-mode", "partial")
21682 .unwrap();
21683 assert_eq!(mx.stream_mode, StreamMode::Partial);
21684
21685 mx.set_prop("channels.matrix.stream-mode", "multi_message")
21686 .unwrap();
21687 assert_eq!(mx.stream_mode, StreamMode::MultiMessage);
21688 }
21689
21690 #[test]
21691 async fn set_prop_enum_rejects_invalid() {
21692 let mut mx = test_matrix_config();
21693 let err = mx
21694 .set_prop("channels.matrix.stream-mode", "invalid")
21695 .unwrap_err();
21696 assert!(err.to_string().contains("expected one of"));
21697 }
21698
21699 #[test]
21700 async fn set_prop_unknown_path_fails() {
21701 let mut mx = test_matrix_config();
21702 assert!(mx.set_prop("channels.matrix.nonexistent", "val").is_err());
21703 }
21704
21705 #[test]
21706 async fn prop_is_secret_static_check() {
21707 assert!(MatrixConfig::prop_is_secret("channels.matrix.access-token"));
21708 assert!(MatrixConfig::prop_is_secret("channels.matrix.recovery-key"));
21709 assert!(!MatrixConfig::prop_is_secret("channels.matrix.homeserver"));
21710 assert!(!MatrixConfig::prop_is_secret(
21711 "channels.matrix.interrupt-on-new-message"
21712 ));
21713 }
21714
21715 #[test]
21716 async fn apply_env_overrides_rejects_schema_version() {
21717 let _env_guard = env_override_lock().await;
21718 unsafe { std::env::set_var("ZEROCLAW_schema_version", "99") };
21720 let mut config = Config::default();
21721 let result = crate::env_overrides::apply_env_overrides(&mut config);
21722 unsafe { std::env::remove_var("ZEROCLAW_schema_version") };
21724
21725 let err = result.expect_err("schema_version override must be rejected");
21726 let msg = format!("{err:#}");
21727 assert!(
21728 msg.contains("schema_version") && msg.contains("not overridable"),
21729 "error must name the path and the reason: {msg}",
21730 );
21731 assert_eq!(
21733 config.schema_version,
21734 crate::migration::CURRENT_SCHEMA_VERSION
21735 );
21736 }
21737
21738 #[test]
21739 async fn prop_is_env_overridden_reflects_env_overridden_paths() {
21740 let mut cfg = Config::default();
21742 assert!(!cfg.prop_is_env_overridden("channels.matrix.homeserver"));
21743 assert!(!cfg.prop_is_env_overridden("gateway.request-timeout-secs"));
21744
21745 cfg.env_overridden_paths = std::collections::HashSet::from([
21748 "channels.matrix.homeserver".to_string(),
21749 "gateway.request-timeout-secs".to_string(),
21750 ]);
21751
21752 assert!(cfg.prop_is_env_overridden("channels.matrix.homeserver"));
21754 assert!(cfg.prop_is_env_overridden("gateway.request-timeout-secs"));
21755 assert!(!cfg.prop_is_env_overridden("channels.matrix.access-token"));
21756 assert!(!cfg.prop_is_env_overridden("gateway.host"));
21757 assert!(!cfg.prop_is_env_overridden(""));
21759 assert!(!cfg.prop_is_env_overridden("does.not.exist"));
21760 }
21761
21762 #[test]
21763 async fn prop_is_secret_routes_through_hashmap_keyed_paths() {
21764 assert!(Config::prop_is_secret(
21772 "providers.models.openrouter.default.api-key"
21773 ));
21774 assert!(Config::prop_is_secret(
21775 "providers.models.anthropic.default.api-key"
21776 ));
21777 assert!(!Config::prop_is_secret(
21778 "providers.models.openrouter.default.endpoint"
21779 ));
21780 assert!(!Config::prop_is_secret(
21781 "providers.models.openrouter.default.context-window"
21782 ));
21783 }
21784
21785 #[test]
21786 async fn typed_custom_slot_round_trips_uri_through_save_and_load() {
21787 let dir = TempDir::new().unwrap();
21792 let mut config = Config {
21793 config_path: dir.path().join("config.toml"),
21794 data_dir: dir.path().join("workspace"),
21795 ..Default::default()
21796 };
21797 let alias = "default";
21798 config
21799 .providers
21800 .models
21801 .ensure("custom", alias)
21802 .expect("custom typed slot");
21803
21804 let prefix = format!("providers.models.custom.{alias}");
21805 let api_key_path = format!("{prefix}.api-key");
21806 let uri_path = format!("{prefix}.uri");
21807 let model_path = format!("{prefix}.model");
21808 let temperature_path = format!("{prefix}.temperature");
21809
21810 assert!(
21811 Config::prop_is_secret(&api_key_path),
21812 "typed custom-slot api-key must route through the secret marker",
21813 );
21814
21815 config.set_prop(&api_key_path, "sk-test-custom").unwrap();
21816 config
21817 .set_prop(&uri_path, "https://api.example.invalid/v1")
21818 .unwrap();
21819 config.set_prop(&model_path, "local-large").unwrap();
21820 config.set_prop(&temperature_path, "0.2").unwrap();
21821
21822 let provider = config
21823 .providers
21824 .models
21825 .find("custom", alias)
21826 .expect("custom typed slot entry must be present");
21827 assert_eq!(provider.api_key.as_deref(), Some("sk-test-custom"));
21828 assert_eq!(
21829 provider.uri.as_deref(),
21830 Some("https://api.example.invalid/v1")
21831 );
21832 assert_eq!(provider.model.as_deref(), Some("local-large"));
21833 assert_eq!(provider.temperature, Some(0.2));
21834
21835 assert_eq!(config.get_prop(&api_key_path).unwrap(), "**** (encrypted)");
21836 assert_eq!(
21837 config.get_prop(&uri_path).unwrap(),
21838 "https://api.example.invalid/v1"
21839 );
21840
21841 config.save().await.unwrap();
21842 let raw_toml = tokio::fs::read_to_string(&config.config_path)
21843 .await
21844 .unwrap();
21845 assert!(
21846 raw_toml.contains("[providers.models.custom.default]"),
21847 "saved TOML should write under the typed custom slot",
21848 );
21849 assert!(
21850 !raw_toml.contains("sk-test-custom"),
21851 "saved TOML must not contain the plaintext custom provider API key",
21852 );
21853
21854 let mut loaded: Config = crate::migration::migrate_to_current(&raw_toml).unwrap();
21855 loaded.config_path = config.config_path.clone();
21856 loaded.data_dir = config.data_dir.clone();
21857 let store = crate::secrets::SecretStore::new(dir.path(), loaded.secrets.encrypt);
21858 loaded.decrypt_secrets(&store).unwrap();
21859 let loaded_provider = loaded
21860 .providers
21861 .models
21862 .find("custom", alias)
21863 .expect("typed custom slot entry must round-trip through save/load");
21864 assert_eq!(loaded_provider.api_key.as_deref(), Some("sk-test-custom"));
21865 assert_eq!(
21866 loaded_provider.uri.as_deref(),
21867 Some("https://api.example.invalid/v1")
21868 );
21869 assert_eq!(loaded_provider.model.as_deref(), Some("local-large"));
21870 assert_eq!(loaded_provider.temperature, Some(0.2));
21871 }
21872
21873 #[test]
21874 async fn env_override_save_cycle_preserves_on_disk_secret() {
21875 let dir = TempDir::new().unwrap();
21892 let mut config = Config {
21893 config_path: dir.path().join("config.toml"),
21894 data_dir: dir.path().join("workspace"),
21895 ..Default::default()
21896 };
21897 let original_secret = "sk-ant-real-on-disk-credential";
21898 let api_key_path = "providers.models.anthropic.default.api-key";
21899 config
21900 .providers
21901 .models
21902 .ensure("anthropic", "default")
21903 .expect("typed slot");
21904 config.set_prop(api_key_path, original_secret).unwrap();
21905
21906 config.save().await.unwrap();
21908
21909 let raw = tokio::fs::read_to_string(&config.config_path)
21911 .await
21912 .unwrap();
21913 let mut reloaded: Config = crate::migration::migrate_to_current(&raw).unwrap();
21914 reloaded.config_path = config.config_path.clone();
21915 reloaded.data_dir = config.data_dir.clone();
21916 let store = crate::secrets::SecretStore::new(dir.path(), reloaded.secrets.encrypt);
21917 reloaded.decrypt_secrets(&store).unwrap();
21918 assert_eq!(
21919 reloaded
21920 .providers
21921 .models
21922 .anthropic
21923 .get("default")
21924 .and_then(|c| c.base.api_key.as_deref()),
21925 Some(original_secret),
21926 "baseline: original secret round-trips through one save/reload cycle",
21927 );
21928
21929 let env_value = "sk-ant-from-env-DIFFERENT";
21935 reloaded.env_overridden_paths = std::collections::HashSet::from([api_key_path.to_string()]);
21936 reloaded.pre_override_snapshots = std::collections::HashMap::from([(
21937 api_key_path.to_string(),
21938 original_secret.to_string(),
21939 )]);
21940 reloaded.set_prop(api_key_path, env_value).unwrap();
21941
21942 reloaded.save().await.unwrap();
21945
21946 let raw_after = tokio::fs::read_to_string(&reloaded.config_path)
21950 .await
21951 .unwrap();
21952 assert!(
21953 !raw_after.contains(env_value),
21954 "env-injected value must never reach disk: {raw_after}",
21955 );
21956 assert!(
21957 !raw_after.contains("**** (encrypted)"),
21958 "display mask must never be persisted as a secret value: {raw_after}",
21959 );
21960
21961 let mut after: Config = crate::migration::migrate_to_current(&raw_after).unwrap();
21962 after.config_path = reloaded.config_path.clone();
21963 after.data_dir = reloaded.data_dir.clone();
21964 let store2 = crate::secrets::SecretStore::new(dir.path(), after.secrets.encrypt);
21965 after.decrypt_secrets(&store2).unwrap();
21966 assert_eq!(
21967 after
21968 .providers
21969 .models
21970 .anthropic
21971 .get("default")
21972 .and_then(|c| c.base.api_key.as_deref()),
21973 Some(original_secret),
21974 "original on-disk secret must survive an env-override + save cycle",
21975 );
21976 }
21977
21978 #[test]
21979 async fn enum_variants_callback_returns_values() {
21980 let mx = test_matrix_config();
21981 let fields = mx.prop_fields();
21982 let stream_field = fields
21983 .iter()
21984 .find(|f| f.name == "channels.matrix.stream-mode")
21985 .unwrap();
21986 let variants = (stream_field.enum_variants.unwrap())();
21987 assert!(variants.contains(&"off".to_string()));
21988 assert!(variants.contains(&"partial".to_string()));
21989 assert!(variants.contains(&"multi_message".to_string()));
21990 }
21991
21992 #[test]
21993 async fn map_key_sections_discovers_per_family_provider_slots() {
21994 let sections = Config::map_key_sections();
21999 let anthropic = sections
22000 .iter()
22001 .find(|s| s.path == "providers.models.anthropic")
22002 .expect("providers.models.anthropic must be discoverable as a map-keyed section");
22003 assert_eq!(anthropic.kind, crate::traits::MapKeyKind::Map);
22004 assert_eq!(anthropic.value_type, "AnthropicModelProviderConfig");
22005
22006 assert!(
22008 sections.iter().any(|s| s.path == "agents"),
22009 "agents map should be discoverable"
22010 );
22011
22012 let mcp_servers = sections
22019 .iter()
22020 .find(|s| s.path == "mcp.servers")
22021 .expect("mcp.servers must be discoverable as a list-shaped section");
22022 assert_eq!(mcp_servers.kind, crate::traits::MapKeyKind::List);
22023 assert_eq!(mcp_servers.value_type, "McpServerConfig");
22024 }
22025
22026 #[test]
22027 async fn create_map_key_inserts_default_mcp_server() {
22028 let mut config = Config::default();
22032 assert!(config.mcp.servers.is_empty());
22033
22034 let created = config
22035 .create_map_key("mcp.servers", "github")
22036 .expect("mcp.servers should accept new list entries");
22037 assert!(created, "first add should report created=true");
22038 assert_eq!(config.mcp.servers.len(), 1);
22039 assert_eq!(
22040 config.mcp.servers[0].name, "github",
22041 "new entry must carry the supplied key as its name field"
22042 );
22043 }
22044
22045 #[test]
22046 async fn create_map_key_inserts_default_alias_under_typed_family() {
22047 let mut config = Config::default();
22050 assert!(
22051 !config
22052 .providers
22053 .models
22054 .contains_model_provider_type("anthropic")
22055 );
22056
22057 let created = config
22058 .create_map_key("providers.models.anthropic", "default")
22059 .expect("typed family slot should accept a new alias");
22060 assert!(created, "first add should report created=true");
22061 assert!(
22062 config
22063 .providers
22064 .models
22065 .find("anthropic", "default")
22066 .is_some(),
22067 "the new alias must show up under the typed family slot",
22068 );
22069
22070 let again = config
22072 .create_map_key("providers.models.anthropic", "default")
22073 .expect("second add still resolves the section");
22074 assert!(!again, "duplicate add should report created=false");
22075 }
22076
22077 #[test]
22078 async fn create_map_key_rejects_unknown_section() {
22079 let mut config = Config::default();
22080 let err = config
22081 .create_map_key("not.a.real.section", "anything")
22082 .expect_err("unknown section path should error");
22083 assert!(err.contains("not.a.real.section"));
22084 }
22085
22086 #[test]
22087 async fn init_defaults_instantiates_none_sections() {
22088 let mut config = Config::default();
22089 assert!(config.channels.matrix.is_empty());
22090
22091 config
22094 .create_map_key("channels.matrix", "default")
22095 .expect("create_map_key should insert a default matrix entry");
22096 assert!(
22097 config.channels.matrix.contains_key("default"),
22098 "create_map_key must add the 'default' alias"
22099 );
22100
22101 let initialized = config.init_defaults(Some("channels.matrix"));
22103 assert!(
22104 !initialized.contains(&"channels.matrix"),
22105 "init_defaults should not report channels.matrix when entry already exists"
22106 );
22107 }
22108
22109 #[test]
22110 async fn deserialized_matrix_set_prop_round_trips_vec_string() {
22111 let toml_src = r#"
22115schema_version = 3
22116
22117[channels.matrix.default]
22118enabled = false
22119homeserver = ""
22120access_token = ""
22121allowed_rooms = []
22122allowed_users = []
22123"#;
22124 let mut config: Config = toml::from_str(toml_src).expect("parse toml");
22125 assert!(
22126 config.channels.matrix.contains_key("default"),
22127 "matrix must have a 'default' alias after deserialize"
22128 );
22129
22130 config
22131 .set_prop(
22132 "channels.matrix.default.allowed-rooms",
22133 r#"["alice","bob"]"#,
22134 )
22135 .expect("set_prop should succeed against deserialized matrix");
22136 assert_eq!(
22137 config.channels.matrix.get("default").unwrap().allowed_rooms,
22138 vec!["alice".to_string(), "bob".to_string()],
22139 );
22140 }
22141
22142 #[test]
22143 async fn init_defaults_then_set_prop_round_trips_vec_string() {
22144 let mut config = Config::default();
22150 config
22151 .create_map_key("channels.matrix", "default")
22152 .expect("create_map_key should insert a default matrix entry");
22153 assert!(config.channels.matrix.contains_key("default"));
22154
22155 let has_field = config
22157 .prop_fields()
22158 .iter()
22159 .any(|f| f.name == "channels.matrix.default.allowed-rooms");
22160 assert!(
22161 has_field,
22162 "channels.matrix.default.allowed-rooms must appear in prop_fields after init"
22163 );
22164
22165 config
22167 .set_prop(
22168 "channels.matrix.default.allowed-rooms",
22169 r#"["alice","bob"]"#,
22170 )
22171 .expect("set_prop should accept JSON-array string for Vec<String>");
22172 assert_eq!(
22173 config.channels.matrix.get("default").unwrap().allowed_rooms,
22174 vec!["alice".to_string(), "bob".to_string()],
22175 );
22176 }
22177
22178 #[test]
22179 async fn mcp_servers_addable_via_create_map_key_and_per_entry_props() {
22180 let mut config = Config::default();
22192
22193 let sections = Config::map_key_sections();
22195 assert!(
22196 sections
22197 .iter()
22198 .any(|s| s.path == "mcp.servers" && s.kind == crate::traits::MapKeyKind::List),
22199 "mcp.servers should surface as a List section in map_key_sections()"
22200 );
22201
22202 config
22205 .create_map_key("mcp.servers", "fs")
22206 .expect("mcp.servers should accept new list entries via create_map_key");
22207 assert_eq!(config.mcp.servers.len(), 1);
22208 assert_eq!(config.mcp.servers[0].name, "fs");
22209
22210 }
22218
22219 #[test]
22220 async fn init_defaults_skips_already_set() {
22221 let mut config = Config::default();
22222 config
22223 .channels
22224 .matrix
22225 .insert("default".to_string(), test_matrix_config());
22226
22227 let initialized = config.init_defaults(Some("channels.matrix"));
22228 assert!(!initialized.contains(&"channels.matrix"));
22230 assert_eq!(
22232 config.channels.matrix.get("default").unwrap().homeserver,
22233 "https://m.org"
22234 );
22235 }
22236
22237 #[test]
22238 async fn nested_get_set_prop_traverses_config_tree() {
22239 let mut config = Config::default();
22240 config
22241 .channels
22242 .matrix
22243 .insert("default".to_string(), test_matrix_config());
22244
22245 assert_eq!(
22247 config
22248 .get_prop("channels.matrix.default.homeserver")
22249 .unwrap(),
22250 "https://m.org"
22251 );
22252
22253 config
22255 .set_prop("channels.matrix.default.homeserver", "https://new.org")
22256 .unwrap();
22257 assert_eq!(
22258 config.channels.matrix.get("default").unwrap().homeserver,
22259 "https://new.org"
22260 );
22261 }
22262
22263 #[test]
22264 async fn hashmap_nested_encrypt_decrypt_traverses_values() {
22265 let dir = TempDir::new().unwrap();
22266 let store = crate::secrets::SecretStore::new(dir.path(), true);
22267
22268 let mut config = Config::default();
22269 config.providers.models.openrouter.insert(
22270 "test".into(),
22271 crate::schema::OpenRouterModelProviderConfig {
22272 base: ModelProviderConfig {
22273 api_key: Some("secret-key".into()),
22274 ..Default::default()
22275 },
22276 },
22277 );
22278
22279 config.encrypt_secrets(&store).unwrap();
22280 let encrypted_key = config
22281 .providers
22282 .models
22283 .find("openrouter", "test")
22284 .expect("entry exists")
22285 .api_key
22286 .as_ref()
22287 .unwrap();
22288 assert!(crate::secrets::SecretStore::is_encrypted(encrypted_key));
22289
22290 config.decrypt_secrets(&store).unwrap();
22291 assert_eq!(
22292 config
22293 .providers
22294 .models
22295 .find("openrouter", "test")
22296 .expect("entry exists")
22297 .api_key
22298 .as_deref(),
22299 Some("secret-key")
22300 );
22301 }
22302
22303 #[test]
22304 async fn vec_secret_encrypt_decrypt_traverses_elements() {
22305 let dir = TempDir::new().unwrap();
22306 let store = crate::secrets::SecretStore::new(dir.path(), true);
22307
22308 let mut config = Config::default();
22309 config.gateway.paired_tokens = vec!["token-a".into(), "token-b".into()];
22310
22311 config.encrypt_secrets(&store).unwrap();
22312 for token in &config.gateway.paired_tokens {
22313 assert!(crate::secrets::SecretStore::is_encrypted(token));
22314 }
22315
22316 config.decrypt_secrets(&store).unwrap();
22317 assert_eq!(config.gateway.paired_tokens, vec!["token-a", "token-b"]);
22318 }
22319
22320 #[test]
22323 async fn every_prop_is_gettable_and_settable() {
22324 let mut config = Config::default();
22325 config.init_defaults(None);
22327
22328 let fields = config.prop_fields();
22329 assert!(
22330 fields.len() > 50,
22331 "Expected 50+ props, got {} — macro may be skipping fields",
22332 fields.len()
22333 );
22334
22335 for field in &fields {
22336 let get_result = config.get_prop(&field.name);
22338 assert!(
22339 get_result.is_ok(),
22340 "get_prop failed for '{}': {}",
22341 field.name,
22342 get_result.unwrap_err()
22343 );
22344
22345 if field.is_secret || field.is_enum() || field.display_value == "<unset>" {
22348 continue;
22349 }
22350
22351 let set_result = config.set_prop(&field.name, &field.display_value);
22352 assert!(
22353 set_result.is_ok(),
22354 "set_prop failed for '{}' with value '{}': {}",
22355 field.name,
22356 field.display_value,
22357 set_result.unwrap_err()
22358 );
22359
22360 let after = config.get_prop(&field.name).unwrap();
22362 assert_eq!(
22363 after, field.display_value,
22364 "round-trip mismatch for '{}': set '{}', got '{}'",
22365 field.name, field.display_value, after
22366 );
22367 }
22368 }
22369
22370 #[test]
22382 async fn every_prop_field_path_is_reachable_via_get_prop() {
22383 let mut config = Config::default();
22384 config.init_defaults(None);
22385 for field in config.prop_fields() {
22386 let result = config.get_prop(&field.name);
22387 assert!(
22388 result.is_ok(),
22389 "get_prop('{}') failed: {} \u{2014} prop_fields() advertises a path \
22390 that the CLI / gateway / TUI all expect to be readable. \
22391 Either the macro emits the path but routing is missing, \
22392 or the field shouldn't be in prop_fields().",
22393 field.name,
22394 result.unwrap_err()
22395 );
22396 }
22397 }
22398
22399 #[test]
22400 async fn onboard_state_prop_path_uses_top_level_kebab_field_name() {
22401 let mut config = Config::default();
22402
22403 config
22404 .set_prop("onboard-state.completed-sections", "agents")
22405 .expect("onboard state marker path should be writable");
22406 assert_eq!(
22407 config
22408 .get_prop("onboard-state.completed-sections")
22409 .expect("onboard state marker path should be readable"),
22410 "[\"agents\"]"
22411 );
22412 }
22413
22414 #[test]
22415 async fn per_agent_nested_prop_fields_use_agent_alias_paths() {
22416 let mut config = Config::default();
22417 config
22418 .agents
22419 .insert("bob".to_string(), AliasedAgentConfig::default());
22420
22421 let fields = config.prop_fields();
22422 assert!(
22423 fields
22424 .iter()
22425 .any(|field| field.name == "agents.bob.history-pruning.enabled"),
22426 "agent nested history-pruning fields should be emitted under the agent alias"
22427 );
22428 assert!(
22429 fields
22430 .iter()
22431 .any(|field| field.name == "agents.bob.precheck.enabled"),
22432 "agent nested precheck fields should be emitted under the agent alias"
22433 );
22434 assert!(
22435 !fields
22436 .iter()
22437 .any(|field| field.name.starts_with("agents.bob.agent.history-pruning")),
22438 "agent nested fields must not leak the legacy global agent prefix"
22439 );
22440 assert!(
22441 !fields
22442 .iter()
22443 .any(|field| field.name.starts_with("agents.bob.agent.precheck")),
22444 "agent nested precheck fields must not leak the legacy global agent prefix"
22445 );
22446
22447 config
22448 .set_prop("agents.bob.history-pruning.enabled", "true")
22449 .expect("set_prop should accept the emitted per-agent nested path");
22450 assert_eq!(
22451 config
22452 .get_prop("agents.bob.history-pruning.enabled")
22453 .expect("get_prop should accept the emitted per-agent nested path"),
22454 "true"
22455 );
22456
22457 config
22458 .set_prop("agents.bob.precheck.enabled", "false")
22459 .expect("set_prop should accept the emitted per-agent precheck path");
22460 assert_eq!(
22461 config
22462 .get_prop("agents.bob.precheck.enabled")
22463 .expect("get_prop should accept the emitted per-agent precheck path"),
22464 "false"
22465 );
22466 }
22467
22468 #[test]
22475 async fn every_scalar_prop_round_trips_through_set_prop() {
22476 let mut config = Config::default();
22477 config.init_defaults(None);
22478 let fields = config.prop_fields();
22479 for field in &fields {
22480 if field.is_secret
22481 || matches!(
22482 field.kind,
22483 crate::config::PropKind::StringArray | crate::config::PropKind::ObjectArray
22484 )
22485 {
22486 continue;
22487 }
22488 let value = match config.get_prop(&field.name) {
22489 Ok(v) => v,
22490 Err(_) => continue,
22491 };
22492 if value == "<unset>" {
22494 continue;
22495 }
22496 let result = config.set_prop(&field.name, &value);
22497 assert!(
22498 result.is_ok(),
22499 "round-trip set_prop('{}', '{}') failed: {}",
22500 field.name,
22501 value,
22502 result.unwrap_err()
22503 );
22504 }
22505 }
22506
22507 #[test]
22510 async fn every_enum_variant_is_settable() {
22511 let mut config = Config::default();
22512 config.init_defaults(None);
22513
22514 for field in config.prop_fields() {
22515 if !field.is_enum() {
22516 continue;
22517 }
22518 let get_variants = field.enum_variants.unwrap_or_else(|| {
22519 panic!("enum field '{}' has no enum_variants callback", field.name)
22520 });
22521 let variants = get_variants();
22522 assert!(
22523 !variants.is_empty(),
22524 "enum field '{}' returned no variants",
22525 field.name
22526 );
22527
22528 for variant in &variants {
22529 let result = config.set_prop(&field.name, variant);
22530 assert!(
22531 result.is_ok(),
22532 "set_prop('{}', '{}') failed: {}",
22533 field.name,
22534 variant,
22535 result.unwrap_err()
22536 );
22537 }
22538 }
22539 }
22540
22541 #[test]
22542 async fn channel_approval_timeout_secs_defaults_to_300() {
22543 let discord: DiscordConfig = serde_json::from_str(r#"{"bot_token":"tok"}"#).unwrap();
22544 assert_eq!(discord.approval_timeout_secs, 300);
22545
22546 let slack: SlackConfig = serde_json::from_str(r#"{"bot_token":"tok"}"#).unwrap();
22547 assert_eq!(slack.approval_timeout_secs, 300);
22548
22549 let signal: SignalConfig =
22550 serde_json::from_str(r#"{"http_url":"http://localhost","account":"+1"}"#).unwrap();
22551 assert_eq!(signal.approval_timeout_secs, 300);
22552
22553 let matrix: MatrixConfig = serde_json::from_str(
22554 r#"{"homeserver":"https://matrix.org","access_token":"tok","allowed_users":[]}"#,
22555 )
22556 .unwrap();
22557 assert_eq!(matrix.approval_timeout_secs, 300);
22558
22559 let whatsapp: WhatsAppConfig = serde_json::from_str(r#"{}"#).unwrap();
22560 assert_eq!(whatsapp.approval_timeout_secs, 300);
22561 }
22562
22563 #[test]
22564 async fn channel_approval_timeout_secs_explicit_override() {
22565 let discord: DiscordConfig =
22566 serde_json::from_str(r#"{"bot_token":"tok","approval_timeout_secs":60}"#).unwrap();
22567 assert_eq!(discord.approval_timeout_secs, 60);
22568
22569 let slack: SlackConfig =
22570 serde_json::from_str(r#"{"bot_token":"tok","approval_timeout_secs":120}"#).unwrap();
22571 assert_eq!(slack.approval_timeout_secs, 120);
22572
22573 let signal: SignalConfig = serde_json::from_str(
22574 r#"{"http_url":"http://localhost","account":"+1","approval_timeout_secs":90}"#,
22575 )
22576 .unwrap();
22577 assert_eq!(signal.approval_timeout_secs, 90);
22578
22579 let matrix: MatrixConfig = serde_json::from_str(
22580 r#"{"homeserver":"https://matrix.org","access_token":"tok","allowed_users":[],"approval_timeout_secs":45}"#,
22581 )
22582 .unwrap();
22583 assert_eq!(matrix.approval_timeout_secs, 45);
22584
22585 let whatsapp: WhatsAppConfig =
22586 serde_json::from_str(r#"{"approval_timeout_secs":180}"#).unwrap();
22587 assert_eq!(whatsapp.approval_timeout_secs, 180);
22588 }
22589
22590 fn multi_agent_test_config() -> Config {
22596 use crate::providers::ChannelRef;
22597
22598 let mut config = Config::default();
22599
22600 config
22602 .risk_profiles
22603 .insert("default".to_string(), RiskProfileConfig::default());
22604
22605 config.providers.models.anthropic.insert(
22607 "default".to_string(),
22608 AnthropicModelProviderConfig::default(),
22609 );
22610
22611 config
22615 .channels
22616 .telegram
22617 .insert("draft".to_string(), TelegramConfig::default());
22618
22619 let agent = AliasedAgentConfig {
22622 channels: vec![ChannelRef::new("telegram.draft")],
22623 model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
22624 risk_profile: "default".to_string(),
22625 ..AliasedAgentConfig::default()
22626 };
22627 config.agents.insert("alpha".to_string(), agent);
22628
22629 config
22630 }
22631
22632 #[test]
22633 async fn validate_accepts_per_agent_precheck_controls() {
22634 let mut config = multi_agent_test_config();
22635 let alpha = config.agents.get_mut("alpha").unwrap();
22636 alpha.precheck.enabled = false;
22637 alpha.precheck.timeout_secs = 5;
22638
22639 config
22640 .validate()
22641 .expect("precheck enabled/timeout controls should validate");
22642 }
22643
22644 #[test]
22645 async fn validate_rejects_per_agent_precheck_zero_timeout() {
22646 let mut config = multi_agent_test_config();
22647 let alpha = config.agents.get_mut("alpha").unwrap();
22648 alpha.precheck.timeout_secs = 0;
22649
22650 let err = config
22651 .validate()
22652 .expect_err("zero precheck timeout must fail validation")
22653 .to_string();
22654 assert!(
22655 err.contains("agents.alpha.precheck.timeout_secs"),
22656 "expected precheck timeout field path, got: {err}"
22657 );
22658 }
22659
22660 #[test]
22661 async fn validate_rejects_workspace_access_self_reference() {
22662 let mut config = multi_agent_test_config();
22663 let alpha = config.agents.get_mut("alpha").unwrap();
22664 alpha.workspace.access.insert(
22665 crate::multi_agent::AgentAlias::new("alpha"),
22666 crate::multi_agent::AccessMode::Read,
22667 );
22668 let err = config
22669 .validate()
22670 .expect_err("self-reference must fail validation");
22671 let msg = err.to_string();
22672 assert!(
22673 msg.contains("agents.alpha.workspace.access.alpha"),
22674 "expected field path in error, got: {msg}"
22675 );
22676 assert!(
22677 msg.contains("self-references"),
22678 "expected self-reference explanation, got: {msg}"
22679 );
22680 }
22681
22682 #[test]
22683 async fn validate_rejects_workspace_access_dangling_target() {
22684 let mut config = multi_agent_test_config();
22685 let alpha = config.agents.get_mut("alpha").unwrap();
22686 alpha.workspace.access.insert(
22687 crate::multi_agent::AgentAlias::new("ghost"),
22688 crate::multi_agent::AccessMode::ReadWrite,
22689 );
22690 let err = config
22691 .validate()
22692 .expect_err("dangling target must fail validation");
22693 let msg = err.to_string();
22694 assert!(
22695 msg.contains("agents.ghost is not configured"),
22696 "expected dangling-ref explanation, got: {msg}"
22697 );
22698 }
22699
22700 #[test]
22701 async fn validate_rejects_read_memory_from_self_reference() {
22702 let mut config = multi_agent_test_config();
22703 let alpha = config.agents.get_mut("alpha").unwrap();
22704 alpha
22705 .workspace
22706 .read_memory_from
22707 .push(crate::multi_agent::AgentAlias::new("alpha"));
22708 let err = config
22709 .validate()
22710 .expect_err("self-reference must fail validation");
22711 assert!(
22712 err.to_string().contains("read_memory_from[0]"),
22713 "expected indexed field path, got: {err}"
22714 );
22715 }
22716
22717 #[test]
22718 async fn validate_rejects_read_memory_from_cross_backend() {
22719 let mut config = multi_agent_test_config();
22720
22721 let beta = AliasedAgentConfig {
22723 channels: vec![crate::providers::ChannelRef::new("telegram.draft")],
22724 model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
22725 risk_profile: "default".to_string(),
22726 memory: crate::multi_agent::AgentMemoryConfig {
22727 backend: crate::multi_agent::MemoryBackendKind::Postgres,
22728 },
22729 ..AliasedAgentConfig::default()
22730 };
22731 config.agents.insert("beta".to_string(), beta);
22732
22733 let alpha = config.agents.get_mut("alpha").unwrap();
22735 alpha
22736 .workspace
22737 .read_memory_from
22738 .push(crate::multi_agent::AgentAlias::new("beta"));
22739
22740 let err = config
22741 .validate()
22742 .expect_err("cross-backend allowlist must fail validation");
22743 let msg = err.to_string();
22744 assert!(
22745 msg.contains("same-backend siblings only"),
22746 "expected cross-backend explanation, got: {msg}"
22747 );
22748 }
22749
22750 #[test]
22751 async fn validate_rejects_peer_group_dangling_member() {
22752 let mut config = multi_agent_test_config();
22753 let group = crate::multi_agent::PeerGroupConfig {
22754 channel: "telegram".to_string(),
22755 agents: vec![
22756 crate::multi_agent::AgentAlias::new("alpha"),
22757 crate::multi_agent::AgentAlias::new("ghost"),
22758 ],
22759 ..crate::multi_agent::PeerGroupConfig::default()
22760 };
22761 config.peer_groups.insert("team_chat".to_string(), group);
22762 let err = config
22763 .validate()
22764 .expect_err("dangling group member must fail validation");
22765 assert!(
22766 err.to_string().contains("peer_groups.team_chat.agents[1]"),
22767 "expected indexed field path, got: {err}"
22768 );
22769 }
22770
22771 #[test]
22772 async fn validate_rejects_peer_group_member_without_channel() {
22773 let mut config = multi_agent_test_config();
22774
22775 config
22777 .channels
22778 .discord
22779 .insert("ops".to_string(), DiscordConfig::default());
22780 let beta = AliasedAgentConfig {
22781 channels: vec![crate::providers::ChannelRef::new("discord.ops")],
22782 model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
22783 risk_profile: "default".to_string(),
22784 ..AliasedAgentConfig::default()
22785 };
22786 config.agents.insert("beta".to_string(), beta);
22787
22788 let group = crate::multi_agent::PeerGroupConfig {
22790 channel: "telegram".to_string(),
22791 agents: vec![
22792 crate::multi_agent::AgentAlias::new("alpha"),
22793 crate::multi_agent::AgentAlias::new("beta"),
22794 ],
22795 ..crate::multi_agent::PeerGroupConfig::default()
22796 };
22797 config.peer_groups.insert("team_chat".to_string(), group);
22798
22799 let err = config
22800 .validate()
22801 .expect_err("channel-mismatch group member must fail validation");
22802 let msg = err.to_string();
22803 assert!(
22804 msg.contains("agents.beta.channels has no entry of type"),
22805 "expected channel-mismatch explanation, got: {msg}"
22806 );
22807 }
22808
22809 #[test]
22810 async fn validate_accepts_valid_peer_group_with_two_compatible_members() {
22811 let mut config = multi_agent_test_config();
22812
22813 let beta = AliasedAgentConfig {
22815 channels: vec![crate::providers::ChannelRef::new("telegram.draft")],
22816 model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
22817 risk_profile: "default".to_string(),
22818 ..AliasedAgentConfig::default()
22819 };
22820 config.agents.insert("beta".to_string(), beta);
22821
22822 let group = crate::multi_agent::PeerGroupConfig {
22824 channel: "telegram".to_string(),
22825 agents: vec![
22826 crate::multi_agent::AgentAlias::new("alpha"),
22827 crate::multi_agent::AgentAlias::new("beta"),
22828 ],
22829 ..crate::multi_agent::PeerGroupConfig::default()
22830 };
22831 config.peer_groups.insert("team_chat".to_string(), group);
22832
22833 config
22834 .validate()
22835 .expect("two-member same-channel peer group must validate cleanly");
22836 }
22837
22838 #[test]
22839 async fn config_validate_rejects_classifier_provider_pointing_at_missing_alias() {
22840 let toml = r#"
22843 [providers.models.custom.default]
22844 api_key = "k"
22845 model = "qwen3.6-plus"
22846 uri = "https://example.com/v1"
22847 wire_api = "chat_completions"
22848
22849 [risk_profiles.default]
22850 level = "supervised"
22851
22852 [agents.default]
22853 enabled = true
22854 model_provider = "custom.default"
22855 risk_profile = "default"
22856 classifier_provider = "custom.does-not-exist"
22857 "#;
22858 let cfg: Config = toml::from_str(toml).unwrap();
22859 let err = cfg
22860 .validate()
22861 .expect_err("missing alias must fail validate");
22862 let msg = format!("{err:#}");
22863 assert!(
22864 msg.contains("classifier_provider")
22865 && msg.contains("does-not-exist")
22866 && msg.contains("providers.models.custom.does-not-exist is not configured"),
22867 "expected DanglingReference error mentioning field + alias + section, got: {msg}"
22868 );
22869 }
22870
22871 #[test]
22872 async fn config_validate_accepts_classifier_provider_pointing_at_existing_alias() {
22873 let toml = r#"
22874 [providers.models.custom.default]
22875 api_key = "k1"
22876 model = "qwen3.6-plus"
22877 uri = "https://example.com/v1"
22878 wire_api = "chat_completions"
22879
22880 [providers.models.custom.kimi-k2-5]
22881 api_key = "k2"
22882 model = "kimi-k2.5"
22883 uri = "https://example.com/v1"
22884 wire_api = "chat_completions"
22885
22886 [risk_profiles.default]
22887 level = "supervised"
22888
22889 [agents.default]
22890 enabled = true
22891 model_provider = "custom.default"
22892 risk_profile = "default"
22893 classifier_provider = "custom.kimi-k2-5"
22894 "#;
22895 let cfg: Config = toml::from_str(toml).unwrap();
22896 cfg.validate()
22897 .expect("validate must succeed for resolvable ref");
22898 assert_eq!(
22899 cfg.agents
22900 .get("default")
22901 .unwrap()
22902 .classifier_provider
22903 .as_str(),
22904 "custom.kimi-k2-5"
22905 );
22906 }
22907
22908 #[test]
22909 async fn config_validate_accepts_empty_classifier_provider_as_inheritance_signal() {
22910 let toml = r#"
22913 [providers.models.custom.default]
22914 api_key = "k"
22915 model = "qwen3.6-plus"
22916 uri = "https://example.com/v1"
22917 wire_api = "chat_completions"
22918
22919 [risk_profiles.default]
22920 level = "supervised"
22921
22922 [agents.default]
22923 enabled = true
22924 model_provider = "custom.default"
22925 risk_profile = "default"
22926 "#;
22927 let cfg: Config = toml::from_str(toml).unwrap();
22928 cfg.validate()
22929 .expect("missing classifier_provider must validate");
22930 assert!(
22931 cfg.agents
22932 .get("default")
22933 .unwrap()
22934 .classifier_provider
22935 .is_empty()
22936 );
22937 }
22938}