1pub mod v1;
5pub mod v2;
6
7use crate::autonomy::AutonomyLevel;
8use crate::autonomy::DelegationPolicy;
9use crate::domain_matcher::DomainMatcher;
10use crate::traits::{ChannelConfig, HasPropKind, PropKind};
11use crate::validation_bail;
12use anyhow::{Context, Result};
13use directories::UserDirs;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use std::sync::{OnceLock, RwLock};
18#[cfg(unix)]
19use tokio::fs::File;
20use tokio::fs::{self, OpenOptions};
21use tokio::io::AsyncWriteExt;
22use zeroclaw_macros::Configurable;
23
24const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
25 "model_provider.anthropic",
26 "model_provider.compatible",
27 "model_provider.copilot",
28 "model_provider.gemini",
29 "model_provider.glm",
30 "model_provider.ollama",
31 "model_provider.openai",
32 "model_provider.openrouter",
33 "channel.dingtalk",
34 "channel.discord",
35 "channel.lark",
36 "channel.matrix",
37 "channel.mattermost",
38 "channel.nextcloud_talk",
39 "channel.qq",
40 "channel.signal",
41 "channel.slack",
42 "channel.telegram",
43 "channel.wati",
44 "channel.wechat",
45 "channel.whatsapp",
46 "tool.browser",
47 "tool.composio",
48 "tool.http_request",
49 "tool.pushover",
50 "tool.web_search",
51 "memory.embeddings",
52 "tunnel.custom",
53 "transcription.groq",
54];
55
56const SUPPORTED_PROXY_SERVICE_SELECTORS: &[&str] = &[
57 "model_provider.*",
58 "channel.*",
59 "tool.*",
60 "memory.*",
61 "tunnel.*",
62 "transcription.*",
63];
64
65static RUNTIME_PROXY_CONFIG: OnceLock<RwLock<ProxyConfig>> = OnceLock::new();
66static RUNTIME_PROXY_CLIENT_CACHE: OnceLock<RwLock<HashMap<String, reqwest::Client>>> =
67 OnceLock::new();
68
69#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
75#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
76pub struct Config {
77 #[serde(skip)]
83 pub data_dir: PathBuf,
84 #[serde(skip)]
86 pub config_path: PathBuf,
87 #[serde(skip)]
93 pub env_overridden_paths: std::collections::HashSet<String>,
94 #[serde(skip)]
100 pub pre_override_snapshots: std::collections::HashMap<String, String>,
101 #[serde(skip)]
105 pub onepassword_reference_snapshots: std::collections::HashMap<String, String>,
106 #[serde(skip)]
109 pub dirty_paths: std::collections::HashSet<String>,
110 #[serde(skip)]
115 pub degraded_security: Vec<String>,
116 #[serde(default = "default_schema_version")]
118 pub schema_version: u32,
119
120 #[serde(default)]
127 #[nested]
128 pub providers: crate::providers::Providers,
129
130 #[serde(default, skip_serializing_if = "Vec::is_empty")]
133 #[credential_class = "requires_follow_up"]
134 pub model_routes: Vec<ModelRouteConfig>,
135
136 #[serde(default, skip_serializing_if = "Vec::is_empty")]
139 #[credential_class = "requires_follow_up"]
140 pub embedding_routes: Vec<EmbeddingRouteConfig>,
141
142 #[serde(default)]
144 #[nested]
145 pub observability: ObservabilityConfig,
146
147 #[serde(default)]
149 #[nested]
150 pub trust: crate::scattered_types::TrustConfig,
151
152 #[serde(default)]
154 #[nested]
155 pub security: SecurityConfig,
156
157 #[serde(default)]
159 #[nested]
160 pub backup: BackupConfig,
161
162 #[serde(default)]
164 #[nested]
165 pub data_retention: DataRetentionConfig,
166
167 #[serde(default)]
169 #[nested]
170 pub cloud_ops: CloudOpsConfig,
171
172 #[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")]
179 #[nested]
180 pub conversational_ai: ConversationalAiConfig,
181
182 #[serde(default)]
184 #[nested]
185 pub security_ops: SecurityOpsConfig,
186
187 #[serde(default)]
189 #[nested]
190 pub runtime: RuntimeConfig,
191
192 #[serde(default)]
194 #[nested]
195 pub reliability: ReliabilityConfig,
196
197 #[serde(default)]
199 #[nested]
200 pub scheduler: SchedulerConfig,
201
202 #[serde(default)]
204 #[nested]
205 pub pacing: PacingConfig,
206
207 #[serde(default)]
209 #[nested]
210 pub skills: SkillsConfig,
211
212 #[serde(default)]
214 #[nested]
215 pub pipeline: PipelineConfig,
216
217 #[serde(default)]
219 #[nested]
220 pub query_classification: QueryClassificationConfig,
221
222 #[serde(default)]
224 #[nested]
225 pub heartbeat: HeartbeatConfig,
226
227 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
233 #[nested]
234 pub cron: HashMap<String, CronJobDecl>,
235
236 #[serde(default)]
238 #[nested]
239 pub acp: AcpConfig,
240
241 #[serde(default, alias = "channels_config")]
243 #[nested]
244 pub channels: ChannelsConfig,
245
246 #[serde(default)]
248 #[nested]
249 pub memory: MemoryConfig,
250
251 #[serde(default)]
253 #[nested]
254 pub storage: StorageConfig,
255
256 #[serde(default)]
258 #[nested]
259 pub tunnel: TunnelConfig,
260
261 #[serde(default)]
263 #[nested]
264 pub gateway: GatewayConfig,
265
266 #[serde(default)]
268 #[nested]
269 pub wss: WssConfig,
270
271 #[serde(default)]
273 #[nested]
274 pub composio: ComposioConfig,
275
276 #[serde(default)]
278 #[nested]
279 pub microsoft365: Microsoft365Config,
280
281 #[serde(default)]
283 #[nested]
284 pub secrets: SecretsConfig,
285
286 #[serde(default)]
288 #[nested]
289 pub browser: BrowserConfig,
290
291 #[serde(default)]
313 #[nested]
314 pub browser_delegate: crate::scattered_types::BrowserDelegateConfig,
315
316 #[serde(default)]
318 #[nested]
319 pub http_request: HttpRequestConfig,
320
321 #[serde(default)]
323 #[nested]
324 pub multimodal: MultimodalConfig,
325
326 #[serde(default)]
328 #[nested]
329 pub media_pipeline: MediaPipelineConfig,
330
331 #[serde(default)]
333 #[nested]
334 pub web_fetch: WebFetchConfig,
335
336 #[serde(default)]
338 #[nested]
339 pub link_enricher: LinkEnricherConfig,
340
341 #[serde(default)]
343 #[nested]
344 pub text_browser: TextBrowserConfig,
345
346 #[serde(default)]
348 #[nested]
349 pub web_search: WebSearchConfig,
350
351 #[serde(default)]
353 #[nested]
354 pub project_intel: ProjectIntelConfig,
355
356 #[serde(default)]
358 #[nested]
359 pub google_workspace: GoogleWorkspaceConfig,
360
361 #[serde(default)]
363 #[nested]
364 pub proxy: ProxyConfig,
365
366 #[serde(default)]
370 #[nested]
371 pub cost: CostConfig,
372
373 #[serde(default)]
375 #[nested]
376 pub peripherals: PeripheralsConfig,
377
378 #[serde(default)]
380 #[nested]
381 pub delegate: DelegateToolConfig,
382
383 #[serde(default)]
389 #[nested]
390 pub agents: HashMap<String, AliasedAgentConfig>,
391
392 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
394 #[nested]
395 pub risk_profiles: HashMap<String, RiskProfileConfig>,
396
397 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
399 #[nested]
400 pub runtime_profiles: HashMap<String, RuntimeProfileConfig>,
401
402 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
404 #[nested]
405 pub skill_bundles: HashMap<String, SkillBundleConfig>,
406
407 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
409 #[nested]
410 pub knowledge_bundles: HashMap<String, KnowledgeBundleConfig>,
411
412 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
414 #[nested]
415 pub mcp_bundles: HashMap<String, McpBundleConfig>,
416
417 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
424 #[nested]
425 pub peer_groups: HashMap<String, crate::multi_agent::PeerGroupConfig>,
426
427 #[serde(default)]
429 #[nested]
430 pub hooks: HooksConfig,
431
432 #[serde(default)]
434 #[nested]
435 pub hardware: HardwareConfig,
436
437 #[serde(default)]
439 #[nested]
440 pub transcription: TranscriptionConfig,
441
442 #[serde(default)]
444 #[nested]
445 pub tts: TtsConfig,
446
447 #[serde(default, alias = "mcpServers")]
449 #[nested]
450 pub mcp: McpConfig,
451
452 #[serde(default)]
454 #[nested]
455 pub nodes: NodesConfig,
456
457 #[serde(default)]
460 #[nested]
461 pub onboard_state: OnboardStateConfig,
462
463 #[serde(default)]
465 #[nested]
466 pub notion: NotionConfig,
467
468 #[serde(default)]
470 #[nested]
471 pub jira: JiraConfig,
472
473 #[serde(default)]
475 #[nested]
476 pub node_transport: NodeTransportConfig,
477
478 #[serde(default)]
480 #[nested]
481 pub knowledge: KnowledgeConfig,
482
483 #[serde(default)]
485 #[nested]
486 pub linkedin: LinkedInConfig,
487
488 #[serde(default)]
490 #[nested]
491 pub image_gen: ImageGenConfig,
492
493 #[serde(default)]
495 #[nested]
496 pub file_upload: FileUploadConfig,
497
498 #[serde(default)]
501 #[nested]
502 pub file_upload_bundle: FileUploadBundleConfig,
503
504 #[serde(default)]
506 #[nested]
507 pub file_download: FileDownloadConfig,
508
509 #[serde(default)]
511 #[nested]
512 pub plugins: PluginsConfig,
513
514 #[serde(default)]
523 pub locale: Option<String>,
524
525 #[serde(default)]
527 #[nested]
528 pub verifiable_intent: VerifiableIntentConfig,
529
530 #[serde(default)]
532 #[nested]
533 pub claude_code: ClaudeCodeConfig,
534
535 #[serde(default)]
537 #[nested]
538 pub claude_code_runner: ClaudeCodeRunnerConfig,
539
540 #[serde(default)]
542 #[nested]
543 pub codex_cli: CodexCliConfig,
544
545 #[serde(default)]
547 #[nested]
548 pub gemini_cli: GeminiCliConfig,
549
550 #[serde(default)]
552 #[nested]
553 pub opencode_cli: OpenCodeCliConfig,
554
555 #[serde(default)]
557 #[nested]
558 pub sop: SopConfig,
559
560 #[serde(default)]
562 #[nested]
563 pub shell_tool: ShellToolConfig,
564
565 #[serde(default)]
567 #[nested]
568 pub escalation: EscalationConfig,
569}
570
571#[allow(clippy::struct_excessive_bools)]
576#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
583#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
584#[prefix = "onboard_state"]
585pub struct OnboardStateConfig {
586 #[serde(default)]
590 pub completed_sections: Vec<String>,
591 #[serde(default)]
599 pub quickstart_completed: bool,
600}
601
602fn is_false(value: &bool) -> bool {
608 !*value
609}
610
611pub trait ModelEndpoint {
622 fn uri(&self) -> &'static str;
623}
624
625pub trait FamilyEndpoint {
631 fn endpoint_uri(&self) -> Option<&'static str> {
632 None
633 }
634}
635
636#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
642#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
643#[serde(rename_all = "snake_case")]
644pub enum WireApi {
645 Responses,
646 ChatCompletions,
647}
648
649impl WireApi {
650 #[must_use]
651 pub fn as_str(self) -> &'static str {
652 match self {
653 Self::Responses => "responses",
654 Self::ChatCompletions => "chat_completions",
655 }
656 }
657}
658
659#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
663#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
664#[serde(rename_all = "snake_case")]
665pub enum AuthMode {
666 #[default]
668 ApiKey,
669 OAuth,
672}
673
674#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
676#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
677#[prefix = "providers.models"]
678pub struct ModelProviderConfig {
679 #[secret]
681 #[credential_class = "encrypted_secret"]
682 #[tab(Connection)]
683 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
684 #[serde(default, skip_serializing_if = "Option::is_none")]
685 pub api_key: Option<String>,
686 #[serde(default, skip_serializing_if = "Option::is_none")]
691 pub kind: Option<String>,
692 #[tab(Connection)]
694 #[serde(default, skip_serializing_if = "Option::is_none")]
695 pub uri: Option<String>,
696 #[tab(Model)]
698 #[serde(default, skip_serializing_if = "Option::is_none")]
699 pub model: Option<String>,
700 #[tab(Model)]
708 #[serde(default, skip_serializing_if = "Vec::is_empty")]
709 pub fallback: Vec<crate::providers::ModelProviderRef>,
710 #[tab(Model)]
717 #[serde(default, skip_serializing_if = "Vec::is_empty")]
718 pub fallback_models: Vec<String>,
719 #[tab(Model)]
723 #[serde(default, skip_serializing_if = "Option::is_none")]
724 pub temperature: Option<f64>,
725 #[tab(Model)]
727 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub timeout_secs: Option<u64>,
729 #[tab(Connection)]
731 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
732 #[secret]
733 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
734 pub extra_headers: HashMap<String, String>,
735 #[tab(Advanced)]
737 #[serde(default, skip_serializing_if = "Option::is_none")]
738 pub wire_api: Option<WireApi>,
739 #[tab(Connection)]
741 #[serde(default, skip_serializing_if = "is_false")]
742 #[credential_class = "external_auth_store"]
743 pub requires_openai_auth: bool,
744 #[tab(Model)]
746 #[serde(default, skip_serializing_if = "Option::is_none")]
747 pub max_tokens: Option<u32>,
748 #[tab(Advanced)]
750 #[serde(default, skip_serializing_if = "is_false")]
751 pub merge_system_into_user: bool,
752 #[tab(Advanced)]
757 #[serde(default, skip_serializing_if = "Option::is_none")]
758 pub provider_extra: Option<serde_json::Value>,
759 #[tab(Advanced)]
771 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
772 pub pricing: HashMap<String, f64>,
773 #[tab(Advanced)]
781 #[serde(default, skip_serializing_if = "Option::is_none")]
782 pub native_tools: Option<bool>,
783 #[tab(Advanced)]
788 #[serde(default, skip_serializing_if = "Option::is_none")]
789 pub think: Option<bool>,
790 #[tab(Advanced)]
796 #[serde(default, skip_serializing_if = "Option::is_none")]
797 pub chat_template_kwargs: Option<serde_json::Value>,
798}
799
800#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
821#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
822#[serde(rename_all = "snake_case")]
823pub enum OpenAIEndpoint {
824 #[default]
825 Default,
826}
827
828impl ModelEndpoint for OpenAIEndpoint {
829 fn uri(&self) -> &'static str {
830 match self {
831 Self::Default => "https://api.openai.com/v1",
832 }
833 }
834}
835
836#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
842#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
843#[prefix = "providers.models.openai"]
844pub struct OpenAIModelProviderConfig {
845 #[nested]
846 #[serde(flatten)]
847 pub base: ModelProviderConfig,
848}
849
850#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
856#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
857#[serde(rename_all = "snake_case")]
858pub enum AzureEndpoint {
859 #[default]
860 Default,
861}
862
863impl ModelEndpoint for AzureEndpoint {
864 fn uri(&self) -> &'static str {
865 match self {
866 Self::Default => "https://{resource}.openai.azure.com/openai/deployments/{deployment}",
870 }
871 }
872}
873
874#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
879#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
880#[prefix = "providers.models.azure"]
881pub struct AzureModelProviderConfig {
882 #[nested]
883 #[serde(flatten)]
884 pub base: ModelProviderConfig,
885 #[serde(
887 default,
888 skip_serializing_if = "Option::is_none",
889 alias = "azure_openai_resource"
890 )]
891 pub resource: Option<String>,
892 #[serde(
894 default,
895 skip_serializing_if = "Option::is_none",
896 alias = "azure_openai_deployment"
897 )]
898 pub deployment: Option<String>,
899 #[serde(
901 default,
902 skip_serializing_if = "Option::is_none",
903 alias = "azure_openai_api_version"
904 )]
905 pub api_version: Option<String>,
906}
907
908#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
912#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
913#[serde(rename_all = "snake_case")]
914pub enum AnthropicEndpoint {
915 #[default]
916 Default,
917}
918
919impl ModelEndpoint for AnthropicEndpoint {
920 fn uri(&self) -> &'static str {
921 match self {
922 Self::Default => "https://api.anthropic.com",
923 }
924 }
925}
926
927#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
931#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
932#[prefix = "providers.models.anthropic"]
933pub struct AnthropicModelProviderConfig {
934 #[nested]
935 #[serde(flatten)]
936 pub base: ModelProviderConfig,
937}
938
939#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
945#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
946#[serde(rename_all = "snake_case")]
947pub enum MoonshotEndpoint {
948 Cn,
950 #[default]
952 Intl,
953 Code,
955}
956
957impl ModelEndpoint for MoonshotEndpoint {
958 fn uri(&self) -> &'static str {
959 match self {
960 Self::Cn => "https://api.moonshot.cn/v1",
961 Self::Intl => "https://api.moonshot.ai/v1",
962 Self::Code => "https://api.moonshot.cn/coder/v1",
963 }
964 }
965}
966
967#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
971#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
972#[prefix = "providers.models.moonshot"]
973pub struct MoonshotModelProviderConfig {
974 #[nested]
975 #[serde(flatten)]
976 pub base: ModelProviderConfig,
977 #[serde(default)]
981 pub endpoint: MoonshotEndpoint,
982}
983
984impl FamilyEndpoint for MoonshotModelProviderConfig {
985 fn endpoint_uri(&self) -> Option<&'static str> {
986 Some(self.endpoint.uri())
987 }
988}
989
990#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
994#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
995#[serde(rename_all = "snake_case")]
996pub enum QwenEndpoint {
997 Cn,
999 #[default]
1001 Intl,
1002 Us,
1004 Code,
1006}
1007
1008impl ModelEndpoint for QwenEndpoint {
1009 fn uri(&self) -> &'static str {
1010 match self {
1011 Self::Cn => "https://dashscope.aliyuncs.com/compatible-mode/v1",
1012 Self::Intl => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
1013 Self::Us => "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
1014 Self::Code => {
1015 "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
1016 }
1017 }
1018 }
1019}
1020
1021#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1024#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1025#[prefix = "providers.models.qwen"]
1026pub struct QwenModelProviderConfig {
1027 #[nested]
1028 #[serde(flatten)]
1029 pub base: ModelProviderConfig,
1030 #[serde(default)]
1031 pub endpoint: QwenEndpoint,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1035 pub auth_mode: Option<AuthMode>,
1036 #[serde(default, skip_serializing_if = "Option::is_none")]
1042 #[secret(category = "model_provider")]
1043 pub oauth_refresh_token: Option<String>,
1044 #[serde(default, skip_serializing_if = "Option::is_none")]
1047 pub oauth_client_id: Option<String>,
1048 #[serde(default, skip_serializing_if = "Option::is_none")]
1053 pub oauth_resource_url: Option<String>,
1054}
1055
1056impl FamilyEndpoint for QwenModelProviderConfig {
1057 fn endpoint_uri(&self) -> Option<&'static str> {
1058 Some(self.endpoint.uri())
1059 }
1060}
1061
1062#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1065#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1066#[serde(rename_all = "snake_case")]
1067pub enum OpenRouterEndpoint {
1068 #[default]
1069 Default,
1070}
1071
1072impl ModelEndpoint for OpenRouterEndpoint {
1073 fn uri(&self) -> &'static str {
1074 match self {
1075 Self::Default => "https://openrouter.ai/api/v1",
1076 }
1077 }
1078}
1079
1080#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1081#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1082#[prefix = "providers.models.openrouter"]
1083pub struct OpenRouterModelProviderConfig {
1084 #[nested]
1085 #[serde(flatten)]
1086 pub base: ModelProviderConfig,
1087}
1088
1089#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1092#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1093#[serde(rename_all = "snake_case")]
1094pub enum OllamaEndpoint {
1095 #[default]
1096 LocalDefault,
1097}
1098
1099impl ModelEndpoint for OllamaEndpoint {
1100 fn uri(&self) -> &'static str {
1101 match self {
1102 Self::LocalDefault => "http://localhost:11434",
1103 }
1104 }
1105}
1106
1107#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1108#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1109#[prefix = "providers.models.ollama"]
1110pub struct OllamaModelProviderConfig {
1111 #[nested]
1112 #[serde(flatten)]
1113 pub base: ModelProviderConfig,
1114 #[serde(default, skip_serializing_if = "Option::is_none")]
1118 pub num_ctx: Option<u32>,
1119 #[serde(default, skip_serializing_if = "Option::is_none")]
1123 pub num_predict: Option<i32>,
1124 #[serde(default, skip_serializing_if = "Option::is_none")]
1130 pub temperature_override: Option<f64>,
1131}
1132
1133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1136#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1137#[serde(rename_all = "snake_case")]
1138pub enum TogetherEndpoint {
1139 #[default]
1140 Default,
1141}
1142
1143impl ModelEndpoint for TogetherEndpoint {
1144 fn uri(&self) -> &'static str {
1145 match self {
1146 Self::Default => "https://api.together.xyz/v1",
1147 }
1148 }
1149}
1150
1151#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1152#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1153#[prefix = "providers.models.together"]
1154pub struct TogetherModelProviderConfig {
1155 #[nested]
1156 #[serde(flatten)]
1157 pub base: ModelProviderConfig,
1158}
1159
1160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1163#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1164#[serde(rename_all = "snake_case")]
1165pub enum FireworksEndpoint {
1166 #[default]
1167 Default,
1168}
1169
1170impl ModelEndpoint for FireworksEndpoint {
1171 fn uri(&self) -> &'static str {
1172 match self {
1173 Self::Default => "https://api.fireworks.ai/inference/v1",
1174 }
1175 }
1176}
1177
1178#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1179#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1180#[prefix = "providers.models.fireworks"]
1181pub struct FireworksModelProviderConfig {
1182 #[nested]
1183 #[serde(flatten)]
1184 pub base: ModelProviderConfig,
1185}
1186
1187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1190#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1191#[serde(rename_all = "snake_case")]
1192pub enum GroqEndpoint {
1193 #[default]
1194 Default,
1195}
1196
1197impl ModelEndpoint for GroqEndpoint {
1198 fn uri(&self) -> &'static str {
1199 match self {
1200 Self::Default => "https://api.groq.com/openai/v1",
1201 }
1202 }
1203}
1204
1205#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1206#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1207#[prefix = "providers.models.groq"]
1208pub struct GroqModelProviderConfig {
1209 #[nested]
1210 #[serde(flatten)]
1211 pub base: ModelProviderConfig,
1212}
1213
1214#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1217#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1218#[serde(rename_all = "snake_case")]
1219pub enum MistralEndpoint {
1220 #[default]
1221 Default,
1222}
1223
1224impl ModelEndpoint for MistralEndpoint {
1225 fn uri(&self) -> &'static str {
1226 match self {
1227 Self::Default => "https://api.mistral.ai/v1",
1228 }
1229 }
1230}
1231
1232#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1233#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1234#[prefix = "providers.models.mistral"]
1235pub struct MistralModelProviderConfig {
1236 #[nested]
1237 #[serde(flatten)]
1238 pub base: ModelProviderConfig,
1239}
1240
1241#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1244#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1245#[serde(rename_all = "snake_case")]
1246pub enum AtomicChatEndpoint {
1247 #[default]
1248 Default,
1249}
1250
1251impl ModelEndpoint for AtomicChatEndpoint {
1252 fn uri(&self) -> &'static str {
1253 match self {
1254 Self::Default => "http://127.0.0.1:1337/v1",
1255 }
1256 }
1257}
1258
1259#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1260#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1261#[prefix = "providers.models.atomic_chat"]
1262pub struct AtomicChatModelProviderConfig {
1263 #[nested]
1264 #[serde(flatten)]
1265 pub base: ModelProviderConfig,
1266}
1267
1268#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1271#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1272#[serde(rename_all = "snake_case")]
1273pub enum DeepseekEndpoint {
1274 #[default]
1275 Default,
1276}
1277
1278impl ModelEndpoint for DeepseekEndpoint {
1279 fn uri(&self) -> &'static str {
1280 match self {
1281 Self::Default => "https://api.deepseek.com/v1",
1282 }
1283 }
1284}
1285
1286#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1287#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1288#[prefix = "providers.models.deepseek"]
1289pub struct DeepseekModelProviderConfig {
1290 #[nested]
1291 #[serde(flatten)]
1292 pub base: ModelProviderConfig,
1293}
1294
1295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1298#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1299#[serde(rename_all = "snake_case")]
1300pub enum CohereEndpoint {
1301 #[default]
1302 Default,
1303}
1304
1305impl ModelEndpoint for CohereEndpoint {
1306 fn uri(&self) -> &'static str {
1307 match self {
1308 Self::Default => "https://api.cohere.ai/compatibility/v1",
1309 }
1310 }
1311}
1312
1313#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1314#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1315#[prefix = "providers.models.cohere"]
1316pub struct CohereModelProviderConfig {
1317 #[nested]
1318 #[serde(flatten)]
1319 pub base: ModelProviderConfig,
1320}
1321
1322#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1325#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1326#[serde(rename_all = "snake_case")]
1327pub enum PerplexityEndpoint {
1328 #[default]
1329 Default,
1330}
1331
1332impl ModelEndpoint for PerplexityEndpoint {
1333 fn uri(&self) -> &'static str {
1334 match self {
1335 Self::Default => "https://api.perplexity.ai",
1336 }
1337 }
1338}
1339
1340#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1341#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1342#[prefix = "providers.models.perplexity"]
1343pub struct PerplexityModelProviderConfig {
1344 #[nested]
1345 #[serde(flatten)]
1346 pub base: ModelProviderConfig,
1347}
1348
1349#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1352#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1353#[serde(rename_all = "snake_case")]
1354pub enum XaiEndpoint {
1355 #[default]
1356 Default,
1357}
1358
1359impl ModelEndpoint for XaiEndpoint {
1360 fn uri(&self) -> &'static str {
1361 match self {
1362 Self::Default => "https://api.x.ai/v1",
1363 }
1364 }
1365}
1366
1367#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1368#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1369#[prefix = "providers.models.xai"]
1370pub struct XaiModelProviderConfig {
1371 #[nested]
1372 #[serde(flatten)]
1373 pub base: ModelProviderConfig,
1374}
1375
1376#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1379#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1380#[serde(rename_all = "snake_case")]
1381pub enum CerebrasEndpoint {
1382 #[default]
1383 Default,
1384}
1385
1386impl ModelEndpoint for CerebrasEndpoint {
1387 fn uri(&self) -> &'static str {
1388 match self {
1389 Self::Default => "https://api.cerebras.ai/v1",
1390 }
1391 }
1392}
1393
1394#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1395#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1396#[prefix = "providers.models.cerebras"]
1397pub struct CerebrasModelProviderConfig {
1398 #[nested]
1399 #[serde(flatten)]
1400 pub base: ModelProviderConfig,
1401}
1402
1403#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1406#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1407#[serde(rename_all = "snake_case")]
1408pub enum SambanovaEndpoint {
1409 #[default]
1410 Default,
1411}
1412
1413impl ModelEndpoint for SambanovaEndpoint {
1414 fn uri(&self) -> &'static str {
1415 match self {
1416 Self::Default => "https://api.sambanova.ai/v1",
1417 }
1418 }
1419}
1420
1421#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1422#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1423#[prefix = "providers.models.sambanova"]
1424pub struct SambanovaModelProviderConfig {
1425 #[nested]
1426 #[serde(flatten)]
1427 pub base: ModelProviderConfig,
1428}
1429
1430#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1433#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1434#[serde(rename_all = "snake_case")]
1435pub enum HyperbolicEndpoint {
1436 #[default]
1437 Default,
1438}
1439
1440impl ModelEndpoint for HyperbolicEndpoint {
1441 fn uri(&self) -> &'static str {
1442 match self {
1443 Self::Default => "https://api.hyperbolic.xyz/v1",
1444 }
1445 }
1446}
1447
1448#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1449#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1450#[prefix = "providers.models.hyperbolic"]
1451pub struct HyperbolicModelProviderConfig {
1452 #[nested]
1453 #[serde(flatten)]
1454 pub base: ModelProviderConfig,
1455}
1456
1457#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1460#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1461#[serde(rename_all = "snake_case")]
1462pub enum DeepinfraEndpoint {
1463 #[default]
1464 Default,
1465}
1466
1467impl ModelEndpoint for DeepinfraEndpoint {
1468 fn uri(&self) -> &'static str {
1469 match self {
1470 Self::Default => "https://api.deepinfra.com/v1/openai",
1471 }
1472 }
1473}
1474
1475#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1476#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1477#[prefix = "providers.models.deepinfra"]
1478pub struct DeepinfraModelProviderConfig {
1479 #[nested]
1480 #[serde(flatten)]
1481 pub base: ModelProviderConfig,
1482}
1483
1484#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1487#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1488#[serde(rename_all = "snake_case")]
1489pub enum HuggingfaceEndpoint {
1490 #[default]
1491 Default,
1492}
1493
1494impl ModelEndpoint for HuggingfaceEndpoint {
1495 fn uri(&self) -> &'static str {
1496 match self {
1497 Self::Default => "https://router.huggingface.co/v1",
1498 }
1499 }
1500}
1501
1502#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1503#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1504#[prefix = "providers.models.huggingface"]
1505pub struct HuggingfaceModelProviderConfig {
1506 #[nested]
1507 #[serde(flatten)]
1508 pub base: ModelProviderConfig,
1509}
1510
1511#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1514#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1515#[serde(rename_all = "snake_case")]
1516pub enum Ai21Endpoint {
1517 #[default]
1518 Default,
1519}
1520impl ModelEndpoint for Ai21Endpoint {
1521 fn uri(&self) -> &'static str {
1522 match self {
1523 Self::Default => "https://api.ai21.com/studio/v1",
1524 }
1525 }
1526}
1527#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1528#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1529#[prefix = "providers.models.ai21"]
1530pub struct Ai21ModelProviderConfig {
1531 #[nested]
1532 #[serde(flatten)]
1533 pub base: ModelProviderConfig,
1534}
1535
1536#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1539#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1540#[serde(rename_all = "snake_case")]
1541pub enum RekaEndpoint {
1542 #[default]
1543 Default,
1544}
1545impl ModelEndpoint for RekaEndpoint {
1546 fn uri(&self) -> &'static str {
1547 match self {
1548 Self::Default => "https://api.reka.ai/v1",
1549 }
1550 }
1551}
1552#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1553#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1554#[prefix = "providers.models.reka"]
1555pub struct RekaModelProviderConfig {
1556 #[nested]
1557 #[serde(flatten)]
1558 pub base: ModelProviderConfig,
1559}
1560
1561#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1564#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1565#[serde(rename_all = "snake_case")]
1566pub enum BasetenEndpoint {
1567 #[default]
1568 Default,
1569}
1570impl ModelEndpoint for BasetenEndpoint {
1571 fn uri(&self) -> &'static str {
1572 match self {
1573 Self::Default => "https://inference.baseten.co/v1",
1574 }
1575 }
1576}
1577#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1578#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1579#[prefix = "providers.models.baseten"]
1580pub struct BasetenModelProviderConfig {
1581 #[nested]
1582 #[serde(flatten)]
1583 pub base: ModelProviderConfig,
1584}
1585
1586#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1589#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1590#[serde(rename_all = "snake_case")]
1591pub enum NscaleEndpoint {
1592 #[default]
1593 Default,
1594}
1595impl ModelEndpoint for NscaleEndpoint {
1596 fn uri(&self) -> &'static str {
1597 match self {
1598 Self::Default => "https://inference.api.nscale.com/v1",
1599 }
1600 }
1601}
1602#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1603#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1604#[prefix = "providers.models.nscale"]
1605pub struct NscaleModelProviderConfig {
1606 #[nested]
1607 #[serde(flatten)]
1608 pub base: ModelProviderConfig,
1609}
1610
1611#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1614#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1615#[serde(rename_all = "snake_case")]
1616pub enum AnyscaleEndpoint {
1617 #[default]
1618 Default,
1619}
1620impl ModelEndpoint for AnyscaleEndpoint {
1621 fn uri(&self) -> &'static str {
1622 match self {
1623 Self::Default => "https://api.endpoints.anyscale.com/v1",
1624 }
1625 }
1626}
1627#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1628#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1629#[prefix = "providers.models.anyscale"]
1630pub struct AnyscaleModelProviderConfig {
1631 #[nested]
1632 #[serde(flatten)]
1633 pub base: ModelProviderConfig,
1634}
1635
1636#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1639#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1640#[serde(rename_all = "snake_case")]
1641pub enum NebiusEndpoint {
1642 #[default]
1643 Default,
1644}
1645impl ModelEndpoint for NebiusEndpoint {
1646 fn uri(&self) -> &'static str {
1647 match self {
1648 Self::Default => "https://api.studio.nebius.ai/v1",
1649 }
1650 }
1651}
1652#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1653#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1654#[prefix = "providers.models.nebius"]
1655pub struct NebiusModelProviderConfig {
1656 #[nested]
1657 #[serde(flatten)]
1658 pub base: ModelProviderConfig,
1659}
1660
1661#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1664#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1665#[serde(rename_all = "snake_case")]
1666pub enum FriendliEndpoint {
1667 #[default]
1668 Default,
1669}
1670impl ModelEndpoint for FriendliEndpoint {
1671 fn uri(&self) -> &'static str {
1672 match self {
1673 Self::Default => "https://api.friendli.ai/serverless/v1",
1674 }
1675 }
1676}
1677#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1678#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1679#[prefix = "providers.models.friendli"]
1680pub struct FriendliModelProviderConfig {
1681 #[nested]
1682 #[serde(flatten)]
1683 pub base: ModelProviderConfig,
1684}
1685
1686#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1689#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1690#[serde(rename_all = "snake_case")]
1691pub enum StepfunEndpoint {
1692 Cn,
1694 #[default]
1696 Intl,
1697}
1698impl ModelEndpoint for StepfunEndpoint {
1699 fn uri(&self) -> &'static str {
1700 match self {
1701 Self::Cn => "https://api.stepfun.com/v1",
1702 Self::Intl => "https://api.stepfun.ai/v1",
1703 }
1704 }
1705}
1706#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1707#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1708#[prefix = "providers.models.stepfun"]
1709pub struct StepfunModelProviderConfig {
1710 #[nested]
1711 #[serde(flatten)]
1712 pub base: ModelProviderConfig,
1713 #[serde(default)]
1714 pub endpoint: StepfunEndpoint,
1715}
1716
1717impl FamilyEndpoint for StepfunModelProviderConfig {
1718 fn endpoint_uri(&self) -> Option<&'static str> {
1719 Some(self.endpoint.uri())
1720 }
1721}
1722
1723#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1726#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1727#[serde(rename_all = "snake_case")]
1728pub enum AihubmixEndpoint {
1729 #[default]
1730 Default,
1731}
1732impl ModelEndpoint for AihubmixEndpoint {
1733 fn uri(&self) -> &'static str {
1734 match self {
1735 Self::Default => "https://aihubmix.com/v1",
1736 }
1737 }
1738}
1739#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1740#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1741#[prefix = "providers.models.aihubmix"]
1742pub struct AihubmixModelProviderConfig {
1743 #[nested]
1744 #[serde(flatten)]
1745 pub base: ModelProviderConfig,
1746}
1747
1748#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1751#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1752#[serde(rename_all = "snake_case")]
1753pub enum SiliconflowEndpoint {
1754 #[default]
1755 Default,
1756}
1757impl ModelEndpoint for SiliconflowEndpoint {
1758 fn uri(&self) -> &'static str {
1759 match self {
1760 Self::Default => "https://api.siliconflow.com/v1",
1761 }
1762 }
1763}
1764#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1765#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1766#[prefix = "providers.models.siliconflow"]
1767pub struct SiliconflowModelProviderConfig {
1768 #[nested]
1769 #[serde(flatten)]
1770 pub base: ModelProviderConfig,
1771}
1772
1773#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1776#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1777#[serde(rename_all = "snake_case")]
1778pub enum AstraiEndpoint {
1779 #[default]
1780 Default,
1781}
1782impl ModelEndpoint for AstraiEndpoint {
1783 fn uri(&self) -> &'static str {
1784 match self {
1785 Self::Default => "https://as-trai.com/v1",
1786 }
1787 }
1788}
1789#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1790#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1791#[prefix = "providers.models.astrai"]
1792pub struct AstraiModelProviderConfig {
1793 #[nested]
1794 #[serde(flatten)]
1795 pub base: ModelProviderConfig,
1796}
1797
1798#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1801#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1802#[serde(rename_all = "snake_case")]
1803pub enum AvianEndpoint {
1804 #[default]
1805 Default,
1806}
1807impl ModelEndpoint for AvianEndpoint {
1808 fn uri(&self) -> &'static str {
1809 match self {
1810 Self::Default => "https://api.avian.io/v1",
1811 }
1812 }
1813}
1814#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1815#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1816#[prefix = "providers.models.avian"]
1817pub struct AvianModelProviderConfig {
1818 #[nested]
1819 #[serde(flatten)]
1820 pub base: ModelProviderConfig,
1821}
1822
1823#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1826#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1827#[serde(rename_all = "snake_case")]
1828pub enum DeepmystEndpoint {
1829 #[default]
1830 Default,
1831}
1832impl ModelEndpoint for DeepmystEndpoint {
1833 fn uri(&self) -> &'static str {
1834 match self {
1835 Self::Default => "https://api.deepmyst.com/v1",
1836 }
1837 }
1838}
1839#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1840#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1841#[prefix = "providers.models.deepmyst"]
1842pub struct DeepmystModelProviderConfig {
1843 #[nested]
1844 #[serde(flatten)]
1845 pub base: ModelProviderConfig,
1846}
1847
1848#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1851#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1852#[serde(rename_all = "snake_case")]
1853pub enum VeniceEndpoint {
1854 #[default]
1855 Default,
1856}
1857impl ModelEndpoint for VeniceEndpoint {
1858 fn uri(&self) -> &'static str {
1859 match self {
1860 Self::Default => "https://api.venice.ai",
1861 }
1862 }
1863}
1864#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1865#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1866#[prefix = "providers.models.venice"]
1867pub struct VeniceModelProviderConfig {
1868 #[nested]
1869 #[serde(flatten)]
1870 pub base: ModelProviderConfig,
1871}
1872
1873#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1876#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1877#[serde(rename_all = "snake_case")]
1878pub enum NovitaEndpoint {
1879 #[default]
1880 Default,
1881}
1882impl ModelEndpoint for NovitaEndpoint {
1883 fn uri(&self) -> &'static str {
1884 match self {
1885 Self::Default => "https://api.novita.ai/openai",
1886 }
1887 }
1888}
1889#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1890#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1891#[prefix = "providers.models.novita"]
1892pub struct NovitaModelProviderConfig {
1893 #[nested]
1894 #[serde(flatten)]
1895 pub base: ModelProviderConfig,
1896}
1897
1898#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1901#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1902#[serde(rename_all = "snake_case")]
1903pub enum NvidiaEndpoint {
1904 #[default]
1905 Default,
1906}
1907impl ModelEndpoint for NvidiaEndpoint {
1908 fn uri(&self) -> &'static str {
1909 match self {
1910 Self::Default => "https://integrate.api.nvidia.com/v1",
1911 }
1912 }
1913}
1914#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1915#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1916#[prefix = "providers.models.nvidia"]
1917pub struct NvidiaModelProviderConfig {
1918 #[nested]
1919 #[serde(flatten)]
1920 pub base: ModelProviderConfig,
1921}
1922
1923#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1926#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1927#[serde(rename_all = "snake_case")]
1928pub enum TelnyxEndpoint {
1929 #[default]
1930 Default,
1931}
1932impl ModelEndpoint for TelnyxEndpoint {
1933 fn uri(&self) -> &'static str {
1934 match self {
1935 Self::Default => "https://api.telnyx.com/v2",
1936 }
1937 }
1938}
1939#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1940#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1941#[prefix = "providers.models.telnyx"]
1942pub struct TelnyxModelProviderConfig {
1943 #[nested]
1944 #[serde(flatten)]
1945 pub base: ModelProviderConfig,
1946}
1947
1948#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1951#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1952#[serde(rename_all = "snake_case")]
1953pub enum VercelEndpoint {
1954 #[default]
1955 Default,
1956}
1957impl ModelEndpoint for VercelEndpoint {
1958 fn uri(&self) -> &'static str {
1959 match self {
1960 Self::Default => "https://ai-gateway.vercel.sh/v1",
1961 }
1962 }
1963}
1964#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1965#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1966#[prefix = "providers.models.vercel"]
1967pub struct VercelModelProviderConfig {
1968 #[nested]
1969 #[serde(flatten)]
1970 pub base: ModelProviderConfig,
1971}
1972
1973#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1976#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1977#[serde(rename_all = "snake_case")]
1978pub enum CloudflareEndpoint {
1979 #[default]
1980 Default,
1981}
1982impl ModelEndpoint for CloudflareEndpoint {
1983 fn uri(&self) -> &'static str {
1984 match self {
1985 Self::Default => "https://gateway.ai.cloudflare.com/v1",
1986 }
1987 }
1988}
1989#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1990#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1991#[prefix = "providers.models.cloudflare"]
1992pub struct CloudflareModelProviderConfig {
1993 #[nested]
1994 #[serde(flatten)]
1995 pub base: ModelProviderConfig,
1996}
1997
1998#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2001#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2002#[serde(rename_all = "snake_case")]
2003pub enum OvhEndpoint {
2004 #[default]
2005 Default,
2006}
2007impl ModelEndpoint for OvhEndpoint {
2008 fn uri(&self) -> &'static str {
2009 match self {
2010 Self::Default => "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1",
2011 }
2012 }
2013}
2014#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2015#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2016#[prefix = "providers.models.ovh"]
2017pub struct OvhModelProviderConfig {
2018 #[nested]
2019 #[serde(flatten)]
2020 pub base: ModelProviderConfig,
2021}
2022
2023#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2026#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2027#[serde(rename_all = "snake_case")]
2028pub enum CopilotEndpoint {
2029 #[default]
2030 Default,
2031}
2032impl ModelEndpoint for CopilotEndpoint {
2033 fn uri(&self) -> &'static str {
2034 match self {
2035 Self::Default => "https://api.githubcopilot.com",
2036 }
2037 }
2038}
2039#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2040#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2041#[prefix = "providers.models.copilot"]
2042pub struct CopilotModelProviderConfig {
2043 #[nested]
2044 #[serde(flatten)]
2045 pub base: ModelProviderConfig,
2046}
2047
2048#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2051#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2052#[serde(rename_all = "snake_case")]
2053pub enum GlmEndpoint {
2054 Cn,
2055 #[default]
2056 Global,
2057}
2058impl ModelEndpoint for GlmEndpoint {
2059 fn uri(&self) -> &'static str {
2060 match self {
2061 Self::Cn => "https://open.bigmodel.cn/api/paas/v4",
2062 Self::Global => "https://api.z.ai/api/paas/v4",
2063 }
2064 }
2065}
2066#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2067#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2068#[prefix = "providers.models.glm"]
2069pub struct GlmModelProviderConfig {
2070 #[nested]
2071 #[serde(flatten)]
2072 pub base: ModelProviderConfig,
2073 #[serde(default)]
2074 pub endpoint: GlmEndpoint,
2075}
2076
2077impl FamilyEndpoint for GlmModelProviderConfig {
2078 fn endpoint_uri(&self) -> Option<&'static str> {
2079 Some(self.endpoint.uri())
2080 }
2081}
2082
2083#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2086#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2087#[serde(rename_all = "snake_case")]
2088pub enum MinimaxEndpoint {
2089 Cn,
2090 #[default]
2091 Intl,
2092}
2093impl ModelEndpoint for MinimaxEndpoint {
2094 fn uri(&self) -> &'static str {
2095 match self {
2096 Self::Cn => "https://api.minimaxi.com/v1",
2097 Self::Intl => "https://api.minimax.io/v1",
2098 }
2099 }
2100}
2101
2102impl MinimaxEndpoint {
2103 pub fn oauth_token_endpoint(self) -> &'static str {
2107 match self {
2108 Self::Cn => "https://api.minimaxi.com/oauth/token",
2109 Self::Intl => "https://api.minimax.io/oauth/token",
2110 }
2111 }
2112}
2113
2114#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2115#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2116#[prefix = "providers.models.minimax"]
2117pub struct MinimaxModelProviderConfig {
2118 #[nested]
2119 #[serde(flatten)]
2120 pub base: ModelProviderConfig,
2121 #[serde(default)]
2122 pub endpoint: MinimaxEndpoint,
2123 #[serde(default, skip_serializing_if = "Option::is_none")]
2124 pub auth_mode: Option<AuthMode>,
2125 #[serde(default, skip_serializing_if = "Option::is_none")]
2131 #[secret(category = "model_provider")]
2132 pub oauth_refresh_token: Option<String>,
2133 #[serde(default, skip_serializing_if = "Option::is_none")]
2137 pub oauth_client_id: Option<String>,
2138}
2139
2140impl FamilyEndpoint for MinimaxModelProviderConfig {
2141 fn endpoint_uri(&self) -> Option<&'static str> {
2142 Some(self.endpoint.uri())
2143 }
2144}
2145
2146#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2149#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2150#[serde(rename_all = "snake_case")]
2151pub enum ZaiEndpoint {
2152 Cn,
2153 #[default]
2154 Global,
2155}
2156impl ModelEndpoint for ZaiEndpoint {
2157 fn uri(&self) -> &'static str {
2158 match self {
2159 Self::Cn => "https://open.bigmodel.cn/api/coding/paas/v4",
2160 Self::Global => "https://api.z.ai/api/coding/paas/v4",
2161 }
2162 }
2163}
2164#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2165#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2166#[prefix = "providers.models.zai"]
2167pub struct ZaiModelProviderConfig {
2168 #[nested]
2169 #[serde(flatten)]
2170 pub base: ModelProviderConfig,
2171 #[serde(default)]
2172 pub endpoint: ZaiEndpoint,
2173}
2174
2175impl FamilyEndpoint for ZaiModelProviderConfig {
2176 fn endpoint_uri(&self) -> Option<&'static str> {
2177 Some(self.endpoint.uri())
2178 }
2179}
2180
2181#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2184#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2185#[serde(rename_all = "snake_case")]
2186pub enum DoubaoEndpoint {
2187 #[default]
2188 Default,
2189}
2190impl ModelEndpoint for DoubaoEndpoint {
2191 fn uri(&self) -> &'static str {
2192 match self {
2193 Self::Default => "https://ark.cn-beijing.volces.com/api/v3",
2194 }
2195 }
2196}
2197#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2198#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2199#[prefix = "providers.models.doubao"]
2200pub struct DoubaoModelProviderConfig {
2201 #[nested]
2202 #[serde(flatten)]
2203 pub base: ModelProviderConfig,
2204}
2205
2206#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2209#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2210#[serde(rename_all = "snake_case")]
2211pub enum YiEndpoint {
2212 #[default]
2213 Default,
2214}
2215impl ModelEndpoint for YiEndpoint {
2216 fn uri(&self) -> &'static str {
2217 match self {
2218 Self::Default => "https://api.lingyiwanwu.com/v1",
2219 }
2220 }
2221}
2222#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2223#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2224#[prefix = "providers.models.yi"]
2225pub struct YiModelProviderConfig {
2226 #[nested]
2227 #[serde(flatten)]
2228 pub base: ModelProviderConfig,
2229}
2230
2231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2234#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2235#[serde(rename_all = "snake_case")]
2236pub enum HunyuanEndpoint {
2237 #[default]
2238 Default,
2239}
2240impl ModelEndpoint for HunyuanEndpoint {
2241 fn uri(&self) -> &'static str {
2242 match self {
2243 Self::Default => "https://api.hunyuan.cloud.tencent.com/v1",
2244 }
2245 }
2246}
2247#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2248#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2249#[prefix = "providers.models.hunyuan"]
2250pub struct HunyuanModelProviderConfig {
2251 #[nested]
2252 #[serde(flatten)]
2253 pub base: ModelProviderConfig,
2254}
2255
2256#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2259#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2260#[serde(rename_all = "snake_case")]
2261pub enum QianfanEndpoint {
2262 #[default]
2263 Default,
2264}
2265impl ModelEndpoint for QianfanEndpoint {
2266 fn uri(&self) -> &'static str {
2267 match self {
2268 Self::Default => "https://qianfan.baidubce.com/v2",
2269 }
2270 }
2271}
2272#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2273#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2274#[prefix = "providers.models.qianfan"]
2275pub struct QianfanModelProviderConfig {
2276 #[nested]
2277 #[serde(flatten)]
2278 pub base: ModelProviderConfig,
2279}
2280
2281#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2284#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2285#[serde(rename_all = "snake_case")]
2286pub enum BaichuanEndpoint {
2287 #[default]
2288 Default,
2289}
2290impl ModelEndpoint for BaichuanEndpoint {
2291 fn uri(&self) -> &'static str {
2292 match self {
2293 Self::Default => "https://api.baichuan-ai.com/v1",
2294 }
2295 }
2296}
2297#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2298#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2299#[prefix = "providers.models.baichuan"]
2300pub struct BaichuanModelProviderConfig {
2301 #[nested]
2302 #[serde(flatten)]
2303 pub base: ModelProviderConfig,
2304}
2305
2306#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2309#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2310#[serde(rename_all = "snake_case")]
2311pub enum GeminiEndpoint {
2312 #[default]
2313 Default,
2314}
2315impl ModelEndpoint for GeminiEndpoint {
2316 fn uri(&self) -> &'static str {
2317 match self {
2318 Self::Default => "https://generativelanguage.googleapis.com/v1beta",
2319 }
2320 }
2321}
2322#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2323#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2324#[prefix = "providers.models.gemini"]
2325pub struct GeminiModelProviderConfig {
2326 #[nested]
2327 #[serde(flatten)]
2328 pub base: ModelProviderConfig,
2329 #[serde(default, skip_serializing_if = "Option::is_none")]
2332 pub auth_mode: Option<AuthMode>,
2333 #[serde(default, skip_serializing_if = "Option::is_none")]
2339 #[secret(category = "model_provider")]
2340 pub oauth_client_id: Option<String>,
2341 #[serde(default, skip_serializing_if = "Option::is_none")]
2343 #[secret(category = "model_provider")]
2344 pub oauth_client_secret: Option<String>,
2345 #[serde(default, skip_serializing_if = "Option::is_none")]
2350 pub oauth_project: Option<String>,
2351}
2352
2353#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2356#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2357#[serde(rename_all = "snake_case")]
2358pub enum GeminiCliEndpoint {
2359 #[default]
2360 LocalSubprocess,
2361}
2362impl ModelEndpoint for GeminiCliEndpoint {
2363 fn uri(&self) -> &'static str {
2364 "subprocess://gemini-cli"
2366 }
2367}
2368#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2369#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2370#[prefix = "providers.models.gemini_cli"]
2371pub struct GeminiCliModelProviderConfig {
2372 #[nested]
2373 #[serde(flatten)]
2374 pub base: ModelProviderConfig,
2375 #[serde(default, skip_serializing_if = "Option::is_none")]
2377 pub binary_path: Option<String>,
2378}
2379
2380#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2383#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2384#[serde(rename_all = "snake_case")]
2385pub enum LmstudioEndpoint {
2386 #[default]
2387 LocalDefault,
2388}
2389impl ModelEndpoint for LmstudioEndpoint {
2390 fn uri(&self) -> &'static str {
2391 match self {
2392 Self::LocalDefault => "http://localhost:1234/v1",
2393 }
2394 }
2395}
2396#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2397#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2398#[prefix = "providers.models.lmstudio"]
2399pub struct LmstudioModelProviderConfig {
2400 #[nested]
2401 #[serde(flatten)]
2402 pub base: ModelProviderConfig,
2403}
2404
2405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2408#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2409#[serde(rename_all = "snake_case")]
2410pub enum LlamacppEndpoint {
2411 #[default]
2412 LocalDefault,
2413}
2414impl ModelEndpoint for LlamacppEndpoint {
2415 fn uri(&self) -> &'static str {
2416 match self {
2417 Self::LocalDefault => "http://localhost:8080/v1",
2418 }
2419 }
2420}
2421#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2422#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2423#[prefix = "providers.models.llamacpp"]
2424pub struct LlamacppModelProviderConfig {
2425 #[nested]
2426 #[serde(flatten)]
2427 pub base: ModelProviderConfig,
2428}
2429
2430#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2433#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2434#[serde(rename_all = "snake_case")]
2435pub enum SglangEndpoint {
2436 #[default]
2437 LocalDefault,
2438}
2439impl ModelEndpoint for SglangEndpoint {
2440 fn uri(&self) -> &'static str {
2441 match self {
2442 Self::LocalDefault => "http://localhost:30000/v1",
2443 }
2444 }
2445}
2446#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2447#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2448#[prefix = "providers.models.sglang"]
2449pub struct SglangModelProviderConfig {
2450 #[nested]
2451 #[serde(flatten)]
2452 pub base: ModelProviderConfig,
2453}
2454
2455#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2458#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2459#[serde(rename_all = "snake_case")]
2460pub enum VllmEndpoint {
2461 #[default]
2462 LocalDefault,
2463}
2464impl ModelEndpoint for VllmEndpoint {
2465 fn uri(&self) -> &'static str {
2466 match self {
2467 Self::LocalDefault => "http://localhost:8000/v1",
2468 }
2469 }
2470}
2471#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2472#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2473#[prefix = "providers.models.vllm"]
2474pub struct VllmModelProviderConfig {
2475 #[nested]
2476 #[serde(flatten)]
2477 pub base: ModelProviderConfig,
2478}
2479
2480#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2483#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2484#[serde(rename_all = "snake_case")]
2485pub enum OsaurusEndpoint {
2486 #[default]
2487 LocalDefault,
2488}
2489impl ModelEndpoint for OsaurusEndpoint {
2490 fn uri(&self) -> &'static str {
2491 match self {
2492 Self::LocalDefault => "http://localhost:1337/v1",
2493 }
2494 }
2495}
2496#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2497#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2498#[prefix = "providers.models.osaurus"]
2499pub struct OsaurusModelProviderConfig {
2500 #[nested]
2501 #[serde(flatten)]
2502 pub base: ModelProviderConfig,
2503}
2504
2505#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2508#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2509#[serde(rename_all = "snake_case")]
2510pub enum LitellmEndpoint {
2511 #[default]
2512 LocalDefault,
2513}
2514impl ModelEndpoint for LitellmEndpoint {
2515 fn uri(&self) -> &'static str {
2516 match self {
2517 Self::LocalDefault => "http://localhost:4000/v1",
2518 }
2519 }
2520}
2521#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2522#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2523#[prefix = "providers.models.litellm"]
2524pub struct LitellmModelProviderConfig {
2525 #[nested]
2526 #[serde(flatten)]
2527 pub base: ModelProviderConfig,
2528}
2529
2530#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2533#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2534#[serde(rename_all = "snake_case")]
2535pub enum LeptonEndpoint {
2536 #[default]
2537 Default,
2538}
2539impl ModelEndpoint for LeptonEndpoint {
2540 fn uri(&self) -> &'static str {
2541 match self {
2542 Self::Default => "https://llama3-1-405b.lepton.run/api/v1",
2543 }
2544 }
2545}
2546#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2547#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2548#[prefix = "providers.models.lepton"]
2549pub struct LeptonModelProviderConfig {
2550 #[nested]
2551 #[serde(flatten)]
2552 pub base: ModelProviderConfig,
2553}
2554
2555#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2558#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2559#[serde(rename_all = "snake_case")]
2560pub enum MorphEndpoint {
2561 #[default]
2562 Default,
2563}
2564impl ModelEndpoint for MorphEndpoint {
2565 fn uri(&self) -> &'static str {
2566 match self {
2567 Self::Default => "https://api.morphllm.com/v1",
2568 }
2569 }
2570}
2571#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2572#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2573#[prefix = "providers.models.morph"]
2574pub struct MorphModelProviderConfig {
2575 #[nested]
2576 #[serde(flatten)]
2577 pub base: ModelProviderConfig,
2578}
2579
2580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2583#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2584#[serde(rename_all = "snake_case")]
2585pub enum GithubModelsEndpoint {
2586 #[default]
2587 Default,
2588}
2589impl ModelEndpoint for GithubModelsEndpoint {
2590 fn uri(&self) -> &'static str {
2591 match self {
2592 Self::Default => "https://models.github.ai/inference",
2593 }
2594 }
2595}
2596#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2597#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2598#[prefix = "providers.models.github_models"]
2599pub struct GithubModelsModelProviderConfig {
2600 #[nested]
2601 #[serde(flatten)]
2602 pub base: ModelProviderConfig,
2603}
2604
2605#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2608#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2609#[serde(rename_all = "snake_case")]
2610pub enum UpstageEndpoint {
2611 #[default]
2612 Default,
2613}
2614impl ModelEndpoint for UpstageEndpoint {
2615 fn uri(&self) -> &'static str {
2616 match self {
2617 Self::Default => "https://api.upstage.ai/v1",
2618 }
2619 }
2620}
2621#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2622#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2623#[prefix = "providers.models.upstage"]
2624pub struct UpstageModelProviderConfig {
2625 #[nested]
2626 #[serde(flatten)]
2627 pub base: ModelProviderConfig,
2628}
2629
2630#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2633#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2634#[serde(rename_all = "snake_case")]
2635pub enum FeatherlessEndpoint {
2636 #[default]
2637 Default,
2638}
2639impl ModelEndpoint for FeatherlessEndpoint {
2640 fn uri(&self) -> &'static str {
2641 match self {
2642 Self::Default => "https://api.featherless.ai/v1",
2643 }
2644 }
2645}
2646#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2647#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2648#[prefix = "providers.models.featherless"]
2649pub struct FeatherlessModelProviderConfig {
2650 #[nested]
2651 #[serde(flatten)]
2652 pub base: ModelProviderConfig,
2653}
2654
2655#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2658#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2659#[serde(rename_all = "snake_case")]
2660pub enum ArceeEndpoint {
2661 #[default]
2662 Default,
2663}
2664impl ModelEndpoint for ArceeEndpoint {
2665 fn uri(&self) -> &'static str {
2666 match self {
2667 Self::Default => "https://api.arcee.ai/api/v1",
2670 }
2671 }
2672}
2673#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2674#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2675#[prefix = "providers.models.arcee"]
2676pub struct ArceeModelProviderConfig {
2677 #[nested]
2678 #[serde(flatten)]
2679 pub base: ModelProviderConfig,
2680}
2681
2682#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2685#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2686#[serde(rename_all = "snake_case")]
2687pub enum LambdaAiEndpoint {
2688 #[default]
2689 Default,
2690}
2691impl ModelEndpoint for LambdaAiEndpoint {
2692 fn uri(&self) -> &'static str {
2693 match self {
2694 Self::Default => "https://api.lambda.ai/v1",
2695 }
2696 }
2697}
2698#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2699#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2700#[prefix = "providers.models.lambda_ai"]
2701pub struct LambdaAiModelProviderConfig {
2702 #[nested]
2703 #[serde(flatten)]
2704 pub base: ModelProviderConfig,
2705}
2706
2707#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2710#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2711#[serde(rename_all = "snake_case")]
2712pub enum InceptionEndpoint {
2713 #[default]
2714 Default,
2715}
2716impl ModelEndpoint for InceptionEndpoint {
2717 fn uri(&self) -> &'static str {
2718 match self {
2719 Self::Default => "https://api.inceptionlabs.ai/v1",
2720 }
2721 }
2722}
2723#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2724#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2725#[prefix = "providers.models.inception"]
2726pub struct InceptionModelProviderConfig {
2727 #[nested]
2728 #[serde(flatten)]
2729 pub base: ModelProviderConfig,
2730}
2731
2732#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2735#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2736#[serde(rename_all = "snake_case")]
2737pub enum SyntheticEndpoint {
2738 #[default]
2739 Default,
2740}
2741impl ModelEndpoint for SyntheticEndpoint {
2742 fn uri(&self) -> &'static str {
2743 match self {
2744 Self::Default => "https://api.synthetic.new/openai/v1",
2745 }
2746 }
2747}
2748#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2749#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2750#[prefix = "providers.models.synthetic"]
2751pub struct SyntheticModelProviderConfig {
2752 #[nested]
2753 #[serde(flatten)]
2754 pub base: ModelProviderConfig,
2755}
2756
2757#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2760#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2761#[serde(rename_all = "snake_case")]
2762pub enum OpencodeEndpoint {
2763 #[default]
2764 Default,
2765}
2766impl ModelEndpoint for OpencodeEndpoint {
2767 fn uri(&self) -> &'static str {
2768 match self {
2769 Self::Default => "https://opencode.ai/zen/v1",
2770 }
2771 }
2772}
2773#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2774#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2775#[prefix = "providers.models.opencode"]
2776pub struct OpencodeModelProviderConfig {
2777 #[nested]
2778 #[serde(flatten)]
2779 pub base: ModelProviderConfig,
2780}
2781
2782#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2785#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2786#[serde(rename_all = "snake_case")]
2787pub enum KiloCliEndpoint {
2788 #[default]
2789 LocalSubprocess,
2790}
2791impl ModelEndpoint for KiloCliEndpoint {
2792 fn uri(&self) -> &'static str {
2793 match self {
2794 Self::LocalSubprocess => "subprocess://kilocli",
2795 }
2796 }
2797}
2798#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2799#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2800#[prefix = "providers.models.kilocli"]
2801pub struct KiloCliModelProviderConfig {
2802 #[nested]
2803 #[serde(flatten)]
2804 pub base: ModelProviderConfig,
2805 #[serde(default, skip_serializing_if = "Option::is_none")]
2807 pub binary_path: Option<String>,
2808}
2809
2810#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2814#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2815#[serde(rename_all = "snake_case")]
2816pub enum KiloEndpoint {
2817 #[default]
2818 Gateway,
2819}
2820impl ModelEndpoint for KiloEndpoint {
2821 fn uri(&self) -> &'static str {
2822 match self {
2823 Self::Gateway => "https://api.kilo.ai/api/gateway",
2824 }
2825 }
2826}
2827
2828#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2829#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2830#[prefix = "providers.models.kilo"]
2831pub struct KiloModelProviderConfig {
2832 #[nested]
2833 #[serde(flatten)]
2834 pub base: ModelProviderConfig,
2835 #[serde(default, skip_serializing_if = "KiloEndpoint::is_default")]
2837 pub endpoint: KiloEndpoint,
2838}
2839
2840impl KiloEndpoint {
2841 fn is_default(&self) -> bool {
2842 matches!(self, Self::Gateway)
2843 }
2844}
2845
2846impl FamilyEndpoint for KiloModelProviderConfig {
2847 fn endpoint_uri(&self) -> Option<&'static str> {
2848 Some(self.endpoint.uri())
2849 }
2850}
2851
2852#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2859#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2860#[serde(rename_all = "snake_case")]
2861pub enum CustomEndpoint {
2862 #[default]
2863 OperatorSupplied,
2864}
2865impl ModelEndpoint for CustomEndpoint {
2866 fn uri(&self) -> &'static str {
2867 match self {
2868 Self::OperatorSupplied => "operator-supplied:set-cfg-uri",
2869 }
2870 }
2871}
2872#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2873#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2874#[prefix = "providers.models.custom"]
2875pub struct CustomModelProviderConfig {
2876 #[nested]
2877 #[serde(flatten)]
2878 pub base: ModelProviderConfig,
2879}
2880
2881#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2886#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2887#[serde(rename_all = "snake_case")]
2888pub enum BedrockEndpoint {
2889 #[default]
2890 Default,
2891}
2892
2893impl ModelEndpoint for BedrockEndpoint {
2894 fn uri(&self) -> &'static str {
2895 match self {
2896 Self::Default => "https://bedrock-runtime.{region}.amazonaws.com",
2899 }
2900 }
2901}
2902
2903#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2908#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2909#[prefix = "providers.models.bedrock"]
2910pub struct BedrockModelProviderConfig {
2911 #[nested]
2912 #[serde(flatten)]
2913 pub base: ModelProviderConfig,
2914 #[serde(default, skip_serializing_if = "Option::is_none")]
2916 pub region: Option<String>,
2917}
2918
2919macro_rules! impl_default_family_endpoint {
2929 ($($t:ty),+ $(,)?) => {
2930 $( impl FamilyEndpoint for $t {} )+
2931 };
2932}
2933
2934impl_default_family_endpoint! {
2935 OpenAIModelProviderConfig,
2936 AzureModelProviderConfig,
2937 AnthropicModelProviderConfig,
2938 AtomicChatModelProviderConfig,
2939 OpenRouterModelProviderConfig,
2940 OllamaModelProviderConfig,
2941 TogetherModelProviderConfig,
2942 FireworksModelProviderConfig,
2943 GroqModelProviderConfig,
2944 MistralModelProviderConfig,
2945 DeepseekModelProviderConfig,
2946 CohereModelProviderConfig,
2947 PerplexityModelProviderConfig,
2948 XaiModelProviderConfig,
2949 CerebrasModelProviderConfig,
2950 SambanovaModelProviderConfig,
2951 HyperbolicModelProviderConfig,
2952 DeepinfraModelProviderConfig,
2953 HuggingfaceModelProviderConfig,
2954 Ai21ModelProviderConfig,
2955 RekaModelProviderConfig,
2956 BasetenModelProviderConfig,
2957 NscaleModelProviderConfig,
2958 AnyscaleModelProviderConfig,
2959 NebiusModelProviderConfig,
2960 FriendliModelProviderConfig,
2961 AihubmixModelProviderConfig,
2962 SiliconflowModelProviderConfig,
2963 AstraiModelProviderConfig,
2964 AvianModelProviderConfig,
2965 DeepmystModelProviderConfig,
2966 VeniceModelProviderConfig,
2967 NovitaModelProviderConfig,
2968 NvidiaModelProviderConfig,
2969 TelnyxModelProviderConfig,
2970 VercelModelProviderConfig,
2971 CloudflareModelProviderConfig,
2972 OvhModelProviderConfig,
2973 CopilotModelProviderConfig,
2974 DoubaoModelProviderConfig,
2975 YiModelProviderConfig,
2976 HunyuanModelProviderConfig,
2977 QianfanModelProviderConfig,
2978 BaichuanModelProviderConfig,
2979 GeminiModelProviderConfig,
2980 GeminiCliModelProviderConfig,
2981 LmstudioModelProviderConfig,
2982 LlamacppModelProviderConfig,
2983 SglangModelProviderConfig,
2984 VllmModelProviderConfig,
2985 OsaurusModelProviderConfig,
2986 LitellmModelProviderConfig,
2987 LeptonModelProviderConfig,
2988 MorphModelProviderConfig,
2989 GithubModelsModelProviderConfig,
2990 UpstageModelProviderConfig,
2991 FeatherlessModelProviderConfig,
2992 ArceeModelProviderConfig,
2993 LambdaAiModelProviderConfig,
2994 InceptionModelProviderConfig,
2995 SyntheticModelProviderConfig,
2996 OpencodeModelProviderConfig,
2997 KiloCliModelProviderConfig,
2998 CustomModelProviderConfig,
2999 BedrockModelProviderConfig,
3000}
3001
3002#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3006#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3007#[prefix = "delegate"]
3008pub struct DelegateToolConfig {
3009 #[serde(default = "default_delegate_timeout_secs")]
3013 pub timeout_secs: u64,
3014 #[serde(default = "default_delegate_agentic_timeout_secs")]
3018 pub agentic_timeout_secs: u64,
3019}
3020
3021impl Default for DelegateToolConfig {
3022 fn default() -> Self {
3023 Self {
3024 timeout_secs: DEFAULT_DELEGATE_TIMEOUT_SECS,
3025 agentic_timeout_secs: DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS,
3026 }
3027 }
3028}
3029
3030#[derive(Debug, Clone)]
3036pub struct ResolvedRuntime {
3037 pub compact_context: bool,
3038 pub max_tool_iterations: usize,
3039 pub max_history_messages: usize,
3040 pub max_context_tokens: usize,
3041 pub parallel_tools: bool,
3042 pub tool_dispatcher: String,
3043 pub strict_tool_parsing: bool,
3044 pub tool_call_dedup_exempt: Vec<String>,
3045 pub tool_filter_groups: Vec<ToolFilterGroup>,
3046 pub max_system_prompt_chars: usize,
3047 pub thinking: crate::scattered_types::ThinkingConfig,
3048 pub history_pruning: crate::scattered_types::HistoryPrunerConfig,
3049 pub context_aware_tools: bool,
3050 pub eval: crate::scattered_types::EvalConfig,
3051 pub auto_classify: Option<crate::scattered_types::AutoClassifyConfig>,
3052 pub context_compression: crate::scattered_types::ContextCompressionConfig,
3053 pub max_tool_result_chars: usize,
3054 pub keep_tool_context_turns: usize,
3055 pub tool_receipts: ToolReceiptsConfig,
3056}
3057
3058impl Default for ResolvedRuntime {
3059 fn default() -> Self {
3060 Self {
3061 compact_context: true,
3062 max_tool_iterations: 10,
3063 max_history_messages: 50,
3064 max_context_tokens: 32_000,
3065 parallel_tools: false,
3066 tool_dispatcher: default_agent_tool_dispatcher(),
3067 strict_tool_parsing: false,
3068 tool_call_dedup_exempt: Vec::new(),
3069 tool_filter_groups: Vec::new(),
3070 max_system_prompt_chars: default_max_system_prompt_chars(),
3071 thinking: crate::scattered_types::ThinkingConfig::default(),
3072 history_pruning: crate::scattered_types::HistoryPrunerConfig::default(),
3073 context_aware_tools: false,
3074 eval: crate::scattered_types::EvalConfig::default(),
3075 auto_classify: None,
3076 context_compression: crate::scattered_types::ContextCompressionConfig::default(),
3077 max_tool_result_chars: default_max_tool_result_chars(),
3078 keep_tool_context_turns: default_keep_tool_context_turns(),
3079 tool_receipts: ToolReceiptsConfig::default(),
3080 }
3081 }
3082}
3083
3084#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3088#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3089#[prefix = "delegate_agent"]
3090pub struct AliasedAgentConfig {
3091 #[tab(General)]
3093 #[serde(default = "default_true")]
3094 pub enabled: bool,
3095 #[tab(Channels)]
3099 #[serde(default)]
3100 pub channels: Vec<crate::providers::ChannelRef>,
3101 #[tab(Providers)]
3105 #[serde(default)]
3106 pub model_provider: crate::providers::ModelProviderRef,
3107 #[tab(General)]
3109 #[serde(default)]
3110 pub risk_profile: String,
3111 #[tab(General)]
3113 #[serde(default)]
3114 pub runtime_profile: String,
3115 #[tab(Bundles)]
3119 #[serde(default)]
3120 pub skill_bundles: Vec<String>,
3121 #[tab(Bundles)]
3124 #[serde(default)]
3125 pub knowledge_bundles: Vec<String>,
3126 #[tab(Bundles)]
3130 #[serde(default)]
3131 pub mcp_bundles: Vec<String>,
3132 #[tab(Cron)]
3136 #[serde(default)]
3137 pub cron_jobs: Vec<String>,
3138 #[tab(Providers)]
3143 #[serde(default)]
3144 pub tts_provider: crate::providers::TtsProviderRef,
3145 #[tab(Providers)]
3153 #[serde(default)]
3154 pub transcription_provider: crate::providers::TranscriptionProviderRef,
3155
3156 #[tab(Providers)]
3181 #[serde(default)]
3182 pub classifier_provider: crate::providers::ModelProviderRef,
3183
3184 #[serde(skip)]
3187 pub resolved: ResolvedRuntime,
3188
3189 #[tab(Workspace)]
3195 #[serde(default)]
3196 #[nested]
3197 pub workspace: crate::multi_agent::AgentWorkspaceConfig,
3198
3199 #[tab(Memory)]
3204 #[serde(default)]
3205 #[nested]
3206 pub memory: crate::multi_agent::AgentMemoryConfig,
3207
3208 #[tab(Tuning)]
3214 #[serde(default)]
3215 #[nested]
3216 pub identity: IdentityConfig,
3217}
3218
3219impl Default for AliasedAgentConfig {
3220 fn default() -> Self {
3221 Self {
3222 enabled: true,
3223 channels: Vec::new(),
3224 model_provider: crate::providers::ModelProviderRef::default(),
3225 risk_profile: String::new(),
3226 runtime_profile: String::new(),
3227 skill_bundles: Vec::new(),
3228 knowledge_bundles: Vec::new(),
3229 mcp_bundles: Vec::new(),
3230 cron_jobs: Vec::new(),
3231 tts_provider: crate::providers::TtsProviderRef::default(),
3232 transcription_provider: crate::providers::TranscriptionProviderRef::default(),
3233 classifier_provider: crate::providers::ModelProviderRef::default(),
3234 resolved: ResolvedRuntime::default(),
3235 workspace: crate::multi_agent::AgentWorkspaceConfig::default(),
3236 memory: crate::multi_agent::AgentMemoryConfig::default(),
3237 identity: IdentityConfig::default(),
3238 }
3239 }
3240}
3241
3242impl AliasedAgentConfig {
3243 #[must_use]
3248 pub fn is_dispatchable(&self) -> bool {
3249 self.enabled
3250 && !self.model_provider.is_empty()
3251 && !self.risk_profile.trim().is_empty()
3252 && !self.runtime_profile.trim().is_empty()
3253 }
3254}
3255
3256#[derive(Debug, Clone, Serialize, Deserialize)]
3260#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3261pub struct ChannelAliasInfo {
3262 pub channel_type: String,
3265 pub alias: String,
3267 pub owning_agent: Option<String>,
3270 pub enabled: bool,
3273}
3274
3275impl Config {
3276 #[must_use]
3282 pub fn resolve_default_model(&self) -> Option<String> {
3283 self.providers
3284 .models
3285 .iter_entries()
3286 .filter_map(|(_, _, base)| base.model.as_deref().map(str::trim))
3287 .find(|m| !m.is_empty())
3288 .map(ToString::to_string)
3289 }
3290
3291 #[must_use]
3300 pub fn risk_profile_for_agent(&self, agent_alias: &str) -> Option<&RiskProfileConfig> {
3301 let agent = self.agents.get(agent_alias)?;
3302 let profile_alias = agent.risk_profile.trim();
3303 if profile_alias.is_empty() {
3304 return None;
3305 }
3306 self.risk_profiles.get(profile_alias)
3307 }
3308
3309 #[must_use]
3315 pub fn runtime_profile_for_agent(&self, agent_alias: &str) -> Option<&RuntimeProfileConfig> {
3316 let agent = self.agents.get(agent_alias)?;
3317 let profile_alias = agent.runtime_profile.trim();
3318 if profile_alias.is_empty() {
3319 return None;
3320 }
3321 self.runtime_profiles.get(profile_alias)
3322 }
3323
3324 #[must_use]
3335 pub fn effective_max_tool_iterations(&self, agent_alias: &str) -> usize {
3336 self.runtime_profile_for_agent(agent_alias)
3337 .map(|p| p.max_tool_iterations)
3338 .filter(|&v| v > 0)
3339 .unwrap_or(10)
3340 }
3341
3342 #[must_use]
3343 pub fn effective_max_history_messages(&self, agent_alias: &str) -> usize {
3344 self.runtime_profile_for_agent(agent_alias)
3345 .and_then(|p| p.max_history_messages)
3346 .unwrap_or(50)
3347 }
3348
3349 #[must_use]
3350 pub fn effective_max_context_tokens(&self, agent_alias: &str) -> usize {
3351 self.runtime_profile_for_agent(agent_alias)
3352 .and_then(|p| p.max_context_tokens)
3353 .unwrap_or(32_000)
3354 }
3355
3356 #[must_use]
3357 pub fn effective_memory_recall_limit(&self, agent_alias: &str) -> usize {
3358 let raw = self
3359 .runtime_profile_for_agent(agent_alias)
3360 .and_then(|p| p.memory_recall_limit)
3361 .unwrap_or(5);
3362 if raw == 0 { usize::MAX } else { raw }
3363 }
3364
3365 #[must_use]
3366 pub fn effective_compact_context(&self, agent_alias: &str) -> bool {
3367 self.runtime_profile_for_agent(agent_alias)
3368 .and_then(|p| p.compact_context)
3369 .unwrap_or(true)
3370 }
3371
3372 #[must_use]
3373 pub fn effective_parallel_tools(&self, agent_alias: &str) -> bool {
3374 self.runtime_profile_for_agent(agent_alias)
3375 .and_then(|p| p.parallel_tools)
3376 .unwrap_or(false)
3377 }
3378
3379 #[must_use]
3380 pub fn effective_tool_dispatcher(&self, agent_alias: &str) -> String {
3381 self.runtime_profile_for_agent(agent_alias)
3382 .and_then(|p| p.tool_dispatcher.as_ref())
3383 .filter(|s| !s.trim().is_empty())
3384 .map_or_else(default_agent_tool_dispatcher, Clone::clone)
3385 }
3386
3387 #[must_use]
3388 pub fn effective_tool_call_dedup_exempt(&self, agent_alias: &str) -> Vec<String> {
3389 self.runtime_profile_for_agent(agent_alias)
3390 .map(|p| p.tool_call_dedup_exempt.clone())
3391 .unwrap_or_default()
3392 }
3393
3394 #[must_use]
3395 pub fn effective_max_system_prompt_chars(&self, agent_alias: &str) -> usize {
3396 self.runtime_profile_for_agent(agent_alias)
3397 .and_then(|p| p.max_system_prompt_chars)
3398 .unwrap_or_else(default_max_system_prompt_chars)
3399 }
3400
3401 #[must_use]
3402 pub fn effective_context_aware_tools(&self, agent_alias: &str) -> bool {
3403 self.runtime_profile_for_agent(agent_alias)
3404 .and_then(|p| p.context_aware_tools)
3405 .unwrap_or(false)
3406 }
3407
3408 #[must_use]
3409 pub fn effective_max_tool_result_chars(&self, agent_alias: &str) -> usize {
3410 self.runtime_profile_for_agent(agent_alias)
3411 .and_then(|p| p.max_tool_result_chars)
3412 .unwrap_or_else(default_max_tool_result_chars)
3413 }
3414
3415 #[must_use]
3416 pub fn effective_keep_tool_context_turns(&self, agent_alias: &str) -> usize {
3417 self.runtime_profile_for_agent(agent_alias)
3418 .and_then(|p| p.keep_tool_context_turns)
3419 .unwrap_or_else(default_keep_tool_context_turns)
3420 }
3421
3422 #[must_use]
3436 pub fn resolved_agent_config(&self, agent_alias: &str) -> Option<AliasedAgentConfig> {
3437 let mut out = self.agents.get(agent_alias)?.clone();
3438 let mut resolved = ResolvedRuntime {
3439 max_tool_iterations: self.effective_max_tool_iterations(agent_alias),
3440 max_history_messages: self.effective_max_history_messages(agent_alias),
3441 max_context_tokens: self.effective_max_context_tokens(agent_alias),
3442 compact_context: self.effective_compact_context(agent_alias),
3443 parallel_tools: self.effective_parallel_tools(agent_alias),
3444 tool_dispatcher: self.effective_tool_dispatcher(agent_alias),
3445 tool_call_dedup_exempt: self.effective_tool_call_dedup_exempt(agent_alias),
3446 max_system_prompt_chars: self.effective_max_system_prompt_chars(agent_alias),
3447 context_aware_tools: self.effective_context_aware_tools(agent_alias),
3448 max_tool_result_chars: self.effective_max_tool_result_chars(agent_alias),
3449 keep_tool_context_turns: self.effective_keep_tool_context_turns(agent_alias),
3450 ..ResolvedRuntime::default()
3451 };
3452 if let Some(profile) = self.runtime_profile_for_agent(agent_alias) {
3453 resolved.strict_tool_parsing = profile.strict_tool_parsing;
3454 resolved.thinking = profile.thinking.clone();
3455 resolved.history_pruning = profile.history_pruning.clone();
3456 resolved.eval = profile.eval.clone();
3457 resolved.auto_classify = profile.auto_classify.clone();
3458 resolved.context_compression = profile.context_compression.clone();
3459 resolved.tool_receipts = profile.tool_receipts.clone();
3460 resolved.tool_filter_groups = profile.tool_filter_groups.clone();
3461 }
3462 out.resolved = resolved;
3463 Some(out)
3464 }
3465
3466 #[must_use]
3479 pub fn model_provider_for_agent(&self, agent_alias: &str) -> Option<&ModelProviderConfig> {
3480 let agent = self.agents.get(agent_alias)?;
3481 let (type_key, alias_key) = agent.model_provider.split_once('.')?;
3482 self.providers.models.find(type_key, alias_key)
3483 }
3484
3485 #[must_use]
3493 pub fn resolved_model_provider_for_agent(
3494 &self,
3495 agent_alias: &str,
3496 ) -> Option<(&'static str, &str, &ModelProviderConfig)> {
3497 let agent = self.agents.get(agent_alias)?;
3498 let (type_key, alias_key) = agent.model_provider.split_once('.')?;
3499 self.providers
3500 .models
3501 .iter_entries()
3502 .find(|(ty, al, _)| *ty == type_key && *al == alias_key)
3503 }
3504
3505 #[must_use]
3511 pub fn agent_for_channel(&self, channel_alias: &str) -> Option<&str> {
3512 self.agents
3513 .iter()
3514 .find(|(_, agent)| agent.enabled && agent.channels.iter().any(|c| c == channel_alias))
3515 .map(|(alias, _)| alias.as_str())
3516 }
3517
3518 #[must_use]
3522 pub fn channel_workspace_dir(&self, channel_ref: &str) -> PathBuf {
3523 self.agent_for_channel(channel_ref)
3524 .map_or_else(|| self.data_dir.clone(), |a| self.agent_workspace_dir(a))
3525 }
3526
3527 #[must_use]
3533 pub fn channels_by_alias(&self) -> Vec<ChannelAliasInfo> {
3534 use std::collections::BTreeMap;
3535 let mut seen: BTreeMap<(String, String), bool> = BTreeMap::new();
3536 for field in self.prop_fields() {
3537 let parts: Vec<&str> = field.name.split('.').collect();
3538 if parts.len() < 4 || parts[0] != "channels" {
3539 continue;
3540 }
3541 let key = (parts[1].to_string(), parts[2].to_string());
3542 let entry = seen.entry(key).or_insert(false);
3543 if parts.len() == 4 && parts[3] == "enabled" {
3544 *entry = field.display_value == "true";
3545 }
3546 }
3547 seen.into_iter()
3548 .map(|((channel_type, alias), enabled)| {
3549 let composite = format!("{channel_type}.{alias}");
3550 let owning_agent = self.agent_for_channel(&composite).map(str::to_string);
3551 ChannelAliasInfo {
3552 channel_type,
3553 alias,
3554 owning_agent,
3555 enabled,
3556 }
3557 })
3558 .collect()
3559 }
3560
3561 #[must_use]
3570 pub fn agent_for_cron_job(&self, cron_alias: &str) -> Option<&str> {
3571 self.agents
3572 .iter()
3573 .find(|(_, agent)| agent.enabled && agent.cron_jobs.iter().any(|c| c == cron_alias))
3574 .map(|(alias, _)| alias.as_str())
3575 }
3576
3577 #[must_use]
3594 pub fn agent_workspace_dir(&self, agent_alias: &str) -> std::path::PathBuf {
3595 if let Some(cfg) = self.agents.get(agent_alias)
3596 && let Some(custom) = cfg.workspace.path.as_ref()
3597 {
3598 return custom.clone();
3599 }
3600 self.install_root_dir()
3601 .join("agents")
3602 .join(agent_alias)
3603 .join("workspace")
3604 }
3605
3606 #[must_use]
3612 pub fn shared_workspace_dir(&self) -> std::path::PathBuf {
3613 self.install_root_dir().join("shared")
3614 }
3615
3616 #[must_use]
3621 pub fn install_root_dir(&self) -> std::path::PathBuf {
3622 self.config_path
3623 .parent()
3624 .map(std::path::Path::to_path_buf)
3625 .unwrap_or_else(|| std::path::PathBuf::from("."))
3626 }
3627
3628 #[must_use]
3632 pub fn agent(&self, agent_alias: &str) -> Option<&AliasedAgentConfig> {
3633 self.agents.get(agent_alias)
3634 }
3635
3636 #[must_use]
3649 pub fn resolved_runtime_agent_alias(&self) -> Option<&str> {
3650 self.agents
3651 .keys()
3652 .find(|k| k.as_str() == "default")
3653 .map(String::as_str)
3654 .or_else(|| {
3655 self.agents
3656 .iter()
3657 .filter(|(_, a)| a.enabled)
3658 .map(|(alias, _)| alias.as_str())
3659 .min()
3660 })
3661 }
3662
3663 pub fn resolve_active_storage(&self) -> ActiveStorage<'_> {
3673 let backend = self.memory.backend.trim();
3674 if backend.is_empty() || backend.eq_ignore_ascii_case("none") {
3675 return ActiveStorage::None;
3676 }
3677 let (kind, alias) = backend.split_once('.').unwrap_or((backend, "default"));
3678 match kind {
3679 "sqlite" => self
3680 .storage
3681 .sqlite
3682 .get(alias)
3683 .map(ActiveStorage::Sqlite)
3684 .unwrap_or(ActiveStorage::None),
3685 "postgres" => self
3686 .storage
3687 .postgres
3688 .get(alias)
3689 .map(ActiveStorage::Postgres)
3690 .unwrap_or(ActiveStorage::None),
3691 "qdrant" => self
3692 .storage
3693 .qdrant
3694 .get(alias)
3695 .map(ActiveStorage::Qdrant)
3696 .unwrap_or(ActiveStorage::None),
3697 "markdown" => self
3698 .storage
3699 .markdown
3700 .get(alias)
3701 .map(ActiveStorage::Markdown)
3702 .unwrap_or(ActiveStorage::None),
3703 "lucid" => self
3704 .storage
3705 .lucid
3706 .get(alias)
3707 .map(ActiveStorage::Lucid)
3708 .unwrap_or(ActiveStorage::None),
3709 _ => ActiveStorage::None,
3710 }
3711 }
3712}
3713
3714#[derive(Debug, Clone, Copy)]
3719pub enum ActiveStorage<'a> {
3720 None,
3722 Sqlite(&'a SqliteStorageConfig),
3724 Postgres(&'a PostgresStorageConfig),
3726 Qdrant(&'a QdrantStorageConfig),
3728 Markdown(&'a MarkdownStorageConfig),
3730 Lucid(&'a LucidStorageConfig),
3732}
3733
3734impl ActiveStorage<'_> {
3735 #[must_use]
3737 pub fn kind(&self) -> &'static str {
3738 match self {
3739 ActiveStorage::None => "none",
3740 ActiveStorage::Sqlite(_) => "sqlite",
3741 ActiveStorage::Postgres(_) => "postgres",
3742 ActiveStorage::Qdrant(_) => "qdrant",
3743 ActiveStorage::Markdown(_) => "markdown",
3744 ActiveStorage::Lucid(_) => "lucid",
3745 }
3746 }
3747}
3748
3749fn default_delegate_timeout_secs() -> u64 {
3750 DEFAULT_DELEGATE_TIMEOUT_SECS
3751}
3752
3753fn default_delegate_agentic_timeout_secs() -> u64 {
3754 DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS
3755}
3756
3757pub const TEMPERATURE_RANGE: std::ops::RangeInclusive<f64> = 0.0..=2.0;
3759
3760fn default_schema_version() -> u32 {
3763 0
3764}
3765
3766pub const DEFAULT_DELEGATE_TIMEOUT_SECS: u64 = 120;
3768
3769pub const DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS: u64 = 300;
3771
3772pub trait HasReplyPacing {
3777 fn reply_min_interval_secs(&self) -> u64;
3778 fn reply_queue_depth_max(&self) -> u16;
3779}
3780
3781macro_rules! impl_reply_pacing {
3782 ($($ty:ty),+ $(,)?) => {
3783 $(impl HasReplyPacing for $ty {
3784 fn reply_min_interval_secs(&self) -> u64 { self.reply_min_interval_secs }
3785 fn reply_queue_depth_max(&self) -> u16 { self.reply_queue_depth_max }
3786 })+
3787 };
3788}
3789
3790pub const REPLY_MIN_INTERVAL_MAX_SECS: u64 = 3600;
3792
3793pub const REPLY_QUEUE_DEPTH_CEILING: u16 = 1024;
3798
3799pub const DEFAULT_REPLY_QUEUE_DEPTH: u16 = 16;
3805
3806pub const PACING_RECIPIENT_CAP: usize = 1024;
3815
3816pub fn validate_temperature(value: f64) -> std::result::Result<f64, String> {
3818 if TEMPERATURE_RANGE.contains(&value) {
3819 Ok(value)
3820 } else {
3821 Err(format!(
3822 "temperature {value} is out of range (expected {}..={})",
3823 TEMPERATURE_RANGE.start(),
3824 TEMPERATURE_RANGE.end()
3825 ))
3826 }
3827}
3828
3829fn normalize_reasoning_effort(value: &str) -> std::result::Result<String, String> {
3830 let normalized = value.trim().to_ascii_lowercase();
3831 match normalized.as_str() {
3832 "minimal" | "low" | "medium" | "high" | "xhigh" => Ok(normalized),
3833 _ => Err(format!(
3834 "reasoning_effort {value:?} is invalid (expected one of: minimal, low, medium, high, xhigh)"
3835 )),
3836 }
3837}
3838
3839fn deserialize_reasoning_effort_opt<'de, D>(
3840 deserializer: D,
3841) -> std::result::Result<Option<String>, D::Error>
3842where
3843 D: serde::Deserializer<'de>,
3844{
3845 let value: Option<String> = Option::deserialize(deserializer)?;
3846 value
3847 .map(|raw| normalize_reasoning_effort(&raw).map_err(serde::de::Error::custom))
3848 .transpose()
3849}
3850
3851fn deserialize_optional_email_skip_empty<'de, D>(
3859 deserializer: D,
3860) -> std::result::Result<Option<String>, D::Error>
3861where
3862 D: serde::Deserializer<'de>,
3863{
3864 let value: Option<String> = Option::deserialize(deserializer)?;
3865 Ok(value.filter(|s| !s.trim().is_empty()))
3866}
3867
3868#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
3872#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3873pub enum HardwareTransport {
3874 #[default]
3875 None,
3876 Native,
3877 Serial,
3878 Probe,
3879}
3880
3881impl std::fmt::Display for HardwareTransport {
3882 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3883 match self {
3884 Self::None => write!(f, "none"),
3885 Self::Native => write!(f, "native"),
3886 Self::Serial => write!(f, "serial"),
3887 Self::Probe => write!(f, "probe"),
3888 }
3889 }
3890}
3891
3892#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3894#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3895#[prefix = "hardware"]
3896pub struct HardwareConfig {
3897 #[serde(default)]
3899 pub enabled: bool,
3900 #[serde(default)]
3902 pub transport: HardwareTransport,
3903 #[serde(default)]
3905 pub serial_port: Option<String>,
3906 #[serde(default = "default_baud_rate")]
3908 pub baud_rate: u32,
3909 #[serde(default)]
3911 pub probe_target: Option<String>,
3912 #[serde(default)]
3914 pub workspace_datasheets: bool,
3915}
3916
3917fn default_baud_rate() -> u32 {
3918 115_200
3919}
3920
3921impl HardwareConfig {
3922 pub fn transport_mode(&self) -> HardwareTransport {
3924 self.transport.clone()
3925 }
3926}
3927
3928impl Default for HardwareConfig {
3929 fn default() -> Self {
3930 Self {
3931 enabled: false,
3932 transport: HardwareTransport::None,
3933 serial_port: None,
3934 baud_rate: default_baud_rate(),
3935 probe_target: None,
3936 workspace_datasheets: false,
3937 }
3938 }
3939}
3940
3941fn default_transcription_api_url() -> String {
3944 "https://api.groq.com/openai/v1/audio/transcriptions".into()
3945}
3946
3947fn default_transcription_model() -> String {
3948 "whisper-large-v3-turbo".into()
3949}
3950
3951fn default_transcription_max_duration_secs() -> u64 {
3952 120
3953}
3954
3955fn default_openai_stt_model() -> String {
3956 "whisper-1".into()
3957}
3958
3959fn default_deepgram_stt_model() -> String {
3960 "nova-2".into()
3961}
3962
3963fn default_google_stt_language_code() -> String {
3964 "en-US".into()
3965}
3966
3967#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3972#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3973#[prefix = "transcription"]
3974pub struct TranscriptionConfig {
3975 #[serde(default)]
3977 pub enabled: bool,
3978 #[serde(default)]
3982 #[secret]
3983 #[credential_class = "encrypted_secret"]
3984 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
3985 pub api_key: Option<String>,
3986 #[serde(default = "default_transcription_api_url")]
3988 pub api_url: String,
3989 #[serde(default = "default_transcription_model")]
3991 pub model: String,
3992 #[serde(default)]
3994 pub language: Option<String>,
3995 #[serde(default)]
3999 pub initial_prompt: Option<String>,
4000 #[serde(default)]
4004 pub max_audio_bytes: Option<usize>,
4005 #[serde(default = "default_transcription_max_duration_secs")]
4007 pub max_duration_secs: u64,
4008 #[serde(default)]
4010 #[nested]
4011 pub openai: Option<OpenAiSttConfig>,
4012 #[serde(default)]
4014 #[nested]
4015 pub deepgram: Option<DeepgramSttConfig>,
4016 #[serde(default)]
4018 #[nested]
4019 pub assemblyai: Option<AssemblyAiSttConfig>,
4020 #[serde(default)]
4022 #[nested]
4023 pub google: Option<GoogleSttConfig>,
4024 #[serde(default)]
4026 #[nested]
4027 pub local_whisper: Option<LocalWhisperConfig>,
4028 #[serde(default)]
4031 pub transcribe_non_ptt_audio: bool,
4032}
4033
4034impl Default for TranscriptionConfig {
4035 fn default() -> Self {
4036 Self {
4037 enabled: false,
4038 api_key: None,
4039 api_url: default_transcription_api_url(),
4040 model: default_transcription_model(),
4041 language: None,
4042 initial_prompt: None,
4043 max_audio_bytes: None,
4044 max_duration_secs: default_transcription_max_duration_secs(),
4045 openai: None,
4046 deepgram: None,
4047 assemblyai: None,
4048 google: None,
4049 local_whisper: None,
4050 transcribe_non_ptt_audio: false,
4051 }
4052 }
4053}
4054
4055#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
4059#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4060#[serde(rename_all = "lowercase")]
4061pub enum McpTransport {
4062 #[default]
4064 Stdio,
4065 Http,
4067 Sse,
4069}
4070
4071#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
4073#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4074#[prefix = "mcp.servers"]
4075pub struct McpServerConfig {
4076 #[serde(default)]
4081 pub name: String,
4082 #[serde(default)]
4084 pub transport: McpTransport,
4085 #[serde(default)]
4087 pub url: Option<String>,
4088 #[serde(default)]
4090 pub command: String,
4091 #[serde(default)]
4093 pub args: Vec<String>,
4094 #[serde(default)]
4096 #[secret]
4097 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4098 pub env: HashMap<String, String>,
4099 #[serde(default)]
4102 #[secret]
4103 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4104 pub headers: HashMap<String, String>,
4105 #[serde(default)]
4107 pub tool_timeout_secs: Option<u64>,
4108}
4109
4110#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4112#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4113#[prefix = "mcp"]
4114pub struct McpConfig {
4115 #[tab(Settings)]
4117 #[serde(default = "default_mcp_enabled")]
4118 pub enabled: bool,
4119 #[tab(Settings)]
4124 #[serde(default = "default_deferred_loading")]
4125 pub deferred_loading: bool,
4126 #[tab(Servers)]
4132 #[serde(default, alias = "mcpServers")]
4133 #[nested]
4134 pub servers: Vec<McpServerConfig>,
4135}
4136
4137fn default_mcp_enabled() -> bool {
4138 true
4139}
4140
4141fn default_deferred_loading() -> bool {
4142 false
4143}
4144
4145impl Default for McpConfig {
4146 fn default() -> Self {
4147 Self {
4148 enabled: default_mcp_enabled(),
4149 deferred_loading: default_deferred_loading(),
4150 servers: Vec::new(),
4151 }
4152 }
4153}
4154
4155#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4157#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4158#[prefix = "verifiable_intent"]
4159pub struct VerifiableIntentConfig {
4160 #[serde(default)]
4162 pub enabled: bool,
4163
4164 #[serde(default = "default_vi_strictness")]
4168 pub strictness: String,
4169}
4170
4171fn default_vi_strictness() -> String {
4172 "strict".to_owned()
4173}
4174
4175impl Default for VerifiableIntentConfig {
4176 fn default() -> Self {
4177 Self {
4178 enabled: false,
4179 strictness: default_vi_strictness(),
4180 }
4181 }
4182}
4183
4184#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4191#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4192#[prefix = "nodes"]
4193pub struct NodesConfig {
4194 #[serde(default)]
4196 pub enabled: bool,
4197 #[serde(default = "default_max_nodes")]
4199 pub max_nodes: usize,
4200 #[serde(default)]
4202 #[secret]
4203 #[credential_class = "encrypted_secret"]
4204 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4205 pub auth_token: Option<String>,
4206}
4207
4208fn default_max_nodes() -> usize {
4209 16
4210}
4211
4212impl Default for NodesConfig {
4213 fn default() -> Self {
4214 Self {
4215 enabled: false,
4216 max_nodes: default_max_nodes(),
4217 auth_token: None,
4218 }
4219 }
4220}
4221
4222fn default_tts_voice() -> String {
4225 "alloy".into()
4226}
4227
4228fn default_tts_format() -> String {
4229 "mp3".into()
4230}
4231
4232fn default_tts_max_text_length() -> usize {
4233 4096
4234}
4235
4236#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4242#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4243#[prefix = "tts"]
4244pub struct TtsConfig {
4245 #[serde(default)]
4247 pub enabled: bool,
4248 #[serde(default = "default_tts_voice")]
4250 pub default_voice: String,
4251 #[serde(default = "default_tts_format")]
4253 pub default_format: String,
4254 #[serde(default = "default_tts_max_text_length")]
4256 pub max_text_length: usize,
4257}
4258
4259impl Default for TtsConfig {
4260 fn default() -> Self {
4261 Self {
4262 enabled: false,
4263 default_voice: default_tts_voice(),
4264 default_format: default_tts_format(),
4265 max_text_length: default_tts_max_text_length(),
4266 }
4267 }
4268}
4269
4270#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4277#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4278#[prefix = "tts_provider"]
4279#[serde(default)]
4280pub struct TtsProviderConfig {
4281 #[secret]
4283 #[credential_class = "encrypted_secret"]
4284 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4285 pub api_key: Option<String>,
4286 pub model: Option<String>,
4289 pub voice: Option<String>,
4292 pub speed: Option<f64>,
4294 pub stability: Option<f64>,
4296 pub similarity_boost: Option<f64>,
4298 pub language_code: Option<String>,
4300 pub binary_path: Option<String>,
4302 pub response_format: Option<String>,
4307 #[serde(alias = "api_url")]
4312 pub uri: Option<String>,
4313}
4314
4315pub trait TtsEndpoint {
4326 fn uri(&self) -> &'static str;
4327}
4328
4329#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4330#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4331#[serde(rename_all = "snake_case")]
4332pub enum OpenAITtsEndpoint {
4333 #[default]
4334 Default,
4335}
4336impl TtsEndpoint for OpenAITtsEndpoint {
4337 fn uri(&self) -> &'static str {
4338 match self {
4339 Self::Default => "https://api.openai.com/v1/audio/speech",
4340 }
4341 }
4342}
4343
4344#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4345#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4346#[prefix = "providers.tts.openai"]
4347pub struct OpenAITtsProviderConfig {
4348 #[nested]
4349 #[serde(flatten)]
4350 pub base: TtsProviderConfig,
4351}
4352
4353#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4354#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4355#[serde(rename_all = "snake_case")]
4356pub enum ElevenLabsTtsEndpoint {
4357 #[default]
4358 Default,
4359}
4360impl TtsEndpoint for ElevenLabsTtsEndpoint {
4361 fn uri(&self) -> &'static str {
4362 match self {
4363 Self::Default => "https://api.elevenlabs.io/v1/text-to-speech",
4364 }
4365 }
4366}
4367
4368#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4369#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4370#[prefix = "providers.tts.elevenlabs"]
4371pub struct ElevenLabsTtsProviderConfig {
4372 #[nested]
4373 #[serde(flatten)]
4374 pub base: TtsProviderConfig,
4375}
4376
4377#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4378#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4379#[serde(rename_all = "snake_case")]
4380pub enum GoogleTtsEndpoint {
4381 #[default]
4382 Default,
4383}
4384impl TtsEndpoint for GoogleTtsEndpoint {
4385 fn uri(&self) -> &'static str {
4386 match self {
4387 Self::Default => "https://texttospeech.googleapis.com/v1/text:synthesize",
4388 }
4389 }
4390}
4391
4392#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4393#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4394#[prefix = "providers.tts.google"]
4395pub struct GoogleTtsProviderConfig {
4396 #[nested]
4397 #[serde(flatten)]
4398 pub base: TtsProviderConfig,
4399}
4400
4401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4402#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4403#[serde(rename_all = "snake_case")]
4404pub enum EdgeTtsEndpoint {
4405 #[default]
4407 LocalSubprocess,
4408}
4409impl TtsEndpoint for EdgeTtsEndpoint {
4410 fn uri(&self) -> &'static str {
4411 match self {
4412 Self::LocalSubprocess => "subprocess://edge-tts",
4413 }
4414 }
4415}
4416
4417#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4418#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4419#[prefix = "providers.tts.edge"]
4420pub struct EdgeTtsProviderConfig {
4421 #[nested]
4422 #[serde(flatten)]
4423 pub base: TtsProviderConfig,
4424}
4425
4426#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4427#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4428#[serde(rename_all = "snake_case")]
4429pub enum PiperTtsEndpoint {
4430 #[default]
4431 LocalDefault,
4432}
4433impl TtsEndpoint for PiperTtsEndpoint {
4434 fn uri(&self) -> &'static str {
4435 match self {
4436 Self::LocalDefault => "http://127.0.0.1:5000/v1/audio/speech",
4437 }
4438 }
4439}
4440
4441#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4442#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4443#[prefix = "providers.tts.piper"]
4444pub struct PiperTtsProviderConfig {
4445 #[nested]
4446 #[serde(flatten)]
4447 pub base: TtsProviderConfig,
4448}
4449
4450#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4462#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4463#[prefix = "providers.transcription"]
4464pub struct TranscriptionProviderConfig {
4465 #[serde(default)]
4467 #[secret]
4468 #[credential_class = "encrypted_secret"]
4469 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4470 pub api_key: Option<String>,
4471 #[serde(default)]
4475 pub language: Option<String>,
4476 #[serde(default)]
4480 pub initial_prompt: Option<String>,
4481}
4482
4483pub trait TranscriptionEndpoint {
4486 fn uri(&self) -> &'static str;
4487}
4488
4489#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4490#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4491#[serde(rename_all = "snake_case")]
4492pub enum GroqTranscriptionEndpoint {
4493 #[default]
4494 Default,
4495}
4496impl TranscriptionEndpoint for GroqTranscriptionEndpoint {
4497 fn uri(&self) -> &'static str {
4498 match self {
4499 Self::Default => "https://api.groq.com/openai/v1/audio/transcriptions",
4500 }
4501 }
4502}
4503
4504#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4505#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4506#[prefix = "providers.transcription.groq"]
4507pub struct GroqTranscriptionProviderConfig {
4508 #[nested]
4509 #[serde(flatten)]
4510 pub base: TranscriptionProviderConfig,
4511 #[serde(default)]
4513 pub model: Option<String>,
4514}
4515
4516#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4517#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4518#[serde(rename_all = "snake_case")]
4519pub enum OpenAiTranscriptionEndpoint {
4520 #[default]
4521 Default,
4522}
4523impl TranscriptionEndpoint for OpenAiTranscriptionEndpoint {
4524 fn uri(&self) -> &'static str {
4525 match self {
4526 Self::Default => "https://api.openai.com/v1/audio/transcriptions",
4527 }
4528 }
4529}
4530
4531#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4532#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4533#[prefix = "providers.transcription.openai"]
4534pub struct OpenAiTranscriptionProviderConfig {
4535 #[nested]
4536 #[serde(flatten)]
4537 pub base: TranscriptionProviderConfig,
4538 #[serde(default)]
4540 pub model: Option<String>,
4541}
4542
4543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4544#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4545#[serde(rename_all = "snake_case")]
4546pub enum DeepgramTranscriptionEndpoint {
4547 #[default]
4548 Default,
4549}
4550impl TranscriptionEndpoint for DeepgramTranscriptionEndpoint {
4551 fn uri(&self) -> &'static str {
4552 match self {
4553 Self::Default => "https://api.deepgram.com/v1/listen",
4554 }
4555 }
4556}
4557
4558#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4559#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4560#[prefix = "providers.transcription.deepgram"]
4561pub struct DeepgramTranscriptionProviderConfig {
4562 #[nested]
4563 #[serde(flatten)]
4564 pub base: TranscriptionProviderConfig,
4565 #[serde(default)]
4567 pub model: Option<String>,
4568}
4569
4570#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4571#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4572#[serde(rename_all = "snake_case")]
4573pub enum AssemblyAiTranscriptionEndpoint {
4574 #[default]
4575 Default,
4576}
4577impl TranscriptionEndpoint for AssemblyAiTranscriptionEndpoint {
4578 fn uri(&self) -> &'static str {
4579 match self {
4580 Self::Default => "https://api.assemblyai.com/v2/transcript",
4581 }
4582 }
4583}
4584
4585#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4586#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4587#[prefix = "providers.transcription.assemblyai"]
4588pub struct AssemblyAiTranscriptionProviderConfig {
4589 #[nested]
4590 #[serde(flatten)]
4591 pub base: TranscriptionProviderConfig,
4592}
4593
4594#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4595#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4596#[serde(rename_all = "snake_case")]
4597pub enum GoogleTranscriptionEndpoint {
4598 #[default]
4599 Default,
4600}
4601impl TranscriptionEndpoint for GoogleTranscriptionEndpoint {
4602 fn uri(&self) -> &'static str {
4603 match self {
4604 Self::Default => "https://speech.googleapis.com/v1/speech:recognize",
4605 }
4606 }
4607}
4608
4609#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4610#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4611#[prefix = "providers.transcription.google"]
4612pub struct GoogleTranscriptionProviderConfig {
4613 #[nested]
4614 #[serde(flatten)]
4615 pub base: TranscriptionProviderConfig,
4616}
4617
4618#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4619#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4620#[serde(rename_all = "snake_case")]
4621pub enum LocalWhisperTranscriptionEndpoint {
4622 #[default]
4625 SelfHosted,
4626}
4627impl TranscriptionEndpoint for LocalWhisperTranscriptionEndpoint {
4628 fn uri(&self) -> &'static str {
4629 match self {
4630 Self::SelfHosted => "self-hosted",
4631 }
4632 }
4633}
4634
4635#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4639#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4640#[prefix = "providers.transcription.local_whisper"]
4641pub struct LocalWhisperTranscriptionProviderConfig {
4642 pub uri: String,
4644 #[serde(default)]
4647 #[secret]
4648 #[credential_class = "encrypted_secret"]
4649 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4650 pub bearer_token: Option<String>,
4651 #[serde(default)]
4653 pub language: Option<String>,
4654 #[serde(default = "default_local_whisper_max_audio_bytes")]
4657 pub max_audio_bytes: usize,
4658 #[serde(default = "default_local_whisper_timeout_secs")]
4660 pub timeout_secs: u64,
4661}
4662
4663#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
4665#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4666#[serde(rename_all = "snake_case")]
4667pub enum ToolFilterGroupMode {
4668 Always,
4670 #[default]
4673 Dynamic,
4674}
4675
4676#[derive(Debug, Clone, Serialize, Deserialize)]
4696#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4697pub struct ToolFilterGroup {
4698 #[serde(default)]
4700 pub mode: ToolFilterGroupMode,
4701 #[serde(default)]
4703 pub tools: Vec<String>,
4704 #[serde(default)]
4707 pub keywords: Vec<String>,
4708 #[serde(default)]
4710 pub filter_builtins: bool,
4711}
4712
4713#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4715#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4716#[prefix = "transcription.openai"]
4717pub struct OpenAiSttConfig {
4718 #[serde(default)]
4720 #[secret]
4721 #[credential_class = "encrypted_secret"]
4722 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4723 pub api_key: Option<String>,
4724 #[serde(default = "default_openai_stt_model")]
4726 pub model: String,
4727}
4728
4729#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4731#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4732#[prefix = "transcription.deepgram"]
4733pub struct DeepgramSttConfig {
4734 #[serde(default)]
4736 #[secret]
4737 #[credential_class = "encrypted_secret"]
4738 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4739 pub api_key: Option<String>,
4740 #[serde(default = "default_deepgram_stt_model")]
4742 pub model: String,
4743}
4744
4745#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4747#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4748#[prefix = "transcription.assemblyai"]
4749pub struct AssemblyAiSttConfig {
4750 #[serde(default)]
4752 #[secret]
4753 #[credential_class = "encrypted_secret"]
4754 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4755 pub api_key: Option<String>,
4756}
4757
4758#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4760#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4761#[prefix = "transcription.google"]
4762pub struct GoogleSttConfig {
4763 #[serde(default)]
4765 #[secret]
4766 #[credential_class = "encrypted_secret"]
4767 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4768 pub api_key: Option<String>,
4769 #[serde(default = "default_google_stt_language_code")]
4771 pub language_code: String,
4772}
4773
4774#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4778#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4779#[prefix = "transcription.local_whisper"]
4780pub struct LocalWhisperConfig {
4781 pub url: String,
4783 #[serde(default)]
4786 #[secret]
4787 #[credential_class = "encrypted_secret"]
4788 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4789 pub bearer_token: Option<String>,
4790 #[serde(default = "default_local_whisper_max_audio_bytes")]
4796 pub max_audio_bytes: usize,
4797 #[serde(default = "default_local_whisper_timeout_secs")]
4799 pub timeout_secs: u64,
4800}
4801
4802fn default_local_whisper_max_audio_bytes() -> usize {
4803 25 * 1024 * 1024
4804}
4805
4806fn default_local_whisper_timeout_secs() -> u64 {
4807 300
4808}
4809
4810#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4817#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4818#[prefix = "delegate_agent.tool_receipts"]
4819pub struct ToolReceiptsConfig {
4820 #[serde(default)]
4824 pub enabled: bool,
4825 #[serde(default)]
4829 pub show_in_response: bool,
4830 #[serde(default = "default_inject_system_prompt")]
4834 pub inject_system_prompt: bool,
4835}
4836
4837fn default_inject_system_prompt() -> bool {
4838 true
4839}
4840
4841impl Default for ToolReceiptsConfig {
4842 fn default() -> Self {
4843 Self {
4844 enabled: false,
4845 show_in_response: false,
4846 inject_system_prompt: default_inject_system_prompt(),
4847 }
4848 }
4849}
4850
4851fn default_max_tool_result_chars() -> usize {
4852 50_000
4853}
4854
4855fn default_keep_tool_context_turns() -> usize {
4856 2
4857}
4858
4859fn default_agent_tool_dispatcher() -> String {
4860 "auto".into()
4861}
4862
4863fn default_max_system_prompt_chars() -> usize {
4864 0
4865}
4866
4867#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4875#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4876#[prefix = "pacing"]
4877pub struct PacingConfig {
4878 #[serde(default)]
4882 pub step_timeout_secs: Option<u64>,
4883
4884 #[serde(default)]
4889 pub loop_detection_min_elapsed_secs: Option<u64>,
4890
4891 #[serde(default)]
4895 pub loop_ignore_tools: Vec<String>,
4896
4897 #[serde(default)]
4903 pub message_timeout_scale_max: Option<u64>,
4904
4905 #[serde(default = "default_loop_detection_enabled")]
4908 pub loop_detection_enabled: bool,
4909
4910 #[serde(default = "default_loop_detection_window_size")]
4913 pub loop_detection_window_size: usize,
4914
4915 #[serde(default = "default_loop_detection_max_repeats")]
4918 pub loop_detection_max_repeats: usize,
4919}
4920
4921fn default_loop_detection_enabled() -> bool {
4922 true
4923}
4924
4925fn default_loop_detection_window_size() -> usize {
4926 20
4927}
4928
4929fn default_loop_detection_max_repeats() -> usize {
4930 3
4931}
4932
4933impl Default for PacingConfig {
4934 fn default() -> Self {
4935 Self {
4936 step_timeout_secs: None,
4937 loop_detection_min_elapsed_secs: None,
4938 loop_ignore_tools: Vec::new(),
4939 message_timeout_scale_max: None,
4940 loop_detection_enabled: default_loop_detection_enabled(),
4941 loop_detection_window_size: default_loop_detection_window_size(),
4942 loop_detection_max_repeats: default_loop_detection_max_repeats(),
4943 }
4944 }
4945}
4946
4947#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4949#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4950#[serde(rename_all = "snake_case")]
4951pub enum SkillsPromptInjectionMode {
4952 #[default]
4954 Full,
4955 Compact,
4957}
4958
4959#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
4961#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4962#[prefix = "skills"]
4963pub struct SkillsConfig {
4964 #[serde(default)]
4967 pub open_skills_enabled: bool,
4968 #[serde(default)]
4971 pub open_skills_dir: Option<String>,
4972 #[serde(default)]
4975 pub allow_scripts: bool,
4976 #[serde(default)]
4979 pub registry_url: Option<String>,
4980 #[serde(default)]
4983 pub prompt_injection_mode: SkillsPromptInjectionMode,
4984 #[serde(default)]
4986 #[nested]
4987 pub skill_creation: SkillCreationConfig,
4988 #[serde(default, alias = "install-suggestions")]
4990 #[nested]
4991 pub install_suggestions: SkillInstallSuggestionsConfig,
4992 #[serde(default)]
4994 #[nested]
4995 pub skill_improvement: SkillImprovementConfig,
4996}
4997
4998#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5000#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5001#[prefix = "skills.skill_creation"]
5002#[serde(default)]
5003pub struct SkillCreationConfig {
5004 pub enabled: bool,
5007 pub max_skills: usize,
5010 pub similarity_threshold: f64,
5013}
5014
5015impl Default for SkillCreationConfig {
5016 fn default() -> Self {
5017 Self {
5018 enabled: false,
5019 max_skills: 500,
5020 similarity_threshold: 0.85,
5021 }
5022 }
5023}
5024
5025#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
5027#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5028#[prefix = "skills.install_suggestions"]
5029#[serde(default)]
5030pub struct SkillInstallSuggestionsConfig {
5031 pub enabled: bool,
5034}
5035
5036#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5038#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5039#[prefix = "skills.skill_improvement"]
5040pub struct SkillImprovementConfig {
5041 #[serde(default = "default_true")]
5044 pub enabled: bool,
5045 #[serde(default = "default_skill_improvement_cooldown")]
5048 pub cooldown_secs: u64,
5049}
5050
5051fn default_skill_improvement_cooldown() -> u64 {
5052 3600
5053}
5054
5055impl Default for SkillImprovementConfig {
5056 fn default() -> Self {
5057 Self {
5058 enabled: true,
5059 cooldown_secs: 3600,
5060 }
5061 }
5062}
5063
5064#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5066#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5067#[prefix = "pipeline"]
5068pub struct PipelineConfig {
5069 #[serde(default)]
5072 pub enabled: bool,
5073 #[serde(default = "default_pipeline_max_steps")]
5076 pub max_steps: usize,
5077 #[serde(default)]
5080 pub allowed_tools: Vec<String>,
5081}
5082
5083fn default_pipeline_max_steps() -> usize {
5084 20
5085}
5086
5087impl Default for PipelineConfig {
5088 fn default() -> Self {
5089 Self {
5090 enabled: false,
5091 max_steps: 20,
5092 allowed_tools: Vec::new(),
5093 }
5094 }
5095}
5096
5097#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5112#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5113#[prefix = "multimodal"]
5114pub struct MultimodalConfig {
5115 #[serde(default = "default_multimodal_max_images")]
5123 pub max_images: usize,
5124 #[serde(default = "default_multimodal_max_image_size_mb")]
5126 pub max_image_size_mb: usize,
5127 #[serde(default)]
5139 pub max_image_turns: usize,
5140 #[serde(default)]
5142 pub allow_remote_fetch: bool,
5143 #[serde(default)]
5147 pub vision_model_provider: Option<String>,
5148 #[serde(default)]
5151 pub vision_model: Option<String>,
5152}
5153
5154fn default_multimodal_max_images() -> usize {
5155 4
5156}
5157
5158fn default_multimodal_max_image_size_mb() -> usize {
5159 5
5160}
5161
5162impl MultimodalConfig {
5163 pub fn effective_limits(&self) -> (usize, usize) {
5165 let max_images = self.max_images.clamp(1, 16);
5166 let max_image_size_mb = self.max_image_size_mb.clamp(1, 20);
5167 (max_images, max_image_size_mb)
5168 }
5169}
5170
5171impl Default for MultimodalConfig {
5172 fn default() -> Self {
5173 Self {
5174 max_images: default_multimodal_max_images(),
5175 max_image_size_mb: default_multimodal_max_image_size_mb(),
5176 max_image_turns: 0,
5177 allow_remote_fetch: false,
5178 vision_model_provider: None,
5179 vision_model: None,
5180 }
5181 }
5182}
5183
5184#[allow(clippy::struct_excessive_bools)]
5192#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5193#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5194#[prefix = "media_pipeline"]
5195pub struct MediaPipelineConfig {
5196 #[serde(default)]
5198 pub enabled: bool,
5199
5200 #[serde(default = "default_true")]
5202 pub transcribe_audio: bool,
5203
5204 #[serde(default = "default_true")]
5206 pub describe_images: bool,
5207
5208 #[serde(default = "default_true")]
5210 pub summarize_video: bool,
5211}
5212
5213impl Default for MediaPipelineConfig {
5214 fn default() -> Self {
5215 Self {
5216 enabled: false,
5217 transcribe_audio: true,
5218 describe_images: true,
5219 summarize_video: true,
5220 }
5221 }
5222}
5223
5224#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5230#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5231#[prefix = "identity"]
5232pub struct IdentityConfig {
5233 #[serde(default = "default_identity_format")]
5235 pub format: String,
5236 #[serde(default)]
5238 pub aieos_path: Option<String>,
5239 #[serde(default)]
5241 pub aieos_inline: Option<String>,
5242}
5243
5244fn default_identity_format() -> String {
5245 "openclaw".into()
5246}
5247
5248impl Default for IdentityConfig {
5249 fn default() -> Self {
5250 Self {
5251 format: default_identity_format(),
5252 aieos_path: None,
5253 aieos_inline: None,
5254 }
5255 }
5256}
5257
5258#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5262#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5263#[prefix = "cost"]
5264pub struct CostConfig {
5265 #[tab(Limits)]
5267 #[serde(default = "default_cost_enabled")]
5268 pub enabled: bool,
5269
5270 #[tab(Limits)]
5272 #[serde(default = "default_daily_limit")]
5273 pub daily_limit_usd: f64,
5274
5275 #[tab(Limits)]
5277 #[serde(default = "default_monthly_limit")]
5278 pub monthly_limit_usd: f64,
5279
5280 #[tab(Limits)]
5282 #[serde(default = "default_warn_percent")]
5283 pub warn_at_percent: u8,
5284
5285 #[tab(Limits)]
5287 #[serde(default)]
5288 pub allow_override: bool,
5289
5290 #[tab(Limits)]
5292 #[serde(default)]
5293 #[nested]
5294 pub enforcement: CostEnforcementConfig,
5295
5296 #[tab(Limits)]
5301 #[serde(default = "default_track_per_agent")]
5302 pub track_per_agent: bool,
5303
5304 #[tab(Costs)]
5325 #[serde(default)]
5326 #[nested]
5327 pub rates: CostRatesConfig,
5328}
5329
5330#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5332#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5333#[prefix = "cost.enforcement"]
5334pub struct CostEnforcementConfig {
5335 #[serde(default = "default_cost_enforcement_mode")]
5337 pub mode: String,
5338 #[serde(default)]
5340 pub route_down_model: Option<String>,
5341 #[serde(default = "default_reserve_percent")]
5343 pub reserve_percent: u8,
5344}
5345
5346fn default_cost_enforcement_mode() -> String {
5347 "warn".to_string()
5348}
5349
5350fn default_reserve_percent() -> u8 {
5351 10
5352}
5353
5354impl Default for CostEnforcementConfig {
5355 fn default() -> Self {
5356 Self {
5357 mode: default_cost_enforcement_mode(),
5358 route_down_model: None,
5359 reserve_percent: default_reserve_percent(),
5360 }
5361 }
5362}
5363
5364fn default_daily_limit() -> f64 {
5365 10.0
5366}
5367
5368fn default_monthly_limit() -> f64 {
5369 100.0
5370}
5371
5372fn default_warn_percent() -> u8 {
5373 80
5374}
5375
5376fn default_cost_enabled() -> bool {
5377 true
5378}
5379
5380fn default_track_per_agent() -> bool {
5381 true
5382}
5383
5384#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5388#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5389#[prefix = "cost.rates"]
5390pub struct CostRatesConfig {
5391 #[serde(default)]
5394 #[nested]
5395 pub providers: ProviderCostRates,
5396
5397 #[serde(default)]
5400 #[nested]
5401 #[resource_key]
5402 pub tools: std::collections::HashMap<String, ToolCostRates>,
5403}
5404
5405impl CostRatesConfig {
5406 #[must_use]
5409 pub fn model_rates(&self, provider_type: &str, model: &str) -> Option<&ModelCostRates> {
5410 self.providers.models.get(provider_type, model)
5411 }
5412
5413 #[must_use]
5415 pub fn tts_rates(&self, provider_type: &str, voice: &str) -> Option<&TtsCostRates> {
5416 self.providers.tts.get(provider_type, voice)
5417 }
5418
5419 #[must_use]
5421 pub fn transcription_rates(
5422 &self,
5423 provider_type: &str,
5424 model: &str,
5425 ) -> Option<&TranscriptionCostRates> {
5426 self.providers.transcription.get(provider_type, model)
5427 }
5428
5429 #[must_use]
5431 pub fn tool_rates(&self, tool_name: &str) -> Option<&ToolCostRates> {
5432 self.tools.get(tool_name)
5433 }
5434}
5435
5436#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5444#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5445#[prefix = "cost.rates.providers"]
5446pub struct ProviderCostRates {
5447 #[serde(default)]
5449 #[nested]
5450 pub models: crate::providers::ModelCostRatesByProvider,
5451 #[serde(default)]
5453 #[nested]
5454 pub tts: crate::providers::TtsCostRatesByProvider,
5455 #[serde(default)]
5457 #[nested]
5458 pub transcription: crate::providers::TranscriptionCostRatesByProvider,
5459}
5460
5461#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5465#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5466#[prefix = "cost.rates.providers.models"]
5467pub struct ModelCostRates {
5468 #[serde(default, skip_serializing_if = "Option::is_none")]
5470 pub input_per_mtok: Option<f64>,
5471 #[serde(default, skip_serializing_if = "Option::is_none")]
5473 pub output_per_mtok: Option<f64>,
5474 #[serde(default, skip_serializing_if = "Option::is_none")]
5477 pub cached_input_per_mtok: Option<f64>,
5478}
5479
5480#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5482#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5483#[prefix = "cost.rates.providers.tts"]
5484pub struct TtsCostRates {
5485 #[serde(default, skip_serializing_if = "Option::is_none")]
5487 pub per_mchar: Option<f64>,
5488}
5489
5490#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5492#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5493#[prefix = "cost.rates.providers.transcription"]
5494pub struct TranscriptionCostRates {
5495 #[serde(default, skip_serializing_if = "Option::is_none")]
5497 pub per_minute: Option<f64>,
5498}
5499
5500#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5503#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5504#[prefix = "cost.rates.tools"]
5505pub struct ToolCostRates {
5506 #[serde(default, skip_serializing_if = "Option::is_none")]
5508 pub per_call: Option<f64>,
5509}
5510
5511impl Default for CostConfig {
5512 fn default() -> Self {
5513 Self {
5514 enabled: true,
5515 daily_limit_usd: default_daily_limit(),
5516 monthly_limit_usd: default_monthly_limit(),
5517 warn_at_percent: default_warn_percent(),
5518 allow_override: false,
5519 enforcement: CostEnforcementConfig::default(),
5520 track_per_agent: default_track_per_agent(),
5521 rates: CostRatesConfig::default(),
5522 }
5523 }
5524}
5525
5526#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
5532#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5533#[prefix = "peripherals"]
5534pub struct PeripheralsConfig {
5535 #[serde(default)]
5537 pub enabled: bool,
5538 #[serde(default)]
5540 pub boards: Vec<PeripheralBoardConfig>,
5541 #[serde(default)]
5544 pub datasheet_dir: Option<String>,
5545}
5546
5547#[derive(Debug, Clone, Serialize, Deserialize)]
5549#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5550pub struct PeripheralBoardConfig {
5551 pub board: String,
5553 #[serde(default = "default_peripheral_transport")]
5555 pub transport: String,
5556 #[serde(default)]
5558 pub path: Option<String>,
5559 #[serde(default = "default_peripheral_baud")]
5561 pub baud: u32,
5562}
5563
5564fn default_peripheral_transport() -> String {
5565 "serial".into()
5566}
5567
5568fn default_peripheral_baud() -> u32 {
5569 115_200
5570}
5571
5572impl Default for PeripheralBoardConfig {
5573 fn default() -> Self {
5574 Self {
5575 board: String::new(),
5576 transport: default_peripheral_transport(),
5577 path: None,
5578 baud: default_peripheral_baud(),
5579 }
5580 }
5581}
5582
5583#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5589#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5590#[prefix = "gateway"]
5591#[allow(clippy::struct_excessive_bools)]
5592pub struct GatewayConfig {
5593 #[serde(default = "default_gateway_port")]
5595 pub port: u16,
5596 #[serde(default = "default_gateway_host")]
5598 pub host: String,
5599 #[serde(default = "default_true")]
5601 pub require_pairing: bool,
5602 #[serde(default)]
5604 pub allow_public_bind: bool,
5605 #[serde(default)]
5615 pub allow_remote_admin: bool,
5616 #[serde(default)]
5618 #[secret]
5619 #[credential_class = "encrypted_secret"]
5620 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5621 pub paired_tokens: Vec<String>,
5622
5623 #[serde(default = "default_pair_rate_limit")]
5625 pub pair_rate_limit_per_minute: u32,
5626
5627 #[serde(default = "default_webhook_rate_limit")]
5629 pub webhook_rate_limit_per_minute: u32,
5630
5631 #[serde(default)]
5634 #[credential_class = "public_value"]
5635 pub trust_forwarded_headers: bool,
5636
5637 #[serde(default)]
5641 pub path_prefix: Option<String>,
5642
5643 #[serde(default = "default_gateway_rate_limit_max_keys")]
5645 pub rate_limit_max_keys: usize,
5646
5647 #[serde(default = "default_idempotency_ttl_secs")]
5649 pub idempotency_ttl_secs: u64,
5650
5651 #[serde(default = "default_gateway_idempotency_max_keys")]
5653 pub idempotency_max_keys: usize,
5654
5655 #[serde(default = "default_true")]
5657 pub session_persistence: bool,
5658
5659 #[serde(default)]
5661 pub session_ttl_hours: u32,
5662
5663 #[serde(default)]
5665 #[nested]
5666 pub pairing_dashboard: PairingDashboardConfig,
5667
5668 #[serde(default)]
5674 pub web_dist_dir: Option<String>,
5675
5676 #[serde(default)]
5678 #[nested]
5679 pub tls: Option<GatewayTlsConfig>,
5680
5681 #[serde(default = "default_gateway_request_timeout_secs")]
5684 pub request_timeout_secs: u64,
5685
5686 #[serde(default = "default_gateway_long_running_request_timeout_secs")]
5690 pub long_running_request_timeout_secs: u64,
5691}
5692
5693fn default_gateway_port() -> u16 {
5694 42617
5695}
5696
5697fn default_gateway_request_timeout_secs() -> u64 {
5698 30
5699}
5700
5701fn default_gateway_long_running_request_timeout_secs() -> u64 {
5702 600
5703}
5704
5705fn default_gateway_host() -> String {
5706 "127.0.0.1".into()
5707}
5708
5709fn default_pair_rate_limit() -> u32 {
5710 10
5711}
5712
5713fn default_webhook_rate_limit() -> u32 {
5714 60
5715}
5716
5717fn default_idempotency_ttl_secs() -> u64 {
5718 300
5719}
5720
5721fn default_gateway_rate_limit_max_keys() -> usize {
5722 10_000
5723}
5724
5725fn default_gateway_idempotency_max_keys() -> usize {
5726 10_000
5727}
5728
5729fn default_true() -> bool {
5730 true
5731}
5732
5733fn default_false() -> bool {
5734 false
5735}
5736
5737impl Default for GatewayConfig {
5738 fn default() -> Self {
5739 Self {
5740 port: default_gateway_port(),
5741 host: default_gateway_host(),
5742 require_pairing: true,
5743 allow_public_bind: false,
5744 allow_remote_admin: false,
5745 paired_tokens: Vec::new(),
5746 pair_rate_limit_per_minute: default_pair_rate_limit(),
5747 webhook_rate_limit_per_minute: default_webhook_rate_limit(),
5748 trust_forwarded_headers: false,
5749 path_prefix: None,
5750 rate_limit_max_keys: default_gateway_rate_limit_max_keys(),
5751 idempotency_ttl_secs: default_idempotency_ttl_secs(),
5752 idempotency_max_keys: default_gateway_idempotency_max_keys(),
5753 session_persistence: true,
5754 session_ttl_hours: 0,
5755 pairing_dashboard: PairingDashboardConfig::default(),
5756 web_dist_dir: None,
5757 tls: None,
5758 request_timeout_secs: default_gateway_request_timeout_secs(),
5759 long_running_request_timeout_secs: default_gateway_long_running_request_timeout_secs(),
5760 }
5761 }
5762}
5763
5764#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5766#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5767#[prefix = "gateway.pairing_dashboard"]
5768pub struct PairingDashboardConfig {
5769 #[serde(default = "default_pairing_code_length")]
5771 pub code_length: usize,
5772 #[serde(default = "default_pairing_ttl")]
5774 pub code_ttl_secs: u64,
5775 #[serde(default = "default_max_pending_codes")]
5777 pub max_pending_codes: usize,
5778 #[serde(default = "default_max_failed_attempts")]
5780 pub max_failed_attempts: u32,
5781 #[serde(default = "default_pairing_lockout_secs")]
5783 pub lockout_secs: u64,
5784}
5785
5786fn default_pairing_code_length() -> usize {
5787 8
5788}
5789fn default_pairing_ttl() -> u64 {
5790 3600
5791}
5792fn default_max_pending_codes() -> usize {
5793 3
5794}
5795fn default_max_failed_attempts() -> u32 {
5796 5
5797}
5798fn default_pairing_lockout_secs() -> u64 {
5799 300
5800}
5801
5802impl Default for PairingDashboardConfig {
5803 fn default() -> Self {
5804 Self {
5805 code_length: default_pairing_code_length(),
5806 code_ttl_secs: default_pairing_ttl(),
5807 max_pending_codes: default_max_pending_codes(),
5808 max_failed_attempts: default_max_failed_attempts(),
5809 lockout_secs: default_pairing_lockout_secs(),
5810 }
5811 }
5812}
5813
5814#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5816#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5817#[prefix = "gateway.tls"]
5818pub struct GatewayTlsConfig {
5819 #[serde(default)]
5821 pub enabled: bool,
5822 pub cert_path: String,
5824 pub key_path: String,
5826 #[serde(default)]
5828 #[nested]
5829 pub client_auth: Option<GatewayClientAuthConfig>,
5830}
5831
5832#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5834#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5835#[prefix = "gateway.tls.client_auth"]
5836pub struct GatewayClientAuthConfig {
5837 #[serde(default)]
5839 pub enabled: bool,
5840 #[serde(default)]
5842 pub ca_cert_path: String,
5843 #[serde(default = "default_true")]
5845 pub require_client_cert: bool,
5846 #[serde(default)]
5849 pub pinned_certs: Vec<String>,
5850}
5851
5852impl Default for GatewayClientAuthConfig {
5853 fn default() -> Self {
5854 Self {
5855 enabled: false,
5856 ca_cert_path: String::new(),
5857 require_client_cert: default_true(),
5858 pinned_certs: Vec::new(),
5859 }
5860 }
5861}
5862
5863#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5869#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5870#[prefix = "wss"]
5871pub struct WssConfig {
5872 #[serde(default)]
5874 pub enabled: bool,
5875 #[serde(default = "default_wss_bind")]
5877 pub bind: String,
5878 #[serde(default = "default_wss_port")]
5880 pub port: u16,
5881 #[serde(default)]
5883 pub cert_path: String,
5884 #[serde(default)]
5886 pub key_path: String,
5887}
5888
5889impl Default for WssConfig {
5890 fn default() -> Self {
5891 Self {
5892 enabled: false,
5893 bind: default_wss_bind(),
5894 port: default_wss_port(),
5895 cert_path: String::new(),
5896 key_path: String::new(),
5897 }
5898 }
5899}
5900
5901fn default_wss_bind() -> String {
5902 "0.0.0.0".into()
5903}
5904
5905fn default_wss_port() -> u16 {
5906 9781
5907}
5908
5909#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5911#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5912#[prefix = "node_transport"]
5913pub struct NodeTransportConfig {
5914 #[serde(default = "default_node_transport_enabled")]
5916 pub enabled: bool,
5917 #[serde(default)]
5919 #[secret]
5920 #[credential_class = "encrypted_secret"]
5921 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5922 pub shared_secret: String,
5923 #[serde(default = "default_max_request_age")]
5925 pub max_request_age_secs: i64,
5926 #[serde(default = "default_require_https")]
5928 pub require_https: bool,
5929 #[serde(default)]
5931 pub allowed_peers: Vec<String>,
5932 #[serde(default)]
5934 pub tls_cert_path: Option<String>,
5935 #[serde(default)]
5937 pub tls_key_path: Option<String>,
5938 #[serde(default)]
5940 pub mutual_tls: bool,
5941 #[serde(default = "default_connection_pool_size")]
5943 pub connection_pool_size: usize,
5944}
5945
5946fn default_node_transport_enabled() -> bool {
5947 true
5948}
5949fn default_max_request_age() -> i64 {
5950 300
5951}
5952fn default_require_https() -> bool {
5953 true
5954}
5955fn default_connection_pool_size() -> usize {
5956 4
5957}
5958
5959impl Default for NodeTransportConfig {
5960 fn default() -> Self {
5961 Self {
5962 enabled: default_node_transport_enabled(),
5963 shared_secret: String::new(),
5964 max_request_age_secs: default_max_request_age(),
5965 require_https: default_require_https(),
5966 allowed_peers: Vec::new(),
5967 tls_cert_path: None,
5968 tls_key_path: None,
5969 mutual_tls: false,
5970 connection_pool_size: default_connection_pool_size(),
5971 }
5972 }
5973}
5974
5975#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5981#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5982#[prefix = "composio"]
5983pub struct ComposioConfig {
5984 #[serde(default, alias = "enable")]
5986 pub enabled: bool,
5987 #[serde(default)]
5989 #[secret]
5990 #[credential_class = "encrypted_secret"]
5991 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5992 pub api_key: Option<String>,
5993 #[serde(default = "default_entity_id")]
5995 pub entity_id: String,
5996}
5997
5998fn default_entity_id() -> String {
5999 "default".into()
6000}
6001
6002impl Default for ComposioConfig {
6003 fn default() -> Self {
6004 Self {
6005 enabled: false,
6006 api_key: None,
6007 entity_id: default_entity_id(),
6008 }
6009 }
6010}
6011
6012#[derive(Clone, Serialize, Deserialize, Configurable)]
6019#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6020#[prefix = "ms365"]
6021pub struct Microsoft365Config {
6022 #[serde(default, alias = "enable")]
6024 pub enabled: bool,
6025 #[serde(default)]
6027 pub tenant_id: Option<String>,
6028 #[serde(default)]
6030 pub client_id: Option<String>,
6031 #[serde(default)]
6033 #[secret]
6034 #[credential_class = "encrypted_secret"]
6035 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6036 pub client_secret: Option<String>,
6037 #[serde(default = "default_ms365_auth_flow")]
6039 pub auth_flow: String,
6040 #[serde(default = "default_ms365_scopes")]
6042 pub scopes: Vec<String>,
6043 #[serde(default = "default_true")]
6045 pub token_cache_encrypted: bool,
6046 #[serde(default)]
6048 pub user_id: Option<String>,
6049}
6050
6051fn default_ms365_auth_flow() -> String {
6052 "client_credentials".to_string()
6053}
6054
6055fn default_ms365_scopes() -> Vec<String> {
6056 vec!["https://graph.microsoft.com/.default".to_string()]
6057}
6058
6059impl std::fmt::Debug for Microsoft365Config {
6060 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6061 f.debug_struct("Microsoft365Config")
6062 .field("enabled", &self.enabled)
6063 .field("tenant_id", &self.tenant_id)
6064 .field("client_id", &self.client_id)
6065 .field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
6066 .field("auth_flow", &self.auth_flow)
6067 .field("scopes", &self.scopes)
6068 .field("token_cache_encrypted", &self.token_cache_encrypted)
6069 .field("user_id", &self.user_id)
6070 .finish()
6071 }
6072}
6073
6074impl Default for Microsoft365Config {
6075 fn default() -> Self {
6076 Self {
6077 enabled: false,
6078 tenant_id: None,
6079 client_id: None,
6080 client_secret: None,
6081 auth_flow: default_ms365_auth_flow(),
6082 scopes: default_ms365_scopes(),
6083 token_cache_encrypted: true,
6084 user_id: None,
6085 }
6086 }
6087}
6088
6089#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6093#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6094#[prefix = "secrets"]
6095pub struct SecretsConfig {
6096 #[serde(default = "default_true")]
6098 #[credential_class = "public_value"]
6099 pub encrypt: bool,
6100}
6101
6102impl Default for SecretsConfig {
6103 fn default() -> Self {
6104 Self { encrypt: true }
6105 }
6106}
6107
6108#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6114#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6115#[prefix = "browser.computer_use"]
6116pub struct BrowserComputerUseConfig {
6117 #[serde(default = "default_browser_computer_use_endpoint")]
6119 pub endpoint: String,
6120 #[serde(default)]
6122 #[secret]
6123 #[credential_class = "encrypted_secret"]
6124 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6125 pub api_key: Option<String>,
6126 #[serde(default = "default_browser_computer_use_timeout_ms")]
6128 pub timeout_ms: u64,
6129 #[serde(default)]
6131 pub allow_remote_endpoint: bool,
6132 #[serde(default)]
6134 pub window_allowlist: Vec<String>,
6135 #[serde(default)]
6137 pub max_coordinate_x: Option<i64>,
6138 #[serde(default)]
6140 pub max_coordinate_y: Option<i64>,
6141}
6142
6143fn default_browser_computer_use_endpoint() -> String {
6144 "http://127.0.0.1:8787/v1/actions".into()
6145}
6146
6147fn default_browser_computer_use_timeout_ms() -> u64 {
6148 15_000
6149}
6150
6151impl Default for BrowserComputerUseConfig {
6152 fn default() -> Self {
6153 Self {
6154 endpoint: default_browser_computer_use_endpoint(),
6155 api_key: None,
6156 timeout_ms: default_browser_computer_use_timeout_ms(),
6157 allow_remote_endpoint: false,
6158 window_allowlist: Vec::new(),
6159 max_coordinate_x: None,
6160 max_coordinate_y: None,
6161 }
6162 }
6163}
6164
6165#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6169#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6170#[prefix = "browser"]
6171#[integration(
6172 category = "ToolsAutomation",
6173 display_name = "Browser",
6174 description = "Chrome/Chromium control",
6175 status_field = "enabled"
6176)]
6177pub struct BrowserConfig {
6178 #[serde(default = "default_true")]
6180 pub enabled: bool,
6181 #[serde(default = "default_browser_allowed_domains")]
6183 pub allowed_domains: Vec<String>,
6184 #[serde(default)]
6186 pub session_name: Option<String>,
6187 #[serde(default = "default_browser_backend")]
6189 pub backend: String,
6190 #[serde(default)]
6192 pub headed: Option<bool>,
6193 #[serde(default = "default_true")]
6195 pub native_headless: bool,
6196 #[serde(default = "default_browser_webdriver_url")]
6198 pub native_webdriver_url: String,
6199 #[serde(default)]
6201 pub native_chrome_path: Option<String>,
6202 #[serde(default)]
6204 #[nested]
6205 pub computer_use: BrowserComputerUseConfig,
6206}
6207
6208fn default_browser_allowed_domains() -> Vec<String> {
6209 vec!["*".into()]
6210}
6211
6212fn default_browser_backend() -> String {
6213 "agent_browser".into()
6214}
6215
6216fn default_browser_webdriver_url() -> String {
6217 "http://127.0.0.1:9515".into()
6218}
6219
6220impl Default for BrowserConfig {
6221 fn default() -> Self {
6222 Self {
6223 enabled: true,
6224 allowed_domains: vec!["*".into()],
6225 session_name: None,
6226 backend: default_browser_backend(),
6227 headed: None,
6228 native_headless: default_true(),
6229 native_webdriver_url: default_browser_webdriver_url(),
6230 native_chrome_path: None,
6231 computer_use: BrowserComputerUseConfig::default(),
6232 }
6233 }
6234}
6235
6236#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6244#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6245#[prefix = "http_request"]
6246pub struct HttpRequestConfig {
6247 #[serde(default)]
6249 pub enabled: bool,
6250 #[serde(default)]
6252 pub allowed_domains: Vec<String>,
6253 #[serde(default = "default_http_max_response_size")]
6255 pub max_response_size: usize,
6256 #[serde(default = "default_http_timeout_secs")]
6258 pub timeout_secs: u64,
6259 #[serde(default)]
6262 pub allow_private_hosts: bool,
6263 #[serde(default)]
6266 pub allowed_private_hosts: Vec<String>,
6267 #[serde(default)]
6269 #[secret]
6270 #[credential_class = "encrypted_secret"]
6271 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6272 pub secrets: HashMap<String, String>,
6273}
6274
6275impl Default for HttpRequestConfig {
6276 fn default() -> Self {
6277 Self {
6278 enabled: true,
6279 allowed_domains: vec!["*".into()],
6280 max_response_size: default_http_max_response_size(),
6281 timeout_secs: default_http_timeout_secs(),
6282 allow_private_hosts: false,
6283 allowed_private_hosts: vec![],
6284 secrets: HashMap::new(),
6285 }
6286 }
6287}
6288
6289fn default_http_max_response_size() -> usize {
6290 1_000_000 }
6292
6293fn default_http_timeout_secs() -> u64 {
6294 30
6295}
6296
6297#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6306#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6307#[prefix = "web_fetch"]
6308pub struct WebFetchConfig {
6309 #[serde(default)]
6311 pub enabled: bool,
6312 #[serde(default = "default_web_fetch_allowed_domains")]
6314 pub allowed_domains: Vec<String>,
6315 #[serde(default)]
6317 pub blocked_domains: Vec<String>,
6318 #[serde(default)]
6320 pub allowed_private_hosts: Vec<String>,
6321 #[serde(default = "default_web_fetch_max_response_size")]
6323 pub max_response_size: usize,
6324 #[serde(default = "default_web_fetch_timeout_secs")]
6326 pub timeout_secs: u64,
6327 #[serde(default)]
6329 #[nested]
6330 pub firecrawl: FirecrawlConfig,
6331}
6332
6333#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
6335#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6336#[serde(rename_all = "lowercase")]
6337pub enum FirecrawlMode {
6338 #[default]
6339 Scrape,
6340 Crawl,
6344}
6345
6346#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6352#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6353#[prefix = "web_fetch.firecrawl"]
6354pub struct FirecrawlConfig {
6355 #[serde(default)]
6357 pub enabled: bool,
6358 #[serde(default = "default_firecrawl_api_key_env")]
6360 #[credential_class = "legacy_env_path"]
6361 pub api_key_env: String,
6362 #[serde(default = "default_firecrawl_api_url")]
6364 pub api_url: String,
6365 #[serde(default)]
6367 pub mode: FirecrawlMode,
6368}
6369
6370fn default_firecrawl_api_key_env() -> String {
6371 "FIRECRAWL_API_KEY".into()
6372}
6373
6374fn default_firecrawl_api_url() -> String {
6375 "https://api.firecrawl.dev/v1".into()
6376}
6377
6378impl Default for FirecrawlConfig {
6379 fn default() -> Self {
6380 Self {
6381 enabled: false,
6382 api_key_env: default_firecrawl_api_key_env(),
6383 api_url: default_firecrawl_api_url(),
6384 mode: FirecrawlMode::default(),
6385 }
6386 }
6387}
6388
6389fn default_web_fetch_max_response_size() -> usize {
6390 500_000 }
6392
6393fn default_web_fetch_timeout_secs() -> u64 {
6394 30
6395}
6396
6397fn default_web_fetch_allowed_domains() -> Vec<String> {
6398 vec!["*".into()]
6399}
6400
6401impl Default for WebFetchConfig {
6402 fn default() -> Self {
6403 Self {
6404 enabled: true,
6405 allowed_domains: vec!["*".into()],
6406 blocked_domains: vec![],
6407 allowed_private_hosts: vec![],
6408 max_response_size: default_web_fetch_max_response_size(),
6409 timeout_secs: default_web_fetch_timeout_secs(),
6410 firecrawl: FirecrawlConfig::default(),
6411 }
6412 }
6413}
6414
6415#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6424#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6425#[prefix = "link_enricher"]
6426pub struct LinkEnricherConfig {
6427 #[serde(default)]
6429 pub enabled: bool,
6430 #[serde(default = "default_link_enricher_max_links")]
6432 pub max_links: usize,
6433 #[serde(default = "default_link_enricher_timeout_secs")]
6435 pub timeout_secs: u64,
6436}
6437
6438fn default_link_enricher_max_links() -> usize {
6439 3
6440}
6441
6442fn default_link_enricher_timeout_secs() -> u64 {
6443 10
6444}
6445
6446impl Default for LinkEnricherConfig {
6447 fn default() -> Self {
6448 Self {
6449 enabled: false,
6450 max_links: default_link_enricher_max_links(),
6451 timeout_secs: default_link_enricher_timeout_secs(),
6452 }
6453 }
6454}
6455
6456#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6463#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6464#[prefix = "text_browser"]
6465pub struct TextBrowserConfig {
6466 #[serde(default)]
6468 pub enabled: bool,
6469 #[serde(default)]
6471 pub preferred_browser: Option<String>,
6472 #[serde(default = "default_text_browser_timeout_secs")]
6474 pub timeout_secs: u64,
6475}
6476
6477fn default_text_browser_timeout_secs() -> u64 {
6478 30
6479}
6480
6481impl Default for TextBrowserConfig {
6482 fn default() -> Self {
6483 Self {
6484 enabled: false,
6485 preferred_browser: None,
6486 timeout_secs: default_text_browser_timeout_secs(),
6487 }
6488 }
6489}
6490
6491#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6499#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6500#[prefix = "shell_tool"]
6501pub struct ShellToolConfig {
6502 #[serde(default = "default_shell_tool_timeout_secs")]
6504 pub timeout_secs: u64,
6505}
6506
6507fn default_shell_tool_timeout_secs() -> u64 {
6508 60
6509}
6510
6511impl Default for ShellToolConfig {
6512 fn default() -> Self {
6513 Self {
6514 timeout_secs: default_shell_tool_timeout_secs(),
6515 }
6516 }
6517}
6518
6519#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
6528#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6529#[prefix = "escalation"]
6530pub struct EscalationConfig {
6531 #[serde(default)]
6536 pub alert_channels: Vec<String>,
6537}
6538
6539#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6543#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6544#[prefix = "web_search"]
6545pub struct WebSearchConfig {
6546 #[serde(default)]
6548 pub enabled: bool,
6549 #[serde(default = "default_web_search_provider")]
6551 pub search_provider: String,
6552 #[serde(default)]
6554 #[secret]
6555 #[credential_class = "encrypted_secret"]
6556 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6557 pub brave_api_key: Option<String>,
6558 #[serde(default)]
6560 #[secret]
6561 #[credential_class = "encrypted_secret"]
6562 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6563 pub tavily_api_key: Option<String>,
6564 #[serde(default)]
6566 #[secret]
6567 #[credential_class = "encrypted_secret"]
6568 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6569 pub jina_api_key: Option<String>,
6570 #[serde(default)]
6572 pub searxng_instance_url: Option<String>,
6573 #[serde(default = "default_web_search_max_results")]
6575 pub max_results: usize,
6576 #[serde(default = "default_web_search_timeout_secs")]
6578 pub timeout_secs: u64,
6579}
6580
6581fn default_web_search_provider() -> String {
6582 "duckduckgo".into()
6583}
6584
6585fn default_web_search_max_results() -> usize {
6586 5
6587}
6588
6589fn default_web_search_timeout_secs() -> u64 {
6590 15
6591}
6592
6593impl Default for WebSearchConfig {
6594 fn default() -> Self {
6595 Self {
6596 enabled: true,
6597 search_provider: default_web_search_provider(),
6598 brave_api_key: None,
6599 tavily_api_key: None,
6600 jina_api_key: None,
6601 searxng_instance_url: None,
6602 max_results: default_web_search_max_results(),
6603 timeout_secs: default_web_search_timeout_secs(),
6604 }
6605 }
6606}
6607
6608#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6612#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6613#[prefix = "project_intel"]
6614pub struct ProjectIntelConfig {
6615 #[serde(default)]
6617 pub enabled: bool,
6618 #[serde(default = "default_project_intel_language")]
6620 pub default_language: String,
6621 #[serde(default = "default_project_intel_report_dir")]
6623 pub report_output_dir: String,
6624 #[serde(default)]
6626 pub templates_dir: Option<String>,
6627 #[serde(default = "default_project_intel_risk_sensitivity")]
6629 pub risk_sensitivity: String,
6630 #[serde(default = "default_true")]
6632 pub include_git_data: bool,
6633 #[serde(default)]
6635 pub include_jira_data: bool,
6636 #[serde(default)]
6638 pub jira_base_url: Option<String>,
6639}
6640
6641fn default_project_intel_language() -> String {
6642 "en".into()
6643}
6644
6645fn default_project_intel_report_dir() -> String {
6646 default_path_under_config_dir("project-reports")
6647}
6648
6649fn default_project_intel_risk_sensitivity() -> String {
6650 "medium".into()
6651}
6652
6653impl Default for ProjectIntelConfig {
6654 fn default() -> Self {
6655 Self {
6656 enabled: false,
6657 default_language: default_project_intel_language(),
6658 report_output_dir: default_project_intel_report_dir(),
6659 templates_dir: None,
6660 risk_sensitivity: default_project_intel_risk_sensitivity(),
6661 include_git_data: true,
6662 include_jira_data: false,
6663 jira_base_url: None,
6664 }
6665 }
6666}
6667
6668#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6672#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6673#[prefix = "backup"]
6674pub struct BackupConfig {
6675 #[serde(default = "default_true")]
6677 pub enabled: bool,
6678 #[serde(default = "default_backup_max_keep")]
6680 pub max_keep: usize,
6681 #[serde(default = "default_backup_include_dirs")]
6683 pub include_dirs: Vec<String>,
6684 #[serde(default = "default_backup_destination_dir")]
6686 pub destination_dir: String,
6687 #[serde(default)]
6689 pub schedule_cron: Option<String>,
6690 #[serde(default)]
6692 pub schedule_timezone: Option<String>,
6693 #[serde(default = "default_true")]
6695 pub compress: bool,
6696 #[serde(default)]
6698 pub encrypt: bool,
6699}
6700
6701fn default_backup_max_keep() -> usize {
6702 10
6703}
6704
6705fn default_backup_include_dirs() -> Vec<String> {
6706 vec![
6707 "config".into(),
6708 "memory".into(),
6709 "audit".into(),
6710 "knowledge".into(),
6711 ]
6712}
6713
6714fn default_backup_destination_dir() -> String {
6715 "state/backups".into()
6716}
6717
6718impl Default for BackupConfig {
6719 fn default() -> Self {
6720 Self {
6721 enabled: true,
6722 max_keep: default_backup_max_keep(),
6723 include_dirs: default_backup_include_dirs(),
6724 destination_dir: default_backup_destination_dir(),
6725 schedule_cron: None,
6726 schedule_timezone: None,
6727 compress: true,
6728 encrypt: false,
6729 }
6730 }
6731}
6732
6733#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6737#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6738#[prefix = "data_retention"]
6739pub struct DataRetentionConfig {
6740 #[serde(default)]
6742 pub enabled: bool,
6743 #[serde(default = "default_retention_days")]
6745 pub retention_days: u64,
6746 #[serde(default)]
6748 pub dry_run: bool,
6749 #[serde(default)]
6751 pub categories: Vec<String>,
6752}
6753
6754fn default_retention_days() -> u64 {
6755 90
6756}
6757
6758impl Default for DataRetentionConfig {
6759 fn default() -> Self {
6760 Self {
6761 enabled: false,
6762 retention_days: default_retention_days(),
6763 dry_run: false,
6764 categories: Vec::new(),
6765 }
6766 }
6767}
6768
6769pub const DEFAULT_GWS_SERVICES: &[&str] = &[
6778 "drive",
6779 "sheets",
6780 "gmail",
6781 "calendar",
6782 "docs",
6783 "slides",
6784 "tasks",
6785 "people",
6786 "chat",
6787 "classroom",
6788 "forms",
6789 "keep",
6790 "meet",
6791 "events",
6792];
6793
6794#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6822#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6823pub struct GoogleWorkspaceAllowedOperation {
6824 pub service: String,
6826 pub resource: String,
6828 #[serde(default)]
6833 pub sub_resource: Option<String>,
6834 #[serde(default)]
6836 pub methods: Vec<String>,
6837}
6838
6839#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6864#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6865#[prefix = "google_workspace"]
6866#[integration(
6867 category = "ToolsAutomation",
6868 display_name = "Google Workspace",
6869 description = "Drive, Gmail, Calendar, Sheets, Docs via gws CLI",
6870 status_field = "enabled"
6871)]
6872pub struct GoogleWorkspaceConfig {
6873 #[serde(default)]
6875 pub enabled: bool,
6876 #[serde(default)]
6883 pub allowed_services: Vec<String>,
6884 #[serde(default)]
6897 pub allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
6898 #[serde(default)]
6904 pub credentials_path: Option<String>,
6905 #[serde(default)]
6909 pub default_account: Option<String>,
6910 #[serde(default = "default_gws_rate_limit")]
6912 pub rate_limit_per_minute: u32,
6913 #[serde(default = "default_gws_timeout_secs")]
6915 pub timeout_secs: u64,
6916 #[serde(default)]
6919 pub audit_log: bool,
6920}
6921
6922fn default_gws_rate_limit() -> u32 {
6923 60
6924}
6925
6926fn default_gws_timeout_secs() -> u64 {
6927 30
6928}
6929
6930impl Default for GoogleWorkspaceConfig {
6931 fn default() -> Self {
6932 Self {
6933 enabled: false,
6934 allowed_services: Vec::new(),
6935 allowed_operations: Vec::new(),
6936 credentials_path: None,
6937 default_account: None,
6938 rate_limit_per_minute: default_gws_rate_limit(),
6939 timeout_secs: default_gws_timeout_secs(),
6940 audit_log: false,
6941 }
6942 }
6943}
6944
6945#[allow(clippy::struct_excessive_bools)]
6949#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6950#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6951#[prefix = "knowledge"]
6952pub struct KnowledgeConfig {
6953 #[serde(default)]
6955 pub enabled: bool,
6956 #[serde(default = "default_knowledge_db_path")]
6958 pub db_path: String,
6959 #[serde(default = "default_knowledge_max_nodes")]
6961 pub max_nodes: usize,
6962 #[serde(default)]
6964 pub auto_capture: bool,
6965 #[serde(default = "default_true")]
6967 pub suggest_on_query: bool,
6968}
6969
6970fn default_knowledge_db_path() -> String {
6971 default_path_under_config_dir("knowledge.db")
6972}
6973
6974fn default_knowledge_max_nodes() -> usize {
6975 100_000
6976}
6977
6978impl Default for KnowledgeConfig {
6979 fn default() -> Self {
6980 Self {
6981 enabled: false,
6982 db_path: default_knowledge_db_path(),
6983 max_nodes: default_knowledge_max_nodes(),
6984 auto_capture: false,
6985 suggest_on_query: true,
6986 }
6987 }
6988}
6989
6990#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6997#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6998#[prefix = "linkedin"]
6999pub struct LinkedInConfig {
7000 #[serde(default)]
7002 pub enabled: bool,
7003
7004 #[serde(default = "default_linkedin_api_version")]
7006 pub api_version: String,
7007
7008 #[serde(default)]
7010 #[nested]
7011 pub content: LinkedInContentConfig,
7012
7013 #[serde(default)]
7015 #[nested]
7016 pub image: LinkedInImageConfig,
7017}
7018
7019impl Default for LinkedInConfig {
7020 fn default() -> Self {
7021 Self {
7022 enabled: false,
7023 api_version: default_linkedin_api_version(),
7024 content: LinkedInContentConfig::default(),
7025 image: LinkedInImageConfig::default(),
7026 }
7027 }
7028}
7029
7030fn default_linkedin_api_version() -> String {
7031 "202602".to_string()
7032}
7033
7034#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7036#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7037#[prefix = "plugins"]
7038pub struct PluginsConfig {
7039 #[serde(default)]
7041 pub enabled: bool,
7042 #[serde(default = "default_plugins_dir")]
7044 pub plugins_dir: String,
7045 #[serde(default)]
7047 pub auto_discover: bool,
7048 #[serde(default = "default_max_plugins")]
7050 pub max_plugins: usize,
7051 #[serde(default)]
7053 #[nested]
7054 pub security: PluginSecurityConfig,
7055}
7056
7057#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7064#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7065#[prefix = "plugins.security"]
7066pub struct PluginSecurityConfig {
7067 #[serde(default = "default_signature_mode")]
7069 pub signature_mode: String,
7070 #[serde(default)]
7072 pub trusted_publisher_keys: Vec<String>,
7073}
7074
7075fn default_signature_mode() -> String {
7076 "disabled".to_string()
7077}
7078
7079impl Default for PluginSecurityConfig {
7080 fn default() -> Self {
7081 Self {
7082 signature_mode: default_signature_mode(),
7083 trusted_publisher_keys: Vec::new(),
7084 }
7085 }
7086}
7087
7088fn default_plugins_dir() -> String {
7089 default_path_under_config_dir("plugins")
7090}
7091
7092fn default_max_plugins() -> usize {
7093 50
7094}
7095
7096impl Default for PluginsConfig {
7097 fn default() -> Self {
7098 Self {
7099 enabled: false,
7100 plugins_dir: default_plugins_dir(),
7101 auto_discover: false,
7102 max_plugins: default_max_plugins(),
7103 security: PluginSecurityConfig::default(),
7104 }
7105 }
7106}
7107
7108#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
7113#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7114#[prefix = "linkedin.content"]
7115pub struct LinkedInContentConfig {
7116 #[serde(default)]
7118 pub rss_feeds: Vec<String>,
7119
7120 #[serde(default)]
7122 pub github_users: Vec<String>,
7123
7124 #[serde(default)]
7126 pub github_repos: Vec<String>,
7127
7128 #[serde(default)]
7130 pub topics: Vec<String>,
7131
7132 #[serde(default)]
7134 pub persona: String,
7135
7136 #[serde(default)]
7138 pub instructions: String,
7139}
7140
7141#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7143#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7144#[prefix = "linkedin.image"]
7145pub struct LinkedInImageConfig {
7146 #[serde(default)]
7148 pub enabled: bool,
7149
7150 #[serde(default = "default_image_providers")]
7152 pub providers: Vec<String>,
7153
7154 #[serde(default = "default_true")]
7156 pub fallback_card: bool,
7157
7158 #[serde(default = "default_card_accent_color")]
7160 pub card_accent_color: String,
7161
7162 #[serde(default = "default_image_temp_dir")]
7164 pub temp_dir: String,
7165
7166 #[serde(default)]
7168 #[nested]
7169 pub stability: ImageProviderStabilityConfig,
7170
7171 #[serde(default)]
7173 #[nested]
7174 pub imagen: ImageProviderImagenConfig,
7175
7176 #[serde(default)]
7178 #[nested]
7179 pub dalle: ImageProviderDalleConfig,
7180
7181 #[serde(default)]
7183 #[nested]
7184 pub flux: ImageProviderFluxConfig,
7185}
7186
7187fn default_image_providers() -> Vec<String> {
7188 vec![
7189 "stability".into(),
7190 "imagen".into(),
7191 "dalle".into(),
7192 "flux".into(),
7193 ]
7194}
7195
7196fn default_card_accent_color() -> String {
7197 "#0A66C2".into()
7198}
7199
7200fn default_image_temp_dir() -> String {
7201 "linkedin/images".into()
7202}
7203
7204impl Default for LinkedInImageConfig {
7205 fn default() -> Self {
7206 Self {
7207 enabled: false,
7208 providers: default_image_providers(),
7209 fallback_card: true,
7210 card_accent_color: default_card_accent_color(),
7211 temp_dir: default_image_temp_dir(),
7212 stability: ImageProviderStabilityConfig::default(),
7213 imagen: ImageProviderImagenConfig::default(),
7214 dalle: ImageProviderDalleConfig::default(),
7215 flux: ImageProviderFluxConfig::default(),
7216 }
7217 }
7218}
7219
7220#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7222#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7223#[prefix = "linkedin.image.stability"]
7224pub struct ImageProviderStabilityConfig {
7225 #[serde(default = "default_stability_api_key_env")]
7227 #[credential_class = "legacy_env_path"]
7228 pub api_key_env: String,
7229 #[serde(default = "default_stability_model")]
7231 pub model: String,
7232}
7233
7234fn default_stability_api_key_env() -> String {
7235 "STABILITY_API_KEY".into()
7236}
7237fn default_stability_model() -> String {
7238 "stable-diffusion-xl-1024-v1-0".into()
7239}
7240
7241impl Default for ImageProviderStabilityConfig {
7242 fn default() -> Self {
7243 Self {
7244 api_key_env: default_stability_api_key_env(),
7245 model: default_stability_model(),
7246 }
7247 }
7248}
7249
7250#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7252#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7253#[prefix = "linkedin.image.imagen"]
7254pub struct ImageProviderImagenConfig {
7255 #[serde(default = "default_imagen_api_key_env")]
7257 #[credential_class = "legacy_env_path"]
7258 pub api_key_env: String,
7259 #[serde(default = "default_imagen_project_id_env")]
7261 #[credential_class = "legacy_env_path"]
7262 pub project_id_env: String,
7263 #[serde(default = "default_imagen_region")]
7265 pub region: String,
7266}
7267
7268fn default_imagen_api_key_env() -> String {
7269 "GOOGLE_VERTEX_API_KEY".into()
7270}
7271fn default_imagen_project_id_env() -> String {
7272 "GOOGLE_CLOUD_PROJECT".into()
7273}
7274fn default_imagen_region() -> String {
7275 "us-central1".into()
7276}
7277
7278impl Default for ImageProviderImagenConfig {
7279 fn default() -> Self {
7280 Self {
7281 api_key_env: default_imagen_api_key_env(),
7282 project_id_env: default_imagen_project_id_env(),
7283 region: default_imagen_region(),
7284 }
7285 }
7286}
7287
7288#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7290#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7291#[prefix = "linkedin.image.dalle"]
7292pub struct ImageProviderDalleConfig {
7293 #[serde(default = "default_dalle_api_key_env")]
7295 #[credential_class = "legacy_env_path"]
7296 pub api_key_env: String,
7297 #[serde(default = "default_dalle_model")]
7299 pub model: String,
7300 #[serde(default = "default_dalle_size")]
7302 pub size: String,
7303}
7304
7305fn default_dalle_api_key_env() -> String {
7306 "OPENAI_API_KEY".into()
7307}
7308fn default_dalle_model() -> String {
7309 "dall-e-3".into()
7310}
7311fn default_dalle_size() -> String {
7312 "1024x1024".into()
7313}
7314
7315impl Default for ImageProviderDalleConfig {
7316 fn default() -> Self {
7317 Self {
7318 api_key_env: default_dalle_api_key_env(),
7319 model: default_dalle_model(),
7320 size: default_dalle_size(),
7321 }
7322 }
7323}
7324
7325#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7327#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7328#[prefix = "linkedin.image.flux"]
7329pub struct ImageProviderFluxConfig {
7330 #[serde(default = "default_flux_api_key_env")]
7332 #[credential_class = "legacy_env_path"]
7333 pub api_key_env: String,
7334 #[serde(default = "default_flux_model")]
7336 pub model: String,
7337}
7338
7339fn default_flux_api_key_env() -> String {
7340 "FAL_API_KEY".into()
7341}
7342fn default_flux_model() -> String {
7343 "fal-ai/flux/schnell".into()
7344}
7345
7346impl Default for ImageProviderFluxConfig {
7347 fn default() -> Self {
7348 Self {
7349 api_key_env: default_flux_api_key_env(),
7350 model: default_flux_model(),
7351 }
7352 }
7353}
7354
7355#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7363#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7364#[prefix = "image_gen"]
7365pub struct ImageGenConfig {
7366 #[serde(default)]
7368 pub enabled: bool,
7369
7370 #[serde(default = "default_image_gen_model")]
7372 pub default_model: String,
7373
7374 #[serde(default = "default_image_gen_api_key_env")]
7376 #[credential_class = "legacy_env_path"]
7377 pub api_key_env: String,
7378}
7379
7380fn default_image_gen_model() -> String {
7381 "fal-ai/flux/schnell".into()
7382}
7383
7384fn default_image_gen_api_key_env() -> String {
7385 "FAL_API_KEY".into()
7386}
7387
7388impl Default for ImageGenConfig {
7389 fn default() -> Self {
7390 Self {
7391 enabled: false,
7392 default_model: default_image_gen_model(),
7393 api_key_env: default_image_gen_api_key_env(),
7394 }
7395 }
7396}
7397
7398#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7410#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7411#[prefix = "file_upload"]
7412pub struct FileUploadConfig {
7413 #[serde(default)]
7415 pub url: Option<String>,
7416
7417 #[serde(default = "default_file_upload_method")]
7419 pub method: String,
7420
7421 #[serde(default = "default_file_upload_field_name")]
7423 pub field_name: String,
7424
7425 #[serde(default = "default_file_upload_max_size_bytes")]
7428 pub max_file_size_bytes: u64,
7429
7430 #[serde(default = "default_file_upload_timeout_secs")]
7432 pub timeout_secs: u64,
7433
7434 #[serde(default)]
7437 #[secret]
7438 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
7439 pub headers: HashMap<String, String>,
7440}
7441
7442fn default_file_upload_method() -> String {
7443 "POST".into()
7444}
7445
7446fn default_file_upload_field_name() -> String {
7447 "file".into()
7448}
7449
7450fn default_file_upload_max_size_bytes() -> u64 {
7451 25 * 1024 * 1024
7452}
7453
7454fn default_file_upload_timeout_secs() -> u64 {
7455 60
7456}
7457
7458impl Default for FileUploadConfig {
7459 fn default() -> Self {
7460 Self {
7461 url: None,
7462 method: default_file_upload_method(),
7463 field_name: default_file_upload_field_name(),
7464 max_file_size_bytes: default_file_upload_max_size_bytes(),
7465 timeout_secs: default_file_upload_timeout_secs(),
7466 headers: HashMap::new(),
7467 }
7468 }
7469}
7470
7471#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7483#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7484#[prefix = "file-upload-bundle"]
7485pub struct FileUploadBundleConfig {
7486 #[serde(default)]
7488 pub url: Option<String>,
7489
7490 #[serde(default = "default_file_upload_bundle_method")]
7492 pub method: String,
7493
7494 #[serde(default = "default_file_upload_bundle_field_name")]
7496 pub field_name: String,
7497
7498 #[serde(default = "default_file_upload_bundle_max_file_size_bytes")]
7500 pub max_file_size_bytes: u64,
7501
7502 #[serde(default = "default_file_upload_bundle_max_total_size_bytes")]
7504 pub max_total_size_bytes: u64,
7505
7506 #[serde(default = "default_file_upload_bundle_max_files")]
7508 pub max_files: u32,
7509
7510 #[serde(default = "default_file_upload_bundle_timeout_secs")]
7512 pub timeout_secs: u64,
7513
7514 #[serde(default = "default_file_upload_bundle_max_response_body_bytes")]
7518 pub max_response_body_bytes: usize,
7519
7520 #[serde(default)]
7522 pub headers: HashMap<String, String>,
7523}
7524
7525fn default_file_upload_bundle_method() -> String {
7526 "POST".into()
7527}
7528
7529fn default_file_upload_bundle_field_name() -> String {
7530 "file".into()
7531}
7532
7533fn default_file_upload_bundle_max_file_size_bytes() -> u64 {
7534 10 * 1024 * 1024
7535}
7536
7537fn default_file_upload_bundle_max_total_size_bytes() -> u64 {
7538 32 * 1024 * 1024
7539}
7540
7541fn default_file_upload_bundle_max_files() -> u32 {
7542 16
7543}
7544
7545fn default_file_upload_bundle_timeout_secs() -> u64 {
7546 120
7547}
7548
7549fn default_file_upload_bundle_max_response_body_bytes() -> usize {
7550 4 * 1024
7551}
7552
7553impl Default for FileUploadBundleConfig {
7554 fn default() -> Self {
7555 Self {
7556 url: None,
7557 method: default_file_upload_bundle_method(),
7558 field_name: default_file_upload_bundle_field_name(),
7559 max_file_size_bytes: default_file_upload_bundle_max_file_size_bytes(),
7560 max_total_size_bytes: default_file_upload_bundle_max_total_size_bytes(),
7561 max_files: default_file_upload_bundle_max_files(),
7562 timeout_secs: default_file_upload_bundle_timeout_secs(),
7563 max_response_body_bytes: default_file_upload_bundle_max_response_body_bytes(),
7564 headers: HashMap::new(),
7565 }
7566 }
7567}
7568
7569#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7582#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7583#[prefix = "file-download"]
7584pub struct FileDownloadConfig {
7585 #[serde(default)]
7588 pub url: Option<String>,
7589
7590 #[serde(default = "default_file_download_max_size_bytes")]
7595 pub max_file_size_bytes: u64,
7596
7597 #[serde(default = "default_file_download_timeout_secs")]
7599 pub timeout_secs: u64,
7600
7601 #[serde(default)]
7605 pub headers: HashMap<String, String>,
7606}
7607
7608fn default_file_download_max_size_bytes() -> u64 {
7609 25 * 1024 * 1024
7610}
7611
7612fn default_file_download_timeout_secs() -> u64 {
7613 120
7614}
7615
7616impl Default for FileDownloadConfig {
7617 fn default() -> Self {
7618 Self {
7619 url: None,
7620 max_file_size_bytes: default_file_download_max_size_bytes(),
7621 timeout_secs: default_file_download_timeout_secs(),
7622 headers: HashMap::new(),
7623 }
7624 }
7625}
7626
7627#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7635#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7636#[prefix = "claude_code"]
7637pub struct ClaudeCodeConfig {
7638 #[serde(default)]
7640 pub enabled: bool,
7641 #[serde(default = "default_claude_code_timeout_secs")]
7643 pub timeout_secs: u64,
7644 #[serde(default = "default_claude_code_allowed_tools")]
7646 pub allowed_tools: Vec<String>,
7647 #[serde(default)]
7649 pub system_prompt: Option<String>,
7650 #[serde(default = "default_claude_code_max_output_bytes")]
7652 pub max_output_bytes: usize,
7653 #[serde(default)]
7655 #[credential_class = "legacy_env_path"]
7656 pub env_passthrough: Vec<String>,
7657}
7658
7659fn default_claude_code_timeout_secs() -> u64 {
7660 600
7661}
7662
7663fn default_claude_code_allowed_tools() -> Vec<String> {
7664 vec!["Read".into(), "Edit".into(), "Bash".into(), "Write".into()]
7665}
7666
7667fn default_claude_code_max_output_bytes() -> usize {
7668 2_097_152
7669}
7670
7671impl Default for ClaudeCodeConfig {
7672 fn default() -> Self {
7673 Self {
7674 enabled: false,
7675 timeout_secs: default_claude_code_timeout_secs(),
7676 allowed_tools: default_claude_code_allowed_tools(),
7677 system_prompt: None,
7678 max_output_bytes: default_claude_code_max_output_bytes(),
7679 env_passthrough: Vec::new(),
7680 }
7681 }
7682}
7683
7684#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7692#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7693#[prefix = "claude_code_runner"]
7694pub struct ClaudeCodeRunnerConfig {
7695 #[serde(default)]
7697 pub enabled: bool,
7698 #[serde(default)]
7700 pub ssh_host: Option<String>,
7701 #[serde(default = "default_claude_code_runner_tmux_prefix")]
7703 pub tmux_prefix: String,
7704 #[serde(default = "default_claude_code_runner_session_ttl")]
7706 pub session_ttl: u64,
7707}
7708
7709fn default_claude_code_runner_tmux_prefix() -> String {
7710 "zc-claude-".into()
7711}
7712
7713fn default_claude_code_runner_session_ttl() -> u64 {
7714 3600
7715}
7716
7717impl Default for ClaudeCodeRunnerConfig {
7718 fn default() -> Self {
7719 Self {
7720 enabled: false,
7721 ssh_host: None,
7722 tmux_prefix: default_claude_code_runner_tmux_prefix(),
7723 session_ttl: default_claude_code_runner_session_ttl(),
7724 }
7725 }
7726}
7727
7728#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7736#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7737#[prefix = "codex_cli"]
7738pub struct CodexCliConfig {
7739 #[serde(default)]
7741 pub enabled: bool,
7742 #[serde(default = "default_codex_cli_timeout_secs")]
7744 pub timeout_secs: u64,
7745 #[serde(default = "default_codex_cli_max_output_bytes")]
7747 pub max_output_bytes: usize,
7748 #[serde(default)]
7750 #[credential_class = "legacy_env_path"]
7751 pub env_passthrough: Vec<String>,
7752 #[serde(default)]
7764 pub extra_args: Vec<String>,
7765}
7766
7767fn default_codex_cli_timeout_secs() -> u64 {
7768 600
7769}
7770
7771fn default_codex_cli_max_output_bytes() -> usize {
7772 2_097_152
7773}
7774
7775impl Default for CodexCliConfig {
7776 fn default() -> Self {
7777 Self {
7778 enabled: false,
7779 timeout_secs: default_codex_cli_timeout_secs(),
7780 max_output_bytes: default_codex_cli_max_output_bytes(),
7781 env_passthrough: Vec::new(),
7782 extra_args: Vec::new(),
7783 }
7784 }
7785}
7786
7787#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7795#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7796#[prefix = "gemini_cli"]
7797pub struct GeminiCliConfig {
7798 #[serde(default)]
7800 pub enabled: bool,
7801 #[serde(default = "default_gemini_cli_timeout_secs")]
7803 pub timeout_secs: u64,
7804 #[serde(default = "default_gemini_cli_max_output_bytes")]
7806 pub max_output_bytes: usize,
7807 #[serde(default)]
7809 #[credential_class = "legacy_env_path"]
7810 pub env_passthrough: Vec<String>,
7811}
7812
7813fn default_gemini_cli_timeout_secs() -> u64 {
7814 600
7815}
7816
7817fn default_gemini_cli_max_output_bytes() -> usize {
7818 2_097_152
7819}
7820
7821impl Default for GeminiCliConfig {
7822 fn default() -> Self {
7823 Self {
7824 enabled: false,
7825 timeout_secs: default_gemini_cli_timeout_secs(),
7826 max_output_bytes: default_gemini_cli_max_output_bytes(),
7827 env_passthrough: Vec::new(),
7828 }
7829 }
7830}
7831
7832#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7840#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7841#[prefix = "opencode_cli"]
7842pub struct OpenCodeCliConfig {
7843 #[serde(default)]
7845 pub enabled: bool,
7846 #[serde(default = "default_opencode_cli_timeout_secs")]
7848 pub timeout_secs: u64,
7849 #[serde(default = "default_opencode_cli_max_output_bytes")]
7851 pub max_output_bytes: usize,
7852 #[serde(default)]
7854 #[credential_class = "legacy_env_path"]
7855 pub env_passthrough: Vec<String>,
7856}
7857
7858fn default_opencode_cli_timeout_secs() -> u64 {
7859 600
7860}
7861
7862fn default_opencode_cli_max_output_bytes() -> usize {
7863 2_097_152
7864}
7865
7866impl Default for OpenCodeCliConfig {
7867 fn default() -> Self {
7868 Self {
7869 enabled: false,
7870 timeout_secs: default_opencode_cli_timeout_secs(),
7871 max_output_bytes: default_opencode_cli_max_output_bytes(),
7872 env_passthrough: Vec::new(),
7873 }
7874 }
7875}
7876
7877#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
7881#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7882#[serde(rename_all = "snake_case")]
7883pub enum ProxyScope {
7884 Environment,
7886 #[default]
7888 Zeroclaw,
7889 Services,
7891}
7892
7893#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7895#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7896#[prefix = "proxy"]
7897pub struct ProxyConfig {
7898 #[serde(default)]
7900 pub enabled: bool,
7901 #[serde(default)]
7903 pub http_proxy: Option<String>,
7904 #[serde(default)]
7906 pub https_proxy: Option<String>,
7907 #[serde(default)]
7909 pub all_proxy: Option<String>,
7910 #[serde(default)]
7912 pub no_proxy: Vec<String>,
7913 #[serde(default)]
7915 pub scope: ProxyScope,
7916 #[serde(default)]
7918 pub services: Vec<String>,
7919}
7920
7921impl Default for ProxyConfig {
7922 fn default() -> Self {
7923 Self {
7924 enabled: false,
7925 http_proxy: None,
7926 https_proxy: None,
7927 all_proxy: None,
7928 no_proxy: Vec::new(),
7929 scope: ProxyScope::Zeroclaw,
7930 services: Vec::new(),
7931 }
7932 }
7933}
7934
7935impl ProxyConfig {
7936 pub fn supported_service_keys() -> &'static [&'static str] {
7937 SUPPORTED_PROXY_SERVICE_KEYS
7938 }
7939
7940 pub fn supported_service_selectors() -> &'static [&'static str] {
7941 SUPPORTED_PROXY_SERVICE_SELECTORS
7942 }
7943
7944 pub fn has_any_proxy_url(&self) -> bool {
7945 normalize_proxy_url_option(self.http_proxy.as_deref()).is_some()
7946 || normalize_proxy_url_option(self.https_proxy.as_deref()).is_some()
7947 || normalize_proxy_url_option(self.all_proxy.as_deref()).is_some()
7948 }
7949
7950 pub fn normalized_services(&self) -> Vec<String> {
7951 normalize_service_list(self.services.clone())
7952 }
7953
7954 pub fn normalized_no_proxy(&self) -> Vec<String> {
7955 normalize_no_proxy_list(self.no_proxy.clone())
7956 }
7957
7958 pub fn validate(&self) -> Result<()> {
7959 for (field, value) in [
7960 ("http_proxy", self.http_proxy.as_deref()),
7961 ("https_proxy", self.https_proxy.as_deref()),
7962 ("all_proxy", self.all_proxy.as_deref()),
7963 ] {
7964 if let Some(url) = normalize_proxy_url_option(value) {
7965 validate_proxy_url(field, &url)?;
7966 }
7967 }
7968
7969 for selector in self.normalized_services() {
7970 if !is_supported_proxy_service_selector(&selector) {
7971 anyhow::bail!(
7972 "Unsupported proxy service selector '{selector}'. Use tool `proxy_config` action `list_services` for valid values"
7973 );
7974 }
7975 }
7976
7977 if self.enabled && !self.has_any_proxy_url() {
7978 anyhow::bail!(
7979 "Proxy is enabled but no proxy URL is configured. Set at least one of http_proxy, https_proxy, or all_proxy"
7980 );
7981 }
7982
7983 if self.enabled
7984 && self.scope == ProxyScope::Services
7985 && self.normalized_services().is_empty()
7986 {
7987 anyhow::bail!(
7988 "proxy.scope='services' requires a non-empty proxy.services list when proxy is enabled"
7989 );
7990 }
7991
7992 Ok(())
7993 }
7994
7995 pub fn should_apply_to_service(&self, service_key: &str) -> bool {
7996 if !self.enabled {
7997 return false;
7998 }
7999
8000 match self.scope {
8001 ProxyScope::Environment => false,
8002 ProxyScope::Zeroclaw => true,
8003 ProxyScope::Services => {
8004 let service_key = service_key.trim().to_ascii_lowercase();
8005 if service_key.is_empty() {
8006 return false;
8007 }
8008
8009 self.normalized_services()
8010 .iter()
8011 .any(|selector| service_selector_matches(selector, &service_key))
8012 }
8013 }
8014 }
8015
8016 pub fn apply_to_reqwest_builder(
8017 &self,
8018 mut builder: reqwest::ClientBuilder,
8019 service_key: &str,
8020 ) -> reqwest::ClientBuilder {
8021 if !self.should_apply_to_service(service_key) {
8022 return builder;
8023 }
8024
8025 let no_proxy = self.no_proxy_value();
8026
8027 if let Some(url) = normalize_proxy_url_option(self.all_proxy.as_deref()) {
8028 match reqwest::Proxy::all(&url) {
8029 Ok(proxy) => {
8030 builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
8031 }
8032 Err(error) => {
8033 ::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: ");
8034 }
8035 }
8036 }
8037
8038 if let Some(url) = normalize_proxy_url_option(self.http_proxy.as_deref()) {
8039 match reqwest::Proxy::http(&url) {
8040 Ok(proxy) => {
8041 builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
8042 }
8043 Err(error) => {
8044 ::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: ");
8045 }
8046 }
8047 }
8048
8049 if let Some(url) = normalize_proxy_url_option(self.https_proxy.as_deref()) {
8050 match reqwest::Proxy::https(&url) {
8051 Ok(proxy) => {
8052 builder = builder.proxy(apply_no_proxy(proxy, no_proxy));
8053 }
8054 Err(error) => {
8055 ::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: ");
8056 }
8057 }
8058 }
8059
8060 builder
8061 }
8062
8063 pub fn apply_to_process_env(&self) {
8064 set_proxy_env_pair("HTTP_PROXY", self.http_proxy.as_deref());
8065 set_proxy_env_pair("HTTPS_PROXY", self.https_proxy.as_deref());
8066 set_proxy_env_pair("ALL_PROXY", self.all_proxy.as_deref());
8067
8068 let no_proxy_joined = {
8069 let list = self.normalized_no_proxy();
8070 (!list.is_empty()).then(|| list.join(","))
8071 };
8072 set_proxy_env_pair("NO_PROXY", no_proxy_joined.as_deref());
8073 }
8074
8075 pub fn clear_process_env() {
8076 clear_proxy_env_pair("HTTP_PROXY");
8077 clear_proxy_env_pair("HTTPS_PROXY");
8078 clear_proxy_env_pair("ALL_PROXY");
8079 clear_proxy_env_pair("NO_PROXY");
8080 }
8081
8082 fn no_proxy_value(&self) -> Option<reqwest::NoProxy> {
8083 let joined = {
8084 let list = self.normalized_no_proxy();
8085 (!list.is_empty()).then(|| list.join(","))
8086 };
8087 joined.as_deref().and_then(reqwest::NoProxy::from_string)
8088 }
8089}
8090
8091fn apply_no_proxy(proxy: reqwest::Proxy, no_proxy: Option<reqwest::NoProxy>) -> reqwest::Proxy {
8092 proxy.no_proxy(no_proxy)
8093}
8094
8095fn normalize_proxy_url_option(raw: Option<&str>) -> Option<String> {
8096 let value = raw?.trim();
8097 (!value.is_empty()).then(|| value.to_string())
8098}
8099
8100fn normalize_no_proxy_list(values: Vec<String>) -> Vec<String> {
8101 normalize_comma_values(values)
8102}
8103
8104fn normalize_service_list(values: Vec<String>) -> Vec<String> {
8105 let mut normalized = normalize_comma_values(values)
8106 .into_iter()
8107 .map(|value| value.to_ascii_lowercase())
8108 .collect::<Vec<_>>();
8109 normalized.sort_unstable();
8110 normalized.dedup();
8111 normalized
8112}
8113
8114fn normalize_comma_values(values: Vec<String>) -> Vec<String> {
8115 let mut output = Vec::new();
8116 for value in values {
8117 for part in value.split(',') {
8118 let normalized = part.trim();
8119 if normalized.is_empty() {
8120 continue;
8121 }
8122 output.push(normalized.to_string());
8123 }
8124 }
8125 output.sort_unstable();
8126 output.dedup();
8127 output
8128}
8129
8130fn is_supported_proxy_service_selector(selector: &str) -> bool {
8131 if SUPPORTED_PROXY_SERVICE_KEYS
8132 .iter()
8133 .any(|known| known.eq_ignore_ascii_case(selector))
8134 {
8135 return true;
8136 }
8137
8138 SUPPORTED_PROXY_SERVICE_SELECTORS
8139 .iter()
8140 .any(|known| known.eq_ignore_ascii_case(selector))
8141}
8142
8143fn service_selector_matches(selector: &str, service_key: &str) -> bool {
8144 if selector == service_key {
8145 return true;
8146 }
8147
8148 if let Some(prefix) = selector.strip_suffix(".*") {
8149 return service_key.starts_with(prefix)
8150 && service_key
8151 .strip_prefix(prefix)
8152 .is_some_and(|suffix| suffix.starts_with('.'));
8153 }
8154
8155 false
8156}
8157
8158const MCP_MAX_TOOL_TIMEOUT_SECS: u64 = 600;
8159
8160fn validate_mcp_config(config: &McpConfig) -> Result<()> {
8161 let mut seen_names = std::collections::HashSet::new();
8162 for (i, server) in config.servers.iter().enumerate() {
8163 let name = server.name.trim();
8164 if name.is_empty() {
8165 validation_bail!(
8166 RequiredFieldEmpty,
8167 format!("mcp.servers[{i}].name"),
8168 "mcp.servers[{i}].name must not be empty"
8169 );
8170 }
8171 if !seen_names.insert(name.to_ascii_lowercase()) {
8172 anyhow::bail!("mcp.servers contains duplicate name: {name}");
8173 }
8174
8175 if let Some(timeout) = server.tool_timeout_secs {
8176 if timeout == 0 {
8177 validation_bail!(
8178 InvalidNumericRange,
8179 format!("mcp.servers[{i}].tool_timeout_secs"),
8180 "mcp.servers[{i}].tool_timeout_secs must be greater than 0"
8181 );
8182 }
8183 if timeout > MCP_MAX_TOOL_TIMEOUT_SECS {
8184 anyhow::bail!(
8185 "mcp.servers[{i}].tool_timeout_secs exceeds max {MCP_MAX_TOOL_TIMEOUT_SECS}"
8186 );
8187 }
8188 }
8189
8190 match server.transport {
8191 McpTransport::Stdio => {
8192 if server.command.trim().is_empty() {
8193 anyhow::bail!(
8194 "mcp.servers[{i}] with transport=stdio requires non-empty command"
8195 );
8196 }
8197 }
8198 McpTransport::Http | McpTransport::Sse => {
8199 let url = server
8200 .url
8201 .as_deref()
8202 .map(str::trim)
8203 .filter(|value| !value.is_empty())
8204 .ok_or_else(|| {
8205 let transport_str = match server.transport {
8206 McpTransport::Http => "http",
8207 McpTransport::Sse => "sse",
8208 McpTransport::Stdio => "stdio",
8209 };
8210 ::zeroclaw_log::record!(
8211 WARN,
8212 ::zeroclaw_log::Event::new(
8213 module_path!(),
8214 ::zeroclaw_log::Action::Reject
8215 )
8216 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
8217 .with_attrs(::serde_json::json!({
8218 "index": i,
8219 "transport": transport_str,
8220 })),
8221 "mcp.servers entry rejected: transport requires url"
8222 );
8223 anyhow::Error::msg(format!(
8224 "mcp.servers[{i}] with transport={transport_str} requires url"
8225 ))
8226 })?;
8227 let parsed = reqwest::Url::parse(url)
8228 .with_context(|| format!("mcp.servers[{i}].url is not a valid URL"))?;
8229 if !matches!(parsed.scheme(), "http" | "https") {
8230 anyhow::bail!("mcp.servers[{i}].url must use http/https");
8231 }
8232 }
8233 }
8234 }
8235 Ok(())
8236}
8237
8238fn validate_proxy_url(field: &str, url: &str) -> Result<()> {
8239 let parsed = reqwest::Url::parse(url)
8240 .with_context(|| format!("Invalid {field} URL: '{url}' is not a valid URL"))?;
8241
8242 match parsed.scheme() {
8243 "http" | "https" | "socks5" | "socks5h" | "socks" => {}
8244 scheme => {
8245 anyhow::bail!(
8246 "Invalid {field} URL scheme '{scheme}'. Allowed: http, https, socks5, socks5h, socks"
8247 );
8248 }
8249 }
8250
8251 if parsed.host_str().is_none() {
8252 anyhow::bail!("Invalid {field} URL: host is required");
8253 }
8254
8255 Ok(())
8256}
8257
8258fn set_proxy_env_pair(key: &str, value: Option<&str>) {
8259 let lowercase_key = key.to_ascii_lowercase();
8260 if let Some(value) = value.and_then(|candidate| normalize_proxy_url_option(Some(candidate))) {
8261 unsafe {
8263 std::env::set_var(key, &value);
8264 std::env::set_var(lowercase_key, value);
8265 }
8266 } else {
8267 unsafe {
8269 std::env::remove_var(key);
8270 std::env::remove_var(lowercase_key);
8271 }
8272 }
8273}
8274
8275fn clear_proxy_env_pair(key: &str) {
8276 unsafe {
8278 std::env::remove_var(key);
8279 std::env::remove_var(key.to_ascii_lowercase());
8280 }
8281}
8282
8283fn runtime_proxy_state() -> &'static RwLock<ProxyConfig> {
8284 RUNTIME_PROXY_CONFIG.get_or_init(|| RwLock::new(ProxyConfig::default()))
8285}
8286
8287fn runtime_proxy_client_cache() -> &'static RwLock<HashMap<String, reqwest::Client>> {
8288 RUNTIME_PROXY_CLIENT_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
8289}
8290
8291fn clear_runtime_proxy_client_cache() {
8292 match runtime_proxy_client_cache().write() {
8293 Ok(mut guard) => {
8294 guard.clear();
8295 }
8296 Err(poisoned) => {
8297 poisoned.into_inner().clear();
8298 }
8299 }
8300}
8301
8302fn runtime_proxy_cache_key(
8303 service_key: &str,
8304 timeout_secs: Option<u64>,
8305 connect_timeout_secs: Option<u64>,
8306) -> String {
8307 format!(
8308 "{}|timeout={}|connect_timeout={}",
8309 service_key.trim().to_ascii_lowercase(),
8310 timeout_secs
8311 .map(|value| value.to_string())
8312 .unwrap_or_else(|| "none".to_string()),
8313 connect_timeout_secs
8314 .map(|value| value.to_string())
8315 .unwrap_or_else(|| "none".to_string())
8316 )
8317}
8318
8319fn runtime_proxy_cached_client(cache_key: &str) -> Option<reqwest::Client> {
8320 match runtime_proxy_client_cache().read() {
8321 Ok(guard) => guard.get(cache_key).cloned(),
8322 Err(poisoned) => poisoned.into_inner().get(cache_key).cloned(),
8323 }
8324}
8325
8326fn set_runtime_proxy_cached_client(cache_key: String, client: reqwest::Client) {
8327 match runtime_proxy_client_cache().write() {
8328 Ok(mut guard) => {
8329 guard.insert(cache_key, client);
8330 }
8331 Err(poisoned) => {
8332 poisoned.into_inner().insert(cache_key, client);
8333 }
8334 }
8335}
8336
8337pub fn set_runtime_proxy_config(config: ProxyConfig) {
8338 match runtime_proxy_state().write() {
8339 Ok(mut guard) => {
8340 *guard = config;
8341 }
8342 Err(poisoned) => {
8343 *poisoned.into_inner() = config;
8344 }
8345 }
8346
8347 clear_runtime_proxy_client_cache();
8348}
8349
8350pub fn runtime_proxy_config() -> ProxyConfig {
8351 match runtime_proxy_state().read() {
8352 Ok(guard) => guard.clone(),
8353 Err(poisoned) => poisoned.into_inner().clone(),
8354 }
8355}
8356
8357pub fn apply_runtime_proxy_to_builder(
8358 builder: reqwest::ClientBuilder,
8359 service_key: &str,
8360) -> reqwest::ClientBuilder {
8361 runtime_proxy_config().apply_to_reqwest_builder(builder, service_key)
8362}
8363
8364pub fn build_runtime_proxy_client(service_key: &str) -> reqwest::Client {
8365 let cache_key = runtime_proxy_cache_key(service_key, None, None);
8366 if let Some(client) = runtime_proxy_cached_client(&cache_key) {
8367 return client;
8368 }
8369
8370 let builder = apply_runtime_proxy_to_builder(reqwest::Client::builder(), service_key);
8371 let client = builder.build().unwrap_or_else(|error| {
8372 ::zeroclaw_log::record!(
8373 WARN,
8374 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
8375 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
8376 .with_attrs(
8377 ::serde_json::json!({"service_key": service_key, "error": format!("{}", error)})
8378 ),
8379 "Failed to build proxied client: "
8380 );
8381 reqwest::Client::new()
8382 });
8383 set_runtime_proxy_cached_client(cache_key, client.clone());
8384 client
8385}
8386
8387pub fn build_runtime_proxy_client_with_timeouts(
8388 service_key: &str,
8389 timeout_secs: u64,
8390 connect_timeout_secs: u64,
8391) -> reqwest::Client {
8392 let cache_key =
8393 runtime_proxy_cache_key(service_key, Some(timeout_secs), Some(connect_timeout_secs));
8394 if let Some(client) = runtime_proxy_cached_client(&cache_key) {
8395 return client;
8396 }
8397
8398 let builder = reqwest::Client::builder()
8399 .timeout(std::time::Duration::from_secs(timeout_secs))
8400 .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs));
8401 let builder = apply_runtime_proxy_to_builder(builder, service_key);
8402 let client = builder.build().unwrap_or_else(|error| {
8403 ::zeroclaw_log::record!(
8404 WARN,
8405 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
8406 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
8407 .with_attrs(
8408 ::serde_json::json!({"service_key": service_key, "error": format!("{}", error)})
8409 ),
8410 "Failed to build proxied timeout client: "
8411 );
8412 reqwest::Client::new()
8413 });
8414 set_runtime_proxy_cached_client(cache_key, client.clone());
8415 client
8416}
8417
8418pub fn build_channel_proxy_client(service_key: &str, proxy_url: Option<&str>) -> reqwest::Client {
8422 match normalize_proxy_url_option(proxy_url) {
8423 Some(url) => build_explicit_proxy_client(service_key, &url, None, None),
8424 None => build_runtime_proxy_client(service_key),
8425 }
8426}
8427
8428pub fn build_channel_proxy_client_with_timeouts(
8432 service_key: &str,
8433 proxy_url: Option<&str>,
8434 timeout_secs: u64,
8435 connect_timeout_secs: u64,
8436) -> reqwest::Client {
8437 match normalize_proxy_url_option(proxy_url) {
8438 Some(url) => build_explicit_proxy_client(
8439 service_key,
8440 &url,
8441 Some(timeout_secs),
8442 Some(connect_timeout_secs),
8443 ),
8444 None => build_runtime_proxy_client_with_timeouts(
8445 service_key,
8446 timeout_secs,
8447 connect_timeout_secs,
8448 ),
8449 }
8450}
8451
8452pub fn apply_channel_proxy_to_builder(
8455 builder: reqwest::ClientBuilder,
8456 service_key: &str,
8457 proxy_url: Option<&str>,
8458) -> reqwest::ClientBuilder {
8459 match normalize_proxy_url_option(proxy_url) {
8460 Some(url) => apply_explicit_proxy_to_builder(builder, service_key, &url),
8461 None => apply_runtime_proxy_to_builder(builder, service_key),
8462 }
8463}
8464
8465fn build_explicit_proxy_client(
8467 service_key: &str,
8468 proxy_url: &str,
8469 timeout_secs: Option<u64>,
8470 connect_timeout_secs: Option<u64>,
8471) -> reqwest::Client {
8472 let cache_key = format!(
8473 "explicit|{}|{}|timeout={}|connect_timeout={}",
8474 service_key.trim().to_ascii_lowercase(),
8475 proxy_url,
8476 timeout_secs
8477 .map(|v| v.to_string())
8478 .unwrap_or_else(|| "none".to_string()),
8479 connect_timeout_secs
8480 .map(|v| v.to_string())
8481 .unwrap_or_else(|| "none".to_string()),
8482 );
8483 if let Some(client) = runtime_proxy_cached_client(&cache_key) {
8484 return client;
8485 }
8486
8487 let mut builder = reqwest::Client::builder();
8488 if let Some(t) = timeout_secs {
8489 builder = builder.timeout(std::time::Duration::from_secs(t));
8490 }
8491 if let Some(ct) = connect_timeout_secs {
8492 builder = builder.connect_timeout(std::time::Duration::from_secs(ct));
8493 }
8494 builder = apply_explicit_proxy_to_builder(builder, service_key, proxy_url);
8495 let client = builder.build().unwrap_or_else(|error| {
8496 ::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: ");
8497 reqwest::Client::new()
8498 });
8499 set_runtime_proxy_cached_client(cache_key, client.clone());
8500 client
8501}
8502
8503fn apply_explicit_proxy_to_builder(
8505 mut builder: reqwest::ClientBuilder,
8506 service_key: &str,
8507 proxy_url: &str,
8508) -> reqwest::ClientBuilder {
8509 match reqwest::Proxy::all(proxy_url) {
8510 Ok(proxy) => {
8511 builder = builder.proxy(proxy);
8512 }
8513 Err(error) => {
8514 ::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: ");
8515 }
8516 }
8517 builder
8518}
8519
8520trait AsyncReadWrite: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
8531impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send> AsyncReadWrite for T {}
8532
8533pub struct BoxedIo(Box<dyn AsyncReadWrite>);
8541
8542impl tokio::io::AsyncRead for BoxedIo {
8543 fn poll_read(
8544 mut self: std::pin::Pin<&mut Self>,
8545 cx: &mut std::task::Context<'_>,
8546 buf: &mut tokio::io::ReadBuf<'_>,
8547 ) -> std::task::Poll<std::io::Result<()>> {
8548 std::pin::Pin::new(&mut *self.0).poll_read(cx, buf)
8549 }
8550}
8551
8552impl tokio::io::AsyncWrite for BoxedIo {
8553 fn poll_write(
8554 mut self: std::pin::Pin<&mut Self>,
8555 cx: &mut std::task::Context<'_>,
8556 buf: &[u8],
8557 ) -> std::task::Poll<std::io::Result<usize>> {
8558 std::pin::Pin::new(&mut *self.0).poll_write(cx, buf)
8559 }
8560
8561 fn poll_flush(
8562 mut self: std::pin::Pin<&mut Self>,
8563 cx: &mut std::task::Context<'_>,
8564 ) -> std::task::Poll<std::io::Result<()>> {
8565 std::pin::Pin::new(&mut *self.0).poll_flush(cx)
8566 }
8567
8568 fn poll_shutdown(
8569 mut self: std::pin::Pin<&mut Self>,
8570 cx: &mut std::task::Context<'_>,
8571 ) -> std::task::Poll<std::io::Result<()>> {
8572 std::pin::Pin::new(&mut *self.0).poll_shutdown(cx)
8573 }
8574}
8575
8576impl Unpin for BoxedIo {}
8577
8578pub type ProxiedWsStream = tokio_tungstenite::WebSocketStream<BoxedIo>;
8581
8582fn resolve_ws_proxy_url(
8586 service_key: &str,
8587 ws_url: &str,
8588 channel_proxy_url: Option<&str>,
8589) -> Option<String> {
8590 if let Some(url) = normalize_proxy_url_option(channel_proxy_url) {
8592 return Some(url);
8593 }
8594
8595 let cfg = runtime_proxy_config();
8597 if !cfg.should_apply_to_service(service_key) {
8598 return None;
8599 }
8600
8601 if let Ok(parsed) = reqwest::Url::parse(ws_url)
8603 && let Some(host) = parsed.host_str()
8604 {
8605 let no_proxy_entries = cfg.normalized_no_proxy();
8606 if !no_proxy_entries.is_empty() {
8607 let host_lower = host.to_ascii_lowercase();
8608 let matches_no_proxy = no_proxy_entries.iter().any(|entry| {
8609 let entry = entry.trim().to_ascii_lowercase();
8610 if entry == "*" {
8611 return true;
8612 }
8613 if host_lower == entry {
8614 return true;
8615 }
8616 if let Some(suffix) = entry.strip_prefix('.') {
8618 return host_lower.ends_with(suffix) || host_lower == suffix;
8619 }
8620 host_lower.ends_with(&format!(".{entry}"))
8622 });
8623 if matches_no_proxy {
8624 return None;
8625 }
8626 }
8627 }
8628
8629 let is_secure = ws_url.starts_with("wss://") || ws_url.starts_with("wss:");
8632 let preferred = if is_secure {
8633 normalize_proxy_url_option(cfg.https_proxy.as_deref())
8634 } else {
8635 normalize_proxy_url_option(cfg.http_proxy.as_deref())
8636 };
8637 preferred.or_else(|| normalize_proxy_url_option(cfg.all_proxy.as_deref()))
8638}
8639
8640pub async fn ws_connect_with_proxy(
8650 ws_url: &str,
8651 service_key: &str,
8652 channel_proxy_url: Option<&str>,
8653) -> anyhow::Result<(
8654 ProxiedWsStream,
8655 tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
8656)> {
8657 let proxy_url = resolve_ws_proxy_url(service_key, ws_url, channel_proxy_url);
8658
8659 match proxy_url {
8660 None => {
8661 use tokio::net::TcpStream;
8671
8672 let target = reqwest::Url::parse(ws_url)
8673 .with_context(|| format!("Invalid WebSocket URL: {ws_url}"))?;
8674 let target_host = target
8675 .host_str()
8676 .ok_or_else(|| {
8677 ::zeroclaw_log::record!(
8678 WARN,
8679 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
8680 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
8681 .with_attrs(::serde_json::json!({"ws_url": ws_url})),
8682 "WebSocket URL has no host"
8683 );
8684 anyhow::Error::msg(format!("WebSocket URL has no host: {ws_url}"))
8685 })?
8686 .to_string();
8687 let target_port = target
8688 .port_or_known_default()
8689 .unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });
8690
8691 let tcp = TcpStream::connect(format!("{target_host}:{target_port}"))
8692 .await
8693 .with_context(|| format!("TCP connect to {target_host}:{target_port}"))?;
8694
8695 let is_secure = target.scheme() == "wss";
8696 let stream: BoxedIo = if is_secure {
8697 let mut root_store = rustls::RootCertStore::empty();
8698 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
8699 let tls_config = std::sync::Arc::new(
8700 rustls::ClientConfig::builder()
8701 .with_root_certificates(root_store)
8702 .with_no_client_auth(),
8703 );
8704 let connector = tokio_rustls::TlsConnector::from(tls_config);
8705 let server_name = rustls_pki_types::ServerName::try_from(target_host.clone())
8706 .with_context(|| format!("Invalid TLS server name: {target_host}"))?;
8707 let tls_stream = connector
8708 .connect(server_name, tcp)
8709 .await
8710 .with_context(|| format!("TLS handshake with {target_host}"))?;
8711 BoxedIo(Box::new(tls_stream))
8712 } else {
8713 BoxedIo(Box::new(tcp))
8714 };
8715
8716 let default_port = if is_secure { 443 } else { 80 };
8717 let host_header = if target_port == default_port {
8718 target_host.clone()
8719 } else {
8720 format!("{target_host}:{target_port}")
8721 };
8722
8723 let ws_request = tokio_tungstenite::tungstenite::http::Request::builder()
8724 .uri(ws_url)
8725 .header("Host", host_header)
8726 .header("Connection", "Upgrade")
8727 .header("Upgrade", "websocket")
8728 .header(
8729 "Sec-WebSocket-Key",
8730 tokio_tungstenite::tungstenite::handshake::client::generate_key(),
8731 )
8732 .header("Sec-WebSocket-Version", "13")
8733 .body(())
8734 .with_context(|| "Failed to build WebSocket upgrade request")?;
8735
8736 let (ws_stream, response) =
8737 tokio_tungstenite::client_async(ws_request, stream)
8738 .await
8739 .with_context(|| format!("WebSocket handshake failed for {ws_url}"))?;
8740
8741 Ok((ws_stream, response))
8742 }
8743 Some(proxy) => ws_connect_via_proxy(ws_url, &proxy).await,
8744 }
8745}
8746
8747async fn ws_connect_via_proxy(
8749 ws_url: &str,
8750 proxy_url: &str,
8751) -> anyhow::Result<(
8752 ProxiedWsStream,
8753 tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
8754)> {
8755 use tokio::io::{AsyncReadExt, AsyncWriteExt as _};
8756 use tokio::net::TcpStream;
8757
8758 let target =
8759 reqwest::Url::parse(ws_url).with_context(|| format!("Invalid WebSocket URL: {ws_url}"))?;
8760 let target_host = target
8761 .host_str()
8762 .ok_or_else(|| {
8763 ::zeroclaw_log::record!(
8764 WARN,
8765 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
8766 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
8767 .with_attrs(::serde_json::json!({"ws_url": ws_url})),
8768 "WebSocket URL has no host"
8769 );
8770 anyhow::Error::msg(format!("WebSocket URL has no host: {ws_url}"))
8771 })?
8772 .to_string();
8773 let target_port = target
8774 .port_or_known_default()
8775 .unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });
8776
8777 let proxy = reqwest::Url::parse(proxy_url)
8778 .with_context(|| format!("Invalid proxy URL: {proxy_url}"))?;
8779
8780 let stream: BoxedIo = match proxy.scheme() {
8781 "socks5" | "socks5h" | "socks" => {
8782 let proxy_addr = format!(
8783 "{}:{}",
8784 proxy.host_str().unwrap_or("127.0.0.1"),
8785 proxy.port_or_known_default().unwrap_or(1080)
8786 );
8787 let target_addr = format!("{target_host}:{target_port}");
8788 let socks_stream = if proxy.username().is_empty() {
8789 tokio_socks::tcp::Socks5Stream::connect(proxy_addr.as_str(), target_addr.as_str())
8790 .await
8791 .with_context(|| format!("SOCKS5 connect to {target_addr} via {proxy_addr}"))?
8792 } else {
8793 let password = proxy.password().unwrap_or("");
8794 tokio_socks::tcp::Socks5Stream::connect_with_password(
8795 proxy_addr.as_str(),
8796 target_addr.as_str(),
8797 proxy.username(),
8798 password,
8799 )
8800 .await
8801 .with_context(|| format!("SOCKS5 auth connect to {target_addr} via {proxy_addr}"))?
8802 };
8803 let tcp: TcpStream = socks_stream.into_inner();
8804 BoxedIo(Box::new(tcp))
8805 }
8806 "http" | "https" => {
8807 let proxy_host = proxy.host_str().unwrap_or("127.0.0.1");
8808 let proxy_port = proxy.port_or_known_default().unwrap_or(8080);
8809 let proxy_addr = format!("{proxy_host}:{proxy_port}");
8810
8811 let mut tcp = TcpStream::connect(&proxy_addr)
8812 .await
8813 .with_context(|| format!("TCP connect to HTTP proxy {proxy_addr}"))?;
8814
8815 let connect_req = format!(
8817 "CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n\r\n"
8818 );
8819 tcp.write_all(connect_req.as_bytes()).await?;
8820
8821 let mut buf = vec![0u8; 4096];
8823 let mut total = 0usize;
8824 loop {
8825 let n = tcp.read(&mut buf[total..]).await?;
8826 if n == 0 {
8827 anyhow::bail!("HTTP CONNECT proxy closed connection before response");
8828 }
8829 total += n;
8830 if let Some(pos) = find_header_end(&buf[..total]) {
8832 let status_line = std::str::from_utf8(&buf[..pos])
8833 .unwrap_or("")
8834 .lines()
8835 .next()
8836 .unwrap_or("");
8837 if !status_line.contains("200") {
8838 anyhow::bail!(
8839 "HTTP CONNECT proxy returned non-200 response: {status_line}"
8840 );
8841 }
8842 break;
8843 }
8844 if total >= buf.len() {
8845 anyhow::bail!("HTTP CONNECT proxy response too large");
8846 }
8847 }
8848
8849 BoxedIo(Box::new(tcp))
8850 }
8851 scheme => {
8852 anyhow::bail!("Unsupported proxy scheme '{scheme}' for WebSocket connections");
8853 }
8854 };
8855
8856 let is_secure = target.scheme() == "wss";
8858 let stream: BoxedIo = if is_secure {
8859 let mut root_store = rustls::RootCertStore::empty();
8860 root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
8861 let tls_config = std::sync::Arc::new(
8862 rustls::ClientConfig::builder()
8863 .with_root_certificates(root_store)
8864 .with_no_client_auth(),
8865 );
8866 let connector = tokio_rustls::TlsConnector::from(tls_config);
8867 let server_name = rustls_pki_types::ServerName::try_from(target_host.clone())
8868 .with_context(|| format!("Invalid TLS server name: {target_host}"))?;
8869
8870 let tls_stream = connector
8874 .connect(server_name, stream)
8875 .await
8876 .with_context(|| format!("TLS handshake with {target_host}"))?;
8877 BoxedIo(Box::new(tls_stream))
8878 } else {
8879 stream
8880 };
8881
8882 let ws_request = tokio_tungstenite::tungstenite::http::Request::builder()
8884 .uri(ws_url)
8885 .header("Host", format!("{target_host}:{target_port}"))
8886 .header("Connection", "Upgrade")
8887 .header("Upgrade", "websocket")
8888 .header(
8889 "Sec-WebSocket-Key",
8890 tokio_tungstenite::tungstenite::handshake::client::generate_key(),
8891 )
8892 .header("Sec-WebSocket-Version", "13")
8893 .body(())
8894 .with_context(|| "Failed to build WebSocket upgrade request")?;
8895
8896 let (ws_stream, response) = tokio_tungstenite::client_async(ws_request, stream)
8897 .await
8898 .with_context(|| format!("WebSocket handshake failed for {ws_url}"))?;
8899
8900 Ok((ws_stream, response))
8901}
8902
8903fn find_header_end(buf: &[u8]) -> Option<usize> {
8905 buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
8906}
8907
8908#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
8918#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8919#[prefix = "storage"]
8920pub struct StorageConfig {
8921 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8923 #[nested]
8924 pub sqlite: HashMap<String, SqliteStorageConfig>,
8925 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8927 #[nested]
8928 pub postgres: HashMap<String, PostgresStorageConfig>,
8929 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8931 #[nested]
8932 pub qdrant: HashMap<String, QdrantStorageConfig>,
8933 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8935 #[nested]
8936 pub markdown: HashMap<String, MarkdownStorageConfig>,
8937 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8939 #[nested]
8940 pub lucid: HashMap<String, LucidStorageConfig>,
8941}
8942
8943#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
8945#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8946#[prefix = "storage_sqlite"]
8947#[serde(default)]
8948pub struct SqliteStorageConfig {
8949 pub path: Option<String>,
8952 pub open_timeout_secs: Option<u64>,
8955}
8956
8957#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8962#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8963#[prefix = "storage_postgres"]
8964#[serde(default)]
8965pub struct PostgresStorageConfig {
8966 #[serde(alias = "dbURL", alias = "database_url", alias = "databaseUrl")]
8969 #[secret]
8970 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
8971 pub db_url: Option<String>,
8972 pub schema: String,
8974 pub table: String,
8976 pub connect_timeout_secs: Option<u64>,
8978 pub vector_enabled: bool,
8980 pub vector_dimensions: usize,
8982}
8983
8984impl Default for PostgresStorageConfig {
8985 fn default() -> Self {
8986 Self {
8987 db_url: None,
8988 schema: default_storage_schema(),
8989 table: default_storage_table(),
8990 connect_timeout_secs: None,
8991 vector_enabled: false,
8992 vector_dimensions: default_pgvector_dimensions(),
8993 }
8994 }
8995}
8996
8997#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9002#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9003#[prefix = "storage_qdrant"]
9004#[serde(default)]
9005pub struct QdrantStorageConfig {
9006 pub url: Option<String>,
9009 pub collection: String,
9012 #[secret]
9015 #[credential_class = "encrypted_secret"]
9016 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
9017 pub api_key: Option<String>,
9018}
9019
9020impl Default for QdrantStorageConfig {
9021 fn default() -> Self {
9022 Self {
9023 url: None,
9024 collection: default_qdrant_collection(),
9025 api_key: None,
9026 }
9027 }
9028}
9029
9030#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9032#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9033#[prefix = "storage_markdown"]
9034#[serde(default)]
9035pub struct MarkdownStorageConfig {
9036 pub directory: Option<String>,
9039}
9040
9041#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9043#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9044#[prefix = "storage_lucid"]
9045#[serde(default)]
9046pub struct LucidStorageConfig {
9047 pub binary_path: Option<String>,
9049}
9050
9051fn default_storage_schema() -> String {
9052 "public".into()
9053}
9054
9055fn default_storage_table() -> String {
9056 "memories".into()
9057}
9058
9059fn default_qdrant_collection() -> String {
9060 "zeroclaw_memories".into()
9061}
9062
9063#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
9065#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9066#[serde(rename_all = "snake_case")]
9067pub enum SearchMode {
9068 Bm25,
9070 Embedding,
9072 #[default]
9074 Hybrid,
9075}
9076
9077#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9084#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9085#[prefix = "memory"]
9086#[allow(clippy::struct_excessive_bools)]
9087pub struct MemoryConfig {
9088 pub backend: String,
9094 #[serde(default = "default_auto_save")]
9096 pub auto_save: bool,
9097 #[serde(default = "default_hygiene_enabled")]
9099 pub hygiene_enabled: bool,
9100 #[serde(default = "default_archive_after_days")]
9102 pub archive_after_days: u32,
9103 #[serde(default = "default_purge_after_days")]
9105 pub purge_after_days: u32,
9106 #[serde(default = "default_conversation_retention_days")]
9108 pub conversation_retention_days: u32,
9109 #[serde(default = "default_embedding_provider")]
9111 pub embedding_provider: String,
9112 #[serde(default = "default_embedding_model")]
9114 pub embedding_model: String,
9115 #[serde(default = "default_embedding_dims")]
9117 pub embedding_dimensions: usize,
9118 #[serde(default = "default_vector_weight")]
9120 pub vector_weight: f64,
9121 #[serde(default = "default_keyword_weight")]
9123 pub keyword_weight: f64,
9124 #[serde(default)]
9126 pub search_mode: SearchMode,
9127 #[serde(default = "default_min_relevance_score")]
9131 pub min_relevance_score: f64,
9132 #[serde(default = "default_cache_size")]
9134 pub embedding_cache_size: usize,
9135 #[serde(default = "default_chunk_size")]
9137 pub chunk_max_tokens: usize,
9138
9139 #[serde(default)]
9142 pub response_cache_enabled: bool,
9143 #[serde(default = "default_response_cache_ttl")]
9145 pub response_cache_ttl_minutes: u32,
9146 #[serde(default = "default_response_cache_max")]
9148 pub response_cache_max_entries: usize,
9149 #[serde(default = "default_response_cache_hot_entries")]
9151 pub response_cache_hot_entries: usize,
9152
9153 #[serde(default)]
9156 pub snapshot_enabled: bool,
9157 #[serde(default)]
9159 pub snapshot_on_hygiene: bool,
9160 #[serde(default = "default_true")]
9162 pub auto_hydrate: bool,
9163
9164 #[serde(default = "default_retrieval_stages")]
9167 pub retrieval_stages: Vec<String>,
9168 #[serde(default)]
9170 pub rerank_enabled: bool,
9171 #[serde(default = "default_rerank_threshold")]
9173 pub rerank_threshold: usize,
9174 #[serde(default = "default_fts_early_return_score")]
9176 pub fts_early_return_score: f64,
9177
9178 #[serde(default = "default_namespace")]
9181 pub default_namespace: String,
9182
9183 #[serde(default = "default_conflict_threshold")]
9186 pub conflict_threshold: f64,
9187
9188 #[serde(default)]
9191 pub audit_enabled: bool,
9192 #[serde(default = "default_audit_retention_days")]
9194 pub audit_retention_days: u32,
9195
9196 #[serde(default)]
9199 #[nested]
9200 pub policy: MemoryPolicyConfig,
9201 }
9206
9207#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9209#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9210#[prefix = "memory.policy"]
9211pub struct MemoryPolicyConfig {
9212 #[serde(default)]
9214 pub max_entries_per_namespace: usize,
9215 #[serde(default)]
9217 pub max_entries_per_category: usize,
9218 #[serde(default)]
9220 pub retention_days_by_category: std::collections::HashMap<String, u32>,
9221 #[serde(default)]
9223 pub read_only_namespaces: Vec<String>,
9224}
9225
9226fn default_retrieval_stages() -> Vec<String> {
9227 vec!["cache".into(), "fts".into(), "vector".into()]
9228}
9229fn default_rerank_threshold() -> usize {
9230 5
9231}
9232fn default_fts_early_return_score() -> f64 {
9233 0.85
9234}
9235fn default_namespace() -> String {
9236 "default".into()
9237}
9238fn default_conflict_threshold() -> f64 {
9239 0.85
9240}
9241fn default_audit_retention_days() -> u32 {
9242 30
9243}
9244
9245fn default_pgvector_dimensions() -> usize {
9246 1536
9247}
9248
9249fn default_embedding_provider() -> String {
9250 "none".into()
9251}
9252fn default_auto_save() -> bool {
9253 true
9254}
9255fn default_hygiene_enabled() -> bool {
9256 true
9257}
9258fn default_archive_after_days() -> u32 {
9259 7
9260}
9261fn default_purge_after_days() -> u32 {
9262 30
9263}
9264fn default_conversation_retention_days() -> u32 {
9265 30
9266}
9267fn default_embedding_model() -> String {
9268 "text-embedding-3-small".into()
9269}
9270fn default_embedding_dims() -> usize {
9271 1536
9272}
9273fn default_vector_weight() -> f64 {
9274 0.7
9275}
9276fn default_keyword_weight() -> f64 {
9277 0.3
9278}
9279fn default_min_relevance_score() -> f64 {
9280 0.4
9281}
9282fn default_cache_size() -> usize {
9283 10_000
9284}
9285fn default_chunk_size() -> usize {
9286 512
9287}
9288fn default_response_cache_ttl() -> u32 {
9289 60
9290}
9291fn default_response_cache_max() -> usize {
9292 5_000
9293}
9294
9295fn default_response_cache_hot_entries() -> usize {
9296 256
9297}
9298
9299impl Default for MemoryConfig {
9300 fn default() -> Self {
9301 Self {
9302 backend: "sqlite".into(),
9303 auto_save: true,
9304 hygiene_enabled: default_hygiene_enabled(),
9305 archive_after_days: default_archive_after_days(),
9306 purge_after_days: default_purge_after_days(),
9307 conversation_retention_days: default_conversation_retention_days(),
9308 embedding_provider: default_embedding_provider(),
9309 embedding_model: default_embedding_model(),
9310 embedding_dimensions: default_embedding_dims(),
9311 vector_weight: default_vector_weight(),
9312 keyword_weight: default_keyword_weight(),
9313 search_mode: SearchMode::default(),
9314 min_relevance_score: default_min_relevance_score(),
9315 embedding_cache_size: default_cache_size(),
9316 chunk_max_tokens: default_chunk_size(),
9317 response_cache_enabled: false,
9318 response_cache_ttl_minutes: default_response_cache_ttl(),
9319 response_cache_max_entries: default_response_cache_max(),
9320 response_cache_hot_entries: default_response_cache_hot_entries(),
9321 snapshot_enabled: false,
9322 snapshot_on_hygiene: false,
9323 auto_hydrate: true,
9324 retrieval_stages: default_retrieval_stages(),
9325 rerank_enabled: false,
9326 rerank_threshold: default_rerank_threshold(),
9327 fts_early_return_score: default_fts_early_return_score(),
9328 default_namespace: default_namespace(),
9329 conflict_threshold: default_conflict_threshold(),
9330 audit_enabled: false,
9331 audit_retention_days: default_audit_retention_days(),
9332 policy: MemoryPolicyConfig::default(),
9333 }
9334 }
9335}
9336
9337#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9341#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9342#[prefix = "observability"]
9343pub struct ObservabilityConfig {
9344 pub backend: String,
9346
9347 #[serde(default)]
9349 pub otel_endpoint: Option<String>,
9350
9351 #[serde(default)]
9353 pub otel_service_name: Option<String>,
9354
9355 #[serde(default)]
9362 #[secret]
9363 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
9364 pub otel_headers: Option<std::collections::HashMap<String, String>>,
9365
9366 #[serde(default = "default_log_persistence", alias = "runtime_trace_mode")]
9370 pub log_persistence: String,
9371
9372 #[serde(default = "default_log_persistence_path", alias = "runtime_trace_path")]
9374 pub log_persistence_path: String,
9375
9376 #[serde(
9378 default = "default_log_persistence_max_entries",
9379 alias = "runtime_trace_max_entries"
9380 )]
9381 pub log_persistence_max_entries: usize,
9382
9383 #[serde(default = "default_log_tool_io")]
9390 pub log_tool_io: String,
9391
9392 #[serde(default = "default_log_tool_io_truncate_bytes")]
9396 pub log_tool_io_truncate_bytes: usize,
9397
9398 #[serde(default)]
9403 pub log_tool_io_denylist: Vec<String>,
9404}
9405
9406impl Default for ObservabilityConfig {
9407 fn default() -> Self {
9408 Self {
9409 backend: "none".into(),
9410 otel_endpoint: None,
9411 otel_service_name: None,
9412 otel_headers: None,
9413 log_persistence: default_log_persistence(),
9414 log_persistence_path: default_log_persistence_path(),
9415 log_persistence_max_entries: default_log_persistence_max_entries(),
9416 log_tool_io: default_log_tool_io(),
9417 log_tool_io_truncate_bytes: default_log_tool_io_truncate_bytes(),
9418 log_tool_io_denylist: Vec::new(),
9419 }
9420 }
9421}
9422
9423fn default_log_persistence() -> String {
9424 "rolling".to_string()
9425}
9426
9427fn default_log_persistence_path() -> String {
9428 "state/runtime-trace.jsonl".to_string()
9429}
9430
9431fn default_log_persistence_max_entries() -> usize {
9432 200
9433}
9434
9435fn default_log_tool_io() -> String {
9436 "redacted".to_string()
9437}
9438
9439fn default_log_tool_io_truncate_bytes() -> usize {
9440 40960
9441}
9442
9443#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9446#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9447#[prefix = "hooks"]
9448pub struct HooksConfig {
9449 pub enabled: bool,
9454 #[serde(default)]
9455 #[nested]
9456 pub builtin: BuiltinHooksConfig,
9457}
9458
9459impl Default for HooksConfig {
9460 fn default() -> Self {
9461 Self {
9462 enabled: true,
9463 builtin: BuiltinHooksConfig::default(),
9464 }
9465 }
9466}
9467
9468#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
9469#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9470#[prefix = "hooks.builtin"]
9471pub struct BuiltinHooksConfig {
9472 pub command_logger: bool,
9474 #[serde(default)]
9479 #[nested]
9480 pub webhook_audit: WebhookAuditConfig,
9481}
9482
9483#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9489#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9490#[prefix = "hooks.builtin.webhook_audit"]
9491pub struct WebhookAuditConfig {
9492 #[serde(default)]
9494 pub enabled: bool,
9495 #[serde(default)]
9497 pub url: String,
9498 #[serde(default)]
9501 pub tool_patterns: Vec<String>,
9502 #[serde(default)]
9506 pub include_args: bool,
9507 #[serde(default = "default_max_args_bytes")]
9511 pub max_args_bytes: u64,
9512}
9513
9514fn default_max_args_bytes() -> u64 {
9515 4096
9516}
9517
9518impl Default for WebhookAuditConfig {
9519 fn default() -> Self {
9520 Self {
9521 enabled: false,
9522 url: String::new(),
9523 tool_patterns: Vec::new(),
9524 include_args: false,
9525 max_args_bytes: default_max_args_bytes(),
9526 }
9527 }
9528}
9529
9530fn default_auto_approve() -> Vec<String> {
9539 vec![
9540 "file_read".into(),
9541 "memory_recall".into(),
9542 "web_search_tool".into(),
9543 "web_fetch".into(),
9544 "calculator".into(),
9545 "glob_search".into(),
9546 "content_search".into(),
9547 "image_info".into(),
9548 "weather".into(),
9549 "tool_search".into(),
9550 "browser".into(),
9551 "browser_open".into(),
9552 ]
9553}
9554
9555fn default_always_ask() -> Vec<String> {
9556 vec![]
9557}
9558
9559impl RiskProfileConfig {
9560 pub fn ensure_default_auto_approve(&mut self) {
9563 let defaults = default_auto_approve();
9564 for entry in defaults {
9565 if !self.auto_approve.iter().any(|existing| existing == &entry) {
9566 self.auto_approve.push(entry);
9567 }
9568 }
9569 }
9570
9571 #[must_use]
9576 pub fn sandbox_config(&self) -> SandboxConfig {
9577 let backend = self
9578 .sandbox_backend
9579 .as_deref()
9580 .map(str::trim)
9581 .filter(|s| !s.is_empty())
9582 .map(parse_sandbox_backend)
9583 .unwrap_or_default();
9584 SandboxConfig {
9585 enabled: self.sandbox_enabled,
9586 backend,
9587 firejail_args: self.firejail_args.clone(),
9588 }
9589 }
9590}
9591
9592fn parse_sandbox_backend(name: &str) -> SandboxBackend {
9593 match name.to_ascii_lowercase().as_str() {
9594 "auto" => SandboxBackend::Auto,
9595 "landlock" => SandboxBackend::Landlock,
9596 "firejail" => SandboxBackend::Firejail,
9597 "bubblewrap" => SandboxBackend::Bubblewrap,
9598 "docker" => SandboxBackend::Docker,
9599 "sandbox-exec" | "sandboxexec" | "seatbelt" => SandboxBackend::SandboxExec,
9600 "none" => SandboxBackend::None,
9601 _ => SandboxBackend::default(),
9602 }
9603}
9604
9605fn is_valid_env_var_name(name: &str) -> bool {
9606 let mut chars = name.chars();
9607 match chars.next() {
9608 Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
9609 _ => return false,
9610 }
9611 chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
9612}
9613
9614#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9626#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9627#[prefix = "risk_profile"]
9628#[serde(default)]
9629pub struct RiskProfileConfig {
9630 pub level: AutonomyLevel,
9632 pub workspace_only: bool,
9634 pub allowed_commands: Vec<String>,
9636 pub forbidden_paths: Vec<String>,
9638 pub require_approval_for_medium_risk: bool,
9640 pub block_high_risk_commands: bool,
9642 #[credential_class = "legacy_env_path"]
9644 pub shell_env_passthrough: Vec<String>,
9645 pub auto_approve: Vec<String>,
9647 pub always_ask: Vec<String>,
9649 #[serde(alias = "allowed_path", alias = "allowed_paths")]
9651 pub allowed_roots: Vec<String>,
9652 #[serde(default)]
9656 #[nested]
9657 pub delegation_policy: DelegationPolicy,
9658 pub allowed_tools: Vec<String>,
9663 pub excluded_tools: Vec<String>,
9665 pub sandbox_enabled: Option<bool>,
9668 pub sandbox_backend: Option<String>,
9670 pub firejail_args: Vec<String>,
9672}
9673
9674impl Default for RiskProfileConfig {
9675 fn default() -> Self {
9676 Self {
9677 level: AutonomyLevel::Supervised,
9678 workspace_only: true,
9679 allowed_commands: crate::policy::default_allowed_commands(),
9680 forbidden_paths: crate::policy::default_forbidden_paths(),
9681 require_approval_for_medium_risk: true,
9682 block_high_risk_commands: true,
9683 shell_env_passthrough: vec![],
9684 auto_approve: default_auto_approve(),
9685 always_ask: default_always_ask(),
9686 allowed_roots: Vec::new(),
9687 delegation_policy: DelegationPolicy::default(),
9688 allowed_tools: Vec::new(),
9689 excluded_tools: Vec::new(),
9690 sandbox_enabled: None,
9691 sandbox_backend: None,
9692 firejail_args: Vec::new(),
9693 }
9694 }
9695}
9696
9697#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9708#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9709#[prefix = "runtime_profile"]
9710#[serde(default)]
9711pub struct RuntimeProfileConfig {
9712 pub agentic: bool,
9714 pub max_tool_iterations: usize,
9716 pub max_actions_per_hour: u32,
9724 pub max_cost_per_day_cents: u32,
9727 pub shell_timeout_secs: u64,
9730 pub max_delegation_depth: u32,
9733 pub delegation_timeout_secs: Option<u64>,
9735 pub agentic_timeout_secs: Option<u64>,
9737 pub max_history_messages: Option<usize>,
9740 pub max_context_tokens: Option<usize>,
9742 pub compact_context: Option<bool>,
9744 pub parallel_tools: Option<bool>,
9746 pub tool_dispatcher: Option<String>,
9748 pub tool_call_dedup_exempt: Vec<String>,
9750 pub max_system_prompt_chars: Option<usize>,
9752 pub context_aware_tools: Option<bool>,
9754 pub max_tool_result_chars: Option<usize>,
9756 pub keep_tool_context_turns: Option<usize>,
9758 pub memory_recall_limit: Option<usize>,
9761 pub strict_tool_parsing: bool,
9762 #[nested]
9763 pub thinking: crate::scattered_types::ThinkingConfig,
9764 #[nested]
9765 pub history_pruning: crate::scattered_types::HistoryPrunerConfig,
9766 #[nested]
9767 pub eval: crate::scattered_types::EvalConfig,
9768 #[nested]
9769 pub auto_classify: Option<crate::scattered_types::AutoClassifyConfig>,
9770 #[nested]
9771 pub context_compression: crate::scattered_types::ContextCompressionConfig,
9772 #[nested]
9773 pub tool_receipts: ToolReceiptsConfig,
9774 pub tool_filter_groups: Vec<ToolFilterGroup>,
9775}
9776
9777impl Default for RuntimeProfileConfig {
9778 fn default() -> Self {
9779 Self {
9780 agentic: false,
9781 max_tool_iterations: 0,
9782 max_actions_per_hour: 20,
9783 max_cost_per_day_cents: 500,
9784 shell_timeout_secs: 60,
9785 max_delegation_depth: 0,
9786 delegation_timeout_secs: None,
9787 agentic_timeout_secs: None,
9788 max_history_messages: None,
9789 max_context_tokens: None,
9790 compact_context: None,
9791 parallel_tools: None,
9792 tool_dispatcher: None,
9793 tool_call_dedup_exempt: Vec::new(),
9794 max_system_prompt_chars: None,
9795 context_aware_tools: None,
9796 max_tool_result_chars: None,
9797 keep_tool_context_turns: None,
9798 memory_recall_limit: None,
9799 strict_tool_parsing: false,
9800 thinking: crate::scattered_types::ThinkingConfig::default(),
9801 history_pruning: crate::scattered_types::HistoryPrunerConfig::default(),
9802 eval: crate::scattered_types::EvalConfig::default(),
9803 auto_classify: None,
9804 context_compression: crate::scattered_types::ContextCompressionConfig::default(),
9805 tool_receipts: ToolReceiptsConfig::default(),
9806 tool_filter_groups: Vec::new(),
9807 }
9808 }
9809}
9810
9811#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9816#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9817#[prefix = "skill_bundle"]
9818#[serde(default)]
9819pub struct SkillBundleConfig {
9820 pub directory: Option<String>,
9822 pub include: Vec<String>,
9824 pub exclude: Vec<String>,
9826}
9827
9828#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9833#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9834#[prefix = "knowledge_bundle"]
9835#[serde(default)]
9836pub struct KnowledgeBundleConfig {
9837 pub sources: Vec<String>,
9839 pub tags: Vec<String>,
9841}
9842
9843#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9847#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9848#[prefix = "mcp_bundle"]
9849#[serde(default)]
9850pub struct McpBundleConfig {
9851 pub servers: Vec<String>,
9853 pub exclude: Vec<String>,
9855}
9856
9857#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9861#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9862#[prefix = "runtime"]
9863pub struct RuntimeConfig {
9864 #[serde(default = "default_runtime_kind")]
9866 pub kind: String,
9867
9868 #[serde(default)]
9870 #[nested]
9871 pub docker: DockerRuntimeConfig,
9872
9873 #[serde(default)]
9878 pub reasoning_enabled: Option<bool>,
9879 #[serde(default, deserialize_with = "deserialize_reasoning_effort_opt")]
9881 pub reasoning_effort: Option<String>,
9882}
9883
9884#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9886#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9887#[prefix = "runtime.docker"]
9888pub struct DockerRuntimeConfig {
9889 #[serde(default = "default_docker_image")]
9891 pub image: String,
9892
9893 #[serde(default = "default_docker_network")]
9895 pub network: String,
9896
9897 #[serde(default = "default_docker_memory_limit_mb")]
9899 pub memory_limit_mb: Option<u64>,
9900
9901 #[serde(default = "default_docker_cpu_limit")]
9903 pub cpu_limit: Option<f64>,
9904
9905 #[serde(default = "default_true")]
9907 pub read_only_rootfs: bool,
9908
9909 #[serde(default = "default_true")]
9911 pub mount_workspace: bool,
9912
9913 #[serde(default)]
9915 pub allowed_workspace_roots: Vec<String>,
9916}
9917
9918fn default_runtime_kind() -> String {
9919 "native".into()
9920}
9921
9922fn default_docker_image() -> String {
9923 "alpine:3.20".into()
9924}
9925
9926fn default_docker_network() -> String {
9927 "none".into()
9928}
9929
9930fn default_docker_memory_limit_mb() -> Option<u64> {
9931 Some(512)
9932}
9933
9934fn default_docker_cpu_limit() -> Option<f64> {
9935 Some(1.0)
9936}
9937
9938impl Default for DockerRuntimeConfig {
9939 fn default() -> Self {
9940 Self {
9941 image: default_docker_image(),
9942 network: default_docker_network(),
9943 memory_limit_mb: default_docker_memory_limit_mb(),
9944 cpu_limit: default_docker_cpu_limit(),
9945 read_only_rootfs: true,
9946 mount_workspace: true,
9947 allowed_workspace_roots: Vec::new(),
9948 }
9949 }
9950}
9951
9952impl Default for RuntimeConfig {
9953 fn default() -> Self {
9954 Self {
9955 kind: default_runtime_kind(),
9956 docker: DockerRuntimeConfig::default(),
9957 reasoning_enabled: None,
9958 reasoning_effort: None,
9959 }
9960 }
9961}
9962
9963#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9969#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9970#[prefix = "reliability"]
9971pub struct ReliabilityConfig {
9972 #[serde(default = "default_provider_retries")]
9974 pub provider_retries: u32,
9975 #[serde(default = "default_provider_backoff_ms")]
9977 pub provider_backoff_ms: u64,
9978 #[serde(default)]
9981 #[secret]
9982 #[credential_class = "encrypted_secret"]
9983 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
9984 pub api_keys: Vec<String>,
9985 #[serde(default = "default_channel_backoff_secs")]
9987 pub channel_initial_backoff_secs: u64,
9988 #[serde(default = "default_channel_backoff_max_secs")]
9990 pub channel_max_backoff_secs: u64,
9991 #[serde(default = "default_scheduler_poll_secs")]
9993 pub scheduler_poll_secs: u64,
9994 #[serde(default = "default_scheduler_retries")]
9996 pub scheduler_retries: u32,
9997}
9998
9999fn default_provider_retries() -> u32 {
10000 2
10001}
10002
10003fn default_provider_backoff_ms() -> u64 {
10004 500
10005}
10006
10007fn default_channel_backoff_secs() -> u64 {
10008 2
10009}
10010
10011fn default_channel_backoff_max_secs() -> u64 {
10012 60
10013}
10014
10015fn default_scheduler_poll_secs() -> u64 {
10016 15
10017}
10018
10019fn default_scheduler_retries() -> u32 {
10020 2
10021}
10022
10023impl Default for ReliabilityConfig {
10024 fn default() -> Self {
10025 Self {
10026 provider_retries: default_provider_retries(),
10027 provider_backoff_ms: default_provider_backoff_ms(),
10028 api_keys: Vec::new(),
10029 channel_initial_backoff_secs: default_channel_backoff_secs(),
10030 channel_max_backoff_secs: default_channel_backoff_max_secs(),
10031 scheduler_poll_secs: default_scheduler_poll_secs(),
10032 scheduler_retries: default_scheduler_retries(),
10033 }
10034 }
10035}
10036
10037#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10045#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10046#[prefix = "scheduler"]
10047pub struct SchedulerConfig {
10048 #[serde(default = "default_scheduler_enabled")]
10050 pub enabled: bool,
10051 #[serde(default = "default_scheduler_max_tasks")]
10053 pub max_tasks: usize,
10054 #[serde(default = "default_scheduler_max_concurrent")]
10056 pub max_concurrent: usize,
10057 #[serde(default = "default_true")]
10063 pub catch_up_on_startup: bool,
10064 #[serde(default = "default_max_run_history")]
10066 pub max_run_history: u32,
10067}
10068
10069fn default_scheduler_enabled() -> bool {
10070 true
10071}
10072
10073fn default_scheduler_max_tasks() -> usize {
10074 64
10075}
10076
10077fn default_scheduler_max_concurrent() -> usize {
10078 4
10079}
10080
10081impl Default for SchedulerConfig {
10082 fn default() -> Self {
10083 Self {
10084 enabled: default_scheduler_enabled(),
10085 max_tasks: default_scheduler_max_tasks(),
10086 max_concurrent: default_scheduler_max_concurrent(),
10087 catch_up_on_startup: true,
10088 max_run_history: default_max_run_history(),
10089 }
10090 }
10091}
10092
10093#[derive(Debug, Clone, Serialize, Deserialize)]
10111#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10112pub struct ModelRouteConfig {
10113 pub hint: String,
10115 pub model_provider: String,
10117 pub model: String,
10119 #[serde(default)]
10121 pub api_key: Option<String>,
10122}
10123
10124#[derive(Debug, Clone, Serialize, Deserialize)]
10139#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10140pub struct EmbeddingRouteConfig {
10141 pub hint: String,
10143 pub model_provider: String,
10145 pub model: String,
10147 #[serde(default)]
10149 pub dimensions: Option<usize>,
10150 #[serde(default)]
10152 pub api_key: Option<String>,
10153}
10154
10155#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
10160#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10161#[prefix = "query_classification"]
10162pub struct QueryClassificationConfig {
10163 #[serde(default)]
10165 pub enabled: bool,
10166 #[serde(default)]
10168 pub rules: Vec<ClassificationRule>,
10169}
10170
10171#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10173#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10174pub struct ClassificationRule {
10175 pub hint: String,
10177 #[serde(default)]
10179 pub keywords: Vec<String>,
10180 #[serde(default)]
10182 pub patterns: Vec<String>,
10183 #[serde(default)]
10185 pub min_length: Option<usize>,
10186 #[serde(default)]
10188 pub max_length: Option<usize>,
10189 #[serde(default)]
10191 pub priority: i32,
10192}
10193
10194#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10198#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10199#[prefix = "heartbeat"]
10200#[allow(clippy::struct_excessive_bools)]
10201pub struct HeartbeatConfig {
10202 #[serde(default)]
10206 pub enabled: bool,
10207 #[serde(default)]
10210 pub agent: String,
10211 #[serde(default = "default_heartbeat_interval")]
10213 pub interval_minutes: u32,
10214 #[serde(default = "default_two_phase")]
10218 pub two_phase: bool,
10219 #[serde(default)]
10221 pub message: Option<String>,
10222 #[serde(default, alias = "channel")]
10225 pub target: Option<String>,
10226 #[serde(default, alias = "recipient")]
10229 pub to: Option<String>,
10230 #[serde(default)]
10233 pub adaptive: bool,
10234 #[serde(default = "default_heartbeat_min_interval")]
10236 pub min_interval_minutes: u32,
10237 #[serde(default = "default_heartbeat_max_interval")]
10239 pub max_interval_minutes: u32,
10240 #[serde(default)]
10243 pub deadman_timeout_minutes: u32,
10244 #[serde(default)]
10247 pub deadman_channel: Option<String>,
10248 #[serde(default)]
10250 pub deadman_to: Option<String>,
10251 #[serde(default = "default_heartbeat_max_run_history")]
10253 pub max_run_history: u32,
10254 #[serde(default)]
10261 pub load_session_context: bool,
10262 #[serde(default = "default_heartbeat_task_timeout")]
10266 pub task_timeout_secs: u64,
10267}
10268
10269fn default_heartbeat_interval() -> u32 {
10270 30
10271}
10272
10273fn default_two_phase() -> bool {
10274 true
10275}
10276
10277fn default_heartbeat_min_interval() -> u32 {
10278 5
10279}
10280
10281fn default_heartbeat_max_interval() -> u32 {
10282 120
10283}
10284
10285fn default_heartbeat_max_run_history() -> u32 {
10286 100
10287}
10288
10289fn default_heartbeat_task_timeout() -> u64 {
10290 600
10291}
10292
10293impl Default for HeartbeatConfig {
10294 fn default() -> Self {
10295 Self {
10296 enabled: false,
10297 agent: String::new(),
10298 interval_minutes: default_heartbeat_interval(),
10299 two_phase: true,
10300 message: None,
10301 target: None,
10302 to: None,
10303 adaptive: false,
10304 min_interval_minutes: default_heartbeat_min_interval(),
10305 max_interval_minutes: default_heartbeat_max_interval(),
10306 deadman_timeout_minutes: 0,
10307 deadman_channel: None,
10308 deadman_to: None,
10309 max_run_history: default_heartbeat_max_run_history(),
10310 load_session_context: false,
10311 task_timeout_secs: default_heartbeat_task_timeout(),
10312 }
10313 }
10314}
10315
10316#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10326#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10327#[prefix = "cron"]
10328pub struct CronJobDecl {
10329 #[serde(default)]
10331 pub name: Option<String>,
10332 #[serde(default = "default_job_type_decl")]
10334 pub job_type: String,
10335 #[serde(default)]
10337 pub schedule: CronScheduleDecl,
10338 #[serde(default)]
10340 pub command: Option<String>,
10341 #[serde(default)]
10343 pub prompt: Option<String>,
10344 #[serde(default = "default_true")]
10346 pub enabled: bool,
10347 #[serde(default)]
10349 pub model: Option<String>,
10350 #[serde(default)]
10353 pub allowed_tools: Option<Vec<String>>,
10354 #[serde(default = "default_true")]
10357 pub uses_memory: bool,
10358 #[serde(default)]
10360 pub session_target: Option<String>,
10361 #[serde(default)]
10363 #[nested]
10364 pub delivery: Option<DeliveryConfigDecl>,
10365}
10366
10367impl Default for CronJobDecl {
10368 fn default() -> Self {
10369 Self {
10370 name: None,
10371 job_type: default_job_type_decl(),
10372 schedule: CronScheduleDecl::default(),
10373 command: None,
10374 prompt: None,
10375 enabled: true,
10376 model: None,
10377 allowed_tools: None,
10378 uses_memory: true,
10379 session_target: None,
10380 delivery: None,
10381 }
10382 }
10383}
10384
10385#[derive(Debug, Clone, Serialize, Deserialize)]
10387#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10388#[serde(tag = "kind", rename_all = "lowercase")]
10389pub enum CronScheduleDecl {
10390 Cron {
10392 expr: String,
10393 #[serde(default)]
10394 tz: Option<String>,
10395 },
10396 Every { every_ms: u64 },
10398 At { at: String },
10400}
10401
10402impl Default for CronScheduleDecl {
10403 fn default() -> Self {
10404 Self::Cron {
10408 expr: String::new(),
10409 tz: None,
10410 }
10411 }
10412}
10413
10414#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10416#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10417#[prefix = "cron_delivery"]
10418pub struct DeliveryConfigDecl {
10419 #[serde(default = "default_delivery_mode")]
10421 pub mode: String,
10422 #[serde(default)]
10424 pub channel: Option<String>,
10425 #[serde(default)]
10427 pub to: Option<String>,
10428 #[serde(default, skip_serializing_if = "Option::is_none")]
10432 pub thread_id: Option<String>,
10433 #[serde(default = "default_true")]
10435 pub best_effort: bool,
10436}
10437
10438impl Default for DeliveryConfigDecl {
10439 fn default() -> Self {
10440 Self {
10441 mode: default_delivery_mode(),
10442 channel: None,
10443 to: None,
10444 thread_id: None,
10445 best_effort: true,
10446 }
10447 }
10448}
10449
10450fn default_job_type_decl() -> String {
10451 "shell".to_string()
10452}
10453
10454fn default_delivery_mode() -> String {
10455 "none".to_string()
10456}
10457
10458fn default_max_run_history() -> u32 {
10459 50
10460}
10461
10462#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10466#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10467#[prefix = "acp"]
10468pub struct AcpConfig {
10469 #[serde(default, skip_serializing_if = "Option::is_none")]
10473 pub default_agent: Option<String>,
10474 #[serde(default = "default_acp_max_sessions")]
10476 pub max_sessions: usize,
10477 #[serde(default = "default_acp_session_timeout_secs")]
10480 pub session_timeout_secs: u64,
10481}
10482
10483fn default_acp_max_sessions() -> usize {
10484 10
10485}
10486
10487fn default_acp_session_timeout_secs() -> u64 {
10488 3600
10489}
10490
10491impl Default for AcpConfig {
10492 fn default() -> Self {
10493 Self {
10494 default_agent: None,
10495 max_sessions: default_acp_max_sessions(),
10496 session_timeout_secs: default_acp_session_timeout_secs(),
10497 }
10498 }
10499}
10500
10501#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10507#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10508#[prefix = "tunnel"]
10509pub struct TunnelConfig {
10510 pub tunnel_provider: String,
10512
10513 #[serde(default)]
10515 #[nested]
10516 pub cloudflare: Option<CloudflareTunnelConfig>,
10517
10518 #[serde(default)]
10520 #[nested]
10521 pub tailscale: Option<TailscaleTunnelConfig>,
10522
10523 #[serde(default)]
10525 #[nested]
10526 pub ngrok: Option<NgrokTunnelConfig>,
10527
10528 #[serde(default)]
10530 #[nested]
10531 pub openvpn: Option<OpenVpnTunnelConfig>,
10532
10533 #[serde(default)]
10535 #[nested]
10536 pub custom: Option<CustomTunnelConfig>,
10537
10538 #[serde(default)]
10540 #[nested]
10541 pub pinggy: Option<PinggyTunnelConfig>,
10542}
10543
10544impl Default for TunnelConfig {
10545 fn default() -> Self {
10546 Self {
10547 tunnel_provider: "none".into(),
10548 cloudflare: None,
10549 tailscale: None,
10550 ngrok: None,
10551 openvpn: None,
10552 custom: None,
10553 pinggy: None,
10554 }
10555 }
10556}
10557
10558#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10559#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10560#[prefix = "tunnel.cloudflare"]
10561pub struct CloudflareTunnelConfig {
10562 #[serde(default)]
10564 #[secret]
10565 #[credential_class = "encrypted_secret"]
10566 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10567 pub token: String,
10568}
10569
10570#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10571#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10572#[prefix = "tunnel.tailscale"]
10573pub struct TailscaleTunnelConfig {
10574 #[serde(default)]
10576 pub funnel: bool,
10577 #[serde(default)]
10579 pub hostname: Option<String>,
10580}
10581
10582#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10583#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10584#[prefix = "tunnel.ngrok"]
10585pub struct NgrokTunnelConfig {
10586 #[serde(default)]
10588 #[secret]
10589 #[credential_class = "encrypted_secret"]
10590 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10591 pub auth_token: String,
10592 #[serde(default)]
10594 pub domain: Option<String>,
10595}
10596
10597#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10605#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10606#[prefix = "tunnel.openvpn"]
10607pub struct OpenVpnTunnelConfig {
10608 pub config_file: String,
10610 #[serde(default)]
10612 #[credential_class = "path_only_reference"]
10613 pub auth_file: Option<String>,
10614 #[serde(default)]
10617 pub advertise_address: Option<String>,
10618 #[serde(default = "default_openvpn_timeout")]
10620 pub connect_timeout_secs: u64,
10621 #[serde(default)]
10623 pub extra_args: Vec<String>,
10624}
10625
10626fn default_openvpn_timeout() -> u64 {
10627 30
10628}
10629
10630impl Default for OpenVpnTunnelConfig {
10631 fn default() -> Self {
10632 Self {
10633 config_file: String::new(),
10634 auth_file: None,
10635 advertise_address: None,
10636 connect_timeout_secs: default_openvpn_timeout(),
10637 extra_args: Vec::new(),
10638 }
10639 }
10640}
10641
10642#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10643#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10644#[prefix = "tunnel.pinggy"]
10645pub struct PinggyTunnelConfig {
10646 #[serde(default)]
10648 #[secret]
10649 #[credential_class = "encrypted_secret"]
10650 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10651 pub token: Option<String>,
10652 #[serde(default)]
10654 pub region: Option<String>,
10655}
10656
10657#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10658#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10659#[prefix = "tunnel.custom"]
10660pub struct CustomTunnelConfig {
10661 #[serde(default)]
10664 pub start_command: String,
10665 #[serde(default)]
10667 pub health_url: Option<String>,
10668 #[serde(default)]
10670 pub url_pattern: Option<String>,
10671}
10672
10673#[allow(clippy::struct_excessive_bools)]
10681#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10682#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10683#[prefix = "channels"]
10684pub struct ChannelsConfig {
10685 #[serde(default = "default_true")]
10687 pub cli: bool,
10688 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10690 #[nested]
10691 pub telegram: HashMap<String, TelegramConfig>,
10692 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10694 #[nested]
10695 pub discord: HashMap<String, DiscordConfig>,
10696 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10698 #[nested]
10699 pub slack: HashMap<String, SlackConfig>,
10700 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10702 #[nested]
10703 pub mattermost: HashMap<String, MattermostConfig>,
10704 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10706 #[nested]
10707 pub webhook: HashMap<String, WebhookConfig>,
10708 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10710 #[nested]
10711 pub imessage: HashMap<String, IMessageConfig>,
10712 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10714 #[nested]
10715 pub matrix: HashMap<String, MatrixConfig>,
10716 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10718 #[nested]
10719 pub signal: HashMap<String, SignalConfig>,
10720 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10722 #[nested]
10723 pub whatsapp: HashMap<String, WhatsAppConfig>,
10724 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10726 #[nested]
10727 pub linq: HashMap<String, LinqConfig>,
10728 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10730 #[nested]
10731 pub wati: HashMap<String, WatiConfig>,
10732 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10734 #[nested]
10735 pub nextcloud_talk: HashMap<String, NextcloudTalkConfig>,
10736 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10738 #[nested]
10739 pub email: HashMap<String, crate::scattered_types::EmailConfig>,
10740 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10742 #[nested]
10743 pub gmail_push: HashMap<String, crate::scattered_types::GmailPushConfig>,
10744 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10746 #[nested]
10747 pub irc: HashMap<String, IrcConfig>,
10748 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10750 #[nested]
10751 pub twitch: HashMap<String, TwitchConfig>,
10752 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10754 #[nested]
10755 pub lark: HashMap<String, LarkConfig>,
10756 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10758 #[nested]
10759 pub line: HashMap<String, LineConfig>,
10760 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10762 #[nested]
10763 pub dingtalk: HashMap<String, DingTalkConfig>,
10764 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10766 #[nested]
10767 pub wecom: HashMap<String, WeComConfig>,
10768 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10770 #[nested]
10771 pub wecom_ws: HashMap<String, WeComWsConfig>,
10772 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10774 #[nested]
10775 pub wechat: HashMap<String, WeChatConfig>,
10776 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10778 #[nested]
10779 pub qq: HashMap<String, QQConfig>,
10780 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10782 #[nested]
10783 pub twitter: HashMap<String, TwitterConfig>,
10784 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10786 #[nested]
10787 pub mochat: HashMap<String, MochatConfig>,
10788 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10789 #[nested]
10790 pub nostr: HashMap<String, NostrConfig>,
10791 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10793 #[nested]
10794 pub clawdtalk: HashMap<String, crate::scattered_types::ClawdTalkConfig>,
10795 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10797 #[nested]
10798 pub reddit: HashMap<String, RedditConfig>,
10799 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10801 #[nested]
10802 pub bluesky: HashMap<String, BlueskyConfig>,
10803 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10805 #[nested]
10806 pub voice_call: HashMap<String, crate::scattered_types::VoiceCallConfig>,
10807 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10809 #[nested]
10810 pub voice_wake: HashMap<String, VoiceWakeConfig>,
10811 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10813 #[nested]
10814 pub voice_duplex: HashMap<String, VoiceDuplexConfig>,
10815 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10817 #[nested]
10818 pub mqtt: HashMap<String, MqttConfig>,
10819 #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10821 #[nested]
10822 pub amqp: HashMap<String, AmqpConfig>,
10823 #[serde(default = "default_channel_message_timeout_secs")]
10829 pub message_timeout_secs: u64,
10830 #[serde(default = "default_channel_max_concurrent_per_channel")]
10835 pub max_concurrent_per_channel: usize,
10836 #[serde(default = "default_true")]
10839 pub ack_reactions: bool,
10840 #[serde(default = "default_false")]
10844 pub show_tool_calls: bool,
10845 #[serde(default = "default_true")]
10848 pub session_persistence: bool,
10849 #[serde(default = "default_session_backend")]
10852 pub session_backend: String,
10853 #[serde(default)]
10855 pub session_ttl_hours: u32,
10856 #[serde(default)]
10860 pub debounce_ms: u64,
10861}
10862
10863impl ChannelsConfig {
10864 pub fn channels(&self) -> Vec<super::traits::ChannelInfo> {
10872 use super::traits::ChannelInfo;
10873 vec![
10874 ChannelInfo {
10875 kind: "telegram",
10876 name: "Telegram",
10877 desc: "connect your bot",
10878 configured: !self.telegram.is_empty(),
10879 },
10880 ChannelInfo {
10881 kind: "discord",
10882 name: "Discord",
10883 desc: "connect your bot",
10884 configured: !self.discord.is_empty(),
10885 },
10886 ChannelInfo {
10887 kind: "slack",
10888 name: "Slack",
10889 desc: "connect your bot",
10890 configured: !self.slack.is_empty(),
10891 },
10892 ChannelInfo {
10893 kind: "mattermost",
10894 name: "Mattermost",
10895 desc: "connect to your bot",
10896 configured: !self.mattermost.is_empty(),
10897 },
10898 ChannelInfo {
10899 kind: "imessage",
10900 name: "iMessage",
10901 desc: "macOS only",
10902 configured: !self.imessage.is_empty(),
10903 },
10904 ChannelInfo {
10905 kind: "matrix",
10906 name: "Matrix",
10907 desc: "self-hosted chat",
10908 configured: !self.matrix.is_empty(),
10909 },
10910 ChannelInfo {
10911 kind: "signal",
10912 name: "Signal",
10913 desc: "An open-source, encrypted messaging service",
10914 configured: !self.signal.is_empty(),
10915 },
10916 ChannelInfo {
10917 kind: "whatsapp",
10918 name: "WhatsApp",
10919 desc: "Business Cloud API",
10920 configured: !self.whatsapp.is_empty(),
10921 },
10922 ChannelInfo {
10923 kind: "whatsapp-web",
10924 name: "WhatsApp Web",
10925 desc: "native WhatsApp Web (wa-rs)",
10926 configured: self.whatsapp.values().any(|c| c.is_web_config()),
10927 },
10928 ChannelInfo {
10929 kind: "linq",
10930 name: "Linq",
10931 desc: "iMessage/RCS/SMS via Linq API",
10932 configured: !self.linq.is_empty(),
10933 },
10934 ChannelInfo {
10935 kind: "wati",
10936 name: "WATI",
10937 desc: "WhatsApp via WATI Business API",
10938 configured: !self.wati.is_empty(),
10939 },
10940 ChannelInfo {
10941 kind: "nextcloud",
10942 name: "NextCloud Talk",
10943 desc: "NextCloud Talk platform",
10944 configured: !self.nextcloud_talk.is_empty(),
10945 },
10946 ChannelInfo {
10947 kind: "email",
10948 name: "Email",
10949 desc: "Email over IMAP/SMTP",
10950 configured: !self.email.is_empty(),
10951 },
10952 ChannelInfo {
10953 kind: "gmail-push",
10954 name: "Gmail Push",
10955 desc: "Gmail Pub/Sub push notifications",
10956 configured: !self.gmail_push.is_empty(),
10957 },
10958 ChannelInfo {
10959 kind: "twitch",
10960 name: "Twitch",
10961 desc: "Twitch chat (IRC)",
10962 configured: !self.twitch.is_empty(),
10963 },
10964 ChannelInfo {
10965 kind: "irc",
10966 name: "IRC",
10967 desc: "IRC over TLS",
10968 configured: !self.irc.is_empty(),
10969 },
10970 ChannelInfo {
10971 kind: "lark",
10972 name: "Lark",
10973 desc: "Lark Bot",
10974 configured: !self.lark.is_empty(),
10975 },
10976 ChannelInfo {
10977 kind: "dingtalk",
10978 name: "DingTalk",
10979 desc: "DingTalk Stream Mode",
10980 configured: !self.dingtalk.is_empty(),
10981 },
10982 ChannelInfo {
10983 kind: "wecom",
10984 name: "WeCom",
10985 desc: "WeCom Bot Webhook",
10986 configured: !self.wecom.is_empty(),
10987 },
10988 ChannelInfo {
10989 kind: "wecom-ws",
10990 name: "WeCom WebSocket",
10991 desc: "WeCom AI Bot long connection",
10992 configured: !self.wecom_ws.is_empty(),
10993 },
10994 ChannelInfo {
10995 kind: "wechat",
10996 name: "WeChat",
10997 desc: "WeChat iLink Bot",
10998 configured: !self.wechat.is_empty(),
10999 },
11000 ChannelInfo {
11001 kind: "qq",
11002 name: "QQ Official",
11003 desc: "Tencent QQ Bot",
11004 configured: !self.qq.is_empty(),
11005 },
11006 ChannelInfo {
11007 kind: "nostr",
11008 name: "Nostr",
11009 desc: "Nostr DMs",
11010 configured: !self.nostr.is_empty(),
11011 },
11012 ChannelInfo {
11013 kind: "clawdtalk",
11014 name: "ClawdTalk",
11015 desc: "ClawdTalk Channel",
11016 configured: !self.clawdtalk.is_empty(),
11017 },
11018 ChannelInfo {
11019 kind: "reddit",
11020 name: "Reddit",
11021 desc: "Reddit bot (OAuth2)",
11022 configured: !self.reddit.is_empty(),
11023 },
11024 ChannelInfo {
11025 kind: "bluesky",
11026 name: "Bluesky",
11027 desc: "AT Protocol",
11028 configured: !self.bluesky.is_empty(),
11029 },
11030 ChannelInfo {
11031 kind: "twitter",
11032 name: "X/Twitter",
11033 desc: "X/Twitter Bot via API v2",
11034 configured: !self.twitter.is_empty(),
11035 },
11036 ChannelInfo {
11037 kind: "mochat",
11038 name: "Mochat",
11039 desc: "Mochat Customer Service",
11040 configured: !self.mochat.is_empty(),
11041 },
11042 ChannelInfo {
11043 kind: "line",
11044 name: "LINE",
11045 desc: "connect your LINE bot",
11046 configured: !self.line.is_empty(),
11047 },
11048 ChannelInfo {
11049 kind: "voice-call",
11050 name: "Voice Call",
11051 desc: "outbound voice call channel",
11052 configured: !self.voice_call.is_empty(),
11053 },
11054 ChannelInfo {
11055 kind: "voice-wake",
11056 name: "VoiceWake",
11057 desc: "voice wake word detection",
11058 configured: !self.voice_wake.is_empty(),
11059 },
11060 ChannelInfo {
11061 kind: "mqtt",
11062 name: "MQTT",
11063 desc: "MQTT SOP Listener",
11064 configured: !self.mqtt.is_empty(),
11065 },
11066 ChannelInfo {
11067 kind: "amqp",
11068 name: "AMQP",
11069 desc: "AMQP topic consumer",
11070 configured: !self.amqp.is_empty(),
11071 },
11072 ChannelInfo {
11073 kind: "webhook",
11074 name: "Webhook",
11075 desc: "HTTP endpoint",
11076 configured: !self.webhook.is_empty(),
11077 },
11078 ]
11079 }
11080
11081 pub fn has_any_enabled(&self) -> bool {
11087 self.telegram.values().any(|c| c.enabled)
11088 || self.discord.values().any(|c| c.enabled)
11089 || self.slack.values().any(|c| c.enabled)
11090 || self.mattermost.values().any(|c| c.enabled)
11091 || self.webhook.values().any(|c| c.enabled)
11092 || self.imessage.values().any(|c| c.enabled)
11093 || self.matrix.values().any(|c| c.enabled)
11094 || self.signal.values().any(|c| c.enabled)
11095 || self.whatsapp.values().any(|c| c.enabled)
11096 || self.linq.values().any(|c| c.enabled)
11097 || self.wati.values().any(|c| c.enabled)
11098 || self.nextcloud_talk.values().any(|c| c.enabled)
11099 || self.email.values().any(|c| c.enabled)
11100 || self.gmail_push.values().any(|c| c.enabled)
11101 || self.irc.values().any(|c| c.enabled)
11102 || self.twitch.values().any(|c| c.enabled)
11103 || self.lark.values().any(|c| c.enabled)
11104 || self.line.values().any(|c| c.enabled)
11105 || self.dingtalk.values().any(|c| c.enabled)
11106 || self.wecom.values().any(|c| c.enabled)
11107 || self.wecom_ws.values().any(|c| c.enabled)
11108 || self.wechat.values().any(|c| c.enabled)
11109 || self.qq.values().any(|c| c.enabled)
11110 || self.twitter.values().any(|c| c.enabled)
11111 || self.mochat.values().any(|c| c.enabled)
11112 || self.nostr.values().any(|c| c.enabled)
11113 || self.clawdtalk.values().any(|c| c.enabled)
11114 || self.reddit.values().any(|c| c.enabled)
11115 || self.bluesky.values().any(|c| c.enabled)
11116 || self.voice_call.values().any(|c| c.enabled)
11117 || self.voice_wake.values().any(|c| c.enabled)
11118 || self.voice_duplex.values().any(|c| c.enabled)
11119 || self.mqtt.values().any(|c| c.enabled)
11120 || self.amqp.values().any(|c| c.enabled)
11121 }
11122}
11123
11124fn default_channel_message_timeout_secs() -> u64 {
11125 300
11126}
11127
11128fn default_channel_max_concurrent_per_channel() -> usize {
11129 4
11130}
11131
11132fn default_session_backend() -> String {
11133 "sqlite".into()
11134}
11135
11136impl Default for ChannelsConfig {
11137 fn default() -> Self {
11138 Self {
11139 cli: true,
11140 telegram: HashMap::new(),
11141 discord: HashMap::new(),
11142 slack: HashMap::new(),
11143 mattermost: HashMap::new(),
11144 webhook: HashMap::new(),
11145 imessage: HashMap::new(),
11146 matrix: HashMap::new(),
11147 signal: HashMap::new(),
11148 whatsapp: HashMap::new(),
11149 linq: HashMap::new(),
11150 wati: HashMap::new(),
11151 nextcloud_talk: HashMap::new(),
11152 email: HashMap::new(),
11153 gmail_push: HashMap::new(),
11154 irc: HashMap::new(),
11155 twitch: HashMap::new(),
11156 lark: HashMap::new(),
11157 line: HashMap::new(),
11158 dingtalk: HashMap::new(),
11159 wecom: HashMap::new(),
11160 wecom_ws: HashMap::new(),
11161 wechat: HashMap::new(),
11162 qq: HashMap::new(),
11163 twitter: HashMap::new(),
11164 mochat: HashMap::new(),
11165 nostr: HashMap::new(),
11166 clawdtalk: HashMap::new(),
11167 reddit: HashMap::new(),
11168 bluesky: HashMap::new(),
11169 voice_call: HashMap::new(),
11170 voice_wake: HashMap::new(),
11171 voice_duplex: HashMap::new(),
11172 mqtt: HashMap::new(),
11173 amqp: HashMap::new(),
11174 message_timeout_secs: default_channel_message_timeout_secs(),
11175 max_concurrent_per_channel: default_channel_max_concurrent_per_channel(),
11176 ack_reactions: true,
11177 show_tool_calls: false,
11178 session_persistence: true,
11179 session_backend: default_session_backend(),
11180 session_ttl_hours: 0,
11181 debounce_ms: 0,
11182 }
11183 }
11184}
11185
11186#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
11188#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11189#[serde(rename_all = "lowercase")]
11190pub enum StreamMode {
11191 #[default]
11193 Off,
11194 Partial,
11196 #[serde(rename = "multi_message")]
11198 MultiMessage,
11199}
11200
11201fn default_draft_update_interval_ms() -> u64 {
11202 1000
11203}
11204
11205fn default_multi_message_delay_ms() -> u64 {
11206 800
11207}
11208
11209fn default_telegram_approval_timeout_secs() -> u64 {
11210 120
11211}
11212
11213fn default_channel_approval_timeout_secs() -> u64 {
11214 300
11215}
11216
11217fn default_matrix_draft_update_interval_ms() -> u64 {
11218 1500
11219}
11220
11221#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11223#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11224#[prefix = "channels.telegram"]
11225pub struct TelegramConfig {
11226 #[tab(Behavior)]
11231 #[serde(default)]
11232 pub enabled: bool,
11233 #[secret]
11235 #[tab(Connection)]
11236 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11237 pub bot_token: String,
11238 #[tab(Behavior)]
11240 #[serde(default)]
11241 pub stream_mode: StreamMode,
11242 #[tab(Behavior)]
11244 #[serde(default = "default_draft_update_interval_ms")]
11245 pub draft_update_interval_ms: u64,
11246 #[tab(Behavior)]
11249 #[serde(default)]
11250 pub interrupt_on_new_message: bool,
11251 #[tab(Behavior)]
11254 #[serde(default)]
11255 pub mention_only: bool,
11256 #[tab(Behavior)]
11260 #[serde(default)]
11261 pub ack_reactions: Option<bool>,
11262 #[tab(Advanced)]
11265 #[serde(default)]
11266 pub proxy_url: Option<String>,
11267 #[tab(Behavior)]
11270 #[serde(default = "default_telegram_approval_timeout_secs")]
11271 pub approval_timeout_secs: u64,
11272
11273 #[tab(Behavior)]
11276 #[serde(default)]
11277 pub excluded_tools: Vec<String>,
11278 #[serde(default)]
11281 pub reply_min_interval_secs: u64,
11282 #[serde(default)]
11288 pub reply_queue_depth_max: u16,
11289}
11290
11291impl ChannelConfig for TelegramConfig {
11292 fn name() -> &'static str {
11293 "Telegram"
11294 }
11295 fn desc() -> &'static str {
11296 "connect your bot"
11297 }
11298}
11299
11300#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11302#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11303#[prefix = "channels.discord"]
11304#[allow(clippy::struct_excessive_bools)]
11305pub struct DiscordConfig {
11306 #[tab(Behavior)]
11311 #[serde(default)]
11312 pub enabled: bool,
11313 #[secret]
11315 #[tab(Connection)]
11316 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11317 pub bot_token: String,
11318 #[tab(Advanced)]
11322 #[serde(default)]
11323 pub guild_ids: Vec<String>,
11324 #[tab(Advanced)]
11328 #[serde(default)]
11329 pub channel_ids: Vec<String>,
11330 #[tab(Advanced)]
11335 #[serde(default)]
11336 pub archive: bool,
11337 #[tab(Advanced)]
11340 #[serde(default)]
11341 pub listen_to_bots: bool,
11342 #[tab(Behavior)]
11345 #[serde(default)]
11346 pub interrupt_on_new_message: bool,
11347 #[tab(Behavior)]
11350 #[serde(default)]
11351 pub mention_only: bool,
11352 #[tab(Advanced)]
11355 #[serde(default)]
11356 pub proxy_url: Option<String>,
11357 #[tab(Behavior)]
11361 #[serde(default)]
11362 pub stream_mode: StreamMode,
11363 #[tab(Behavior)]
11366 #[serde(default = "default_draft_update_interval_ms")]
11367 pub draft_update_interval_ms: u64,
11368 #[tab(Behavior)]
11371 #[serde(default = "default_multi_message_delay_ms")]
11372 pub multi_message_delay_ms: u64,
11373 #[tab(Advanced)]
11376 #[serde(default)]
11377 pub stall_timeout_secs: u64,
11378 #[tab(Behavior)]
11380 #[serde(default = "default_channel_approval_timeout_secs")]
11381 pub approval_timeout_secs: u64,
11382
11383 #[tab(Behavior)]
11386 #[serde(default)]
11387 pub excluded_tools: Vec<String>,
11388 #[serde(default)]
11391 pub reply_min_interval_secs: u64,
11392 #[serde(default)]
11398 pub reply_queue_depth_max: u16,
11399}
11400
11401impl ChannelConfig for DiscordConfig {
11402 fn name() -> &'static str {
11403 "Discord"
11404 }
11405 fn desc() -> &'static str {
11406 "connect your bot"
11407 }
11408}
11409
11410#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11412#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11413#[prefix = "channels.slack"]
11414#[allow(clippy::struct_excessive_bools)]
11415pub struct SlackConfig {
11416 #[tab(Behavior)]
11421 #[serde(default)]
11422 pub enabled: bool,
11423 #[secret]
11425 #[tab(Connection)]
11426 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11427 pub bot_token: String,
11428 #[secret]
11430 #[tab(Connection)]
11431 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11432 pub app_token: Option<String>,
11433 #[tab(Advanced)]
11437 #[serde(default)]
11438 pub channel_ids: Vec<String>,
11439 #[tab(Behavior)]
11442 #[serde(default)]
11443 pub interrupt_on_new_message: bool,
11444 #[tab(Advanced)]
11447 #[serde(default)]
11448 pub thread_replies: Option<bool>,
11449 #[tab(Behavior)]
11452 #[serde(default)]
11453 pub mention_only: bool,
11454 #[tab(Advanced)]
11461 #[serde(default)]
11462 pub strict_mention_in_thread: bool,
11463 #[tab(Advanced)]
11467 #[serde(default)]
11468 pub use_markdown_blocks: bool,
11469 #[tab(Advanced)]
11472 #[serde(default)]
11473 pub proxy_url: Option<String>,
11474 #[tab(Behavior)]
11476 #[serde(default)]
11477 pub stream_drafts: bool,
11478 #[tab(Behavior)]
11480 #[serde(default = "default_slack_draft_update_interval_ms")]
11481 pub draft_update_interval_ms: u64,
11482 #[tab(Advanced)]
11486 #[serde(default)]
11487 pub cancel_reaction: Option<String>,
11488 #[tab(Behavior)]
11490 #[serde(default = "default_channel_approval_timeout_secs")]
11491 pub approval_timeout_secs: u64,
11492
11493 #[tab(Behavior)]
11496 #[serde(default)]
11497 pub excluded_tools: Vec<String>,
11498 #[serde(default)]
11501 pub reply_min_interval_secs: u64,
11502 #[serde(default)]
11508 pub reply_queue_depth_max: u16,
11509}
11510
11511fn default_slack_draft_update_interval_ms() -> u64 {
11512 1200
11513}
11514
11515impl ChannelConfig for SlackConfig {
11516 fn name() -> &'static str {
11517 "Slack"
11518 }
11519 fn desc() -> &'static str {
11520 "connect your bot"
11521 }
11522}
11523
11524#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11526#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11527#[prefix = "channels.mattermost"]
11528pub struct MattermostConfig {
11529 #[tab(Behavior)]
11534 #[serde(default)]
11535 pub enabled: bool,
11536 #[tab(Connection)]
11538 pub url: String,
11539 #[secret]
11542 #[tab(Connection)]
11543 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11544 #[serde(default)]
11545 pub bot_token: Option<String>,
11546 #[tab(Connection)]
11550 #[serde(default)]
11551 pub login_id: Option<String>,
11552 #[secret]
11555 #[tab(Connection)]
11556 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11557 #[serde(default)]
11558 pub password: Option<String>,
11559 #[tab(Advanced)]
11565 #[serde(default)]
11566 pub channel_ids: Vec<String>,
11567 #[tab(Advanced)]
11572 #[serde(default)]
11573 pub team_ids: Vec<String>,
11574 #[tab(Advanced)]
11580 #[serde(default)]
11581 pub discover_dms: Option<bool>,
11582 #[tab(Advanced)]
11585 #[serde(default)]
11586 pub thread_replies: Option<bool>,
11587 #[tab(Behavior)]
11593 #[serde(default)]
11594 pub mention_only: Option<bool>,
11595 #[tab(Behavior)]
11598 #[serde(default)]
11599 pub interrupt_on_new_message: bool,
11600 #[tab(Advanced)]
11603 #[serde(default)]
11604 pub proxy_url: Option<String>,
11605
11606 #[tab(Behavior)]
11609 #[serde(default)]
11610 pub excluded_tools: Vec<String>,
11611 #[serde(default)]
11614 pub reply_min_interval_secs: u64,
11615 #[serde(default)]
11621 pub reply_queue_depth_max: u16,
11622}
11623
11624impl ChannelConfig for MattermostConfig {
11625 fn name() -> &'static str {
11626 "Mattermost"
11627 }
11628 fn desc() -> &'static str {
11629 "connect to your bot"
11630 }
11631}
11632
11633#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11638#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11639#[prefix = "channels.webhook"]
11640pub struct WebhookConfig {
11641 #[tab(Behavior)]
11646 #[serde(default)]
11647 pub enabled: bool,
11648 #[tab(Advanced)]
11650 #[serde(default = "default_webhook_channel_port")]
11651 pub port: u16,
11652 #[tab(Advanced)]
11654 #[serde(default)]
11655 pub listen_path: Option<String>,
11656 #[tab(Advanced)]
11658 #[serde(default)]
11659 pub send_url: Option<String>,
11660 #[tab(Advanced)]
11662 #[serde(default)]
11663 pub send_method: Option<String>,
11664 #[tab(Connection)]
11666 #[serde(default)]
11667 #[secret]
11668 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11669 pub auth_header: Option<String>,
11670 #[secret]
11672 #[tab(Connection)]
11673 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11674 pub secret: Option<String>,
11675
11676 #[tab(Behavior)]
11679 #[serde(default)]
11680 pub excluded_tools: Vec<String>,
11681 #[serde(default)]
11684 pub reply_min_interval_secs: u64,
11685 #[serde(default)]
11691 pub reply_queue_depth_max: u16,
11692
11693 #[serde(default)]
11696 pub max_retries: Option<u32>,
11697 #[serde(default)]
11700 pub retry_base_delay_ms: Option<u64>,
11701 #[serde(default)]
11704 pub retry_max_delay_ms: Option<u64>,
11705}
11706
11707fn default_webhook_channel_port() -> u16 {
11708 8090
11709}
11710
11711impl ChannelConfig for WebhookConfig {
11712 fn name() -> &'static str {
11713 "Webhook"
11714 }
11715 fn desc() -> &'static str {
11716 "HTTP endpoint"
11717 }
11718}
11719
11720#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11722#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11723#[prefix = "channels.imessage"]
11724pub struct IMessageConfig {
11725 #[tab(Behavior)]
11730 #[serde(default)]
11731 pub enabled: bool,
11732 #[tab(Behavior)]
11735 #[serde(default)]
11736 pub excluded_tools: Vec<String>,
11737 #[serde(default)]
11740 pub reply_min_interval_secs: u64,
11741 #[serde(default)]
11747 pub reply_queue_depth_max: u16,
11748}
11749
11750impl ChannelConfig for IMessageConfig {
11751 fn name() -> &'static str {
11752 "iMessage"
11753 }
11754 fn desc() -> &'static str {
11755 "macOS only"
11756 }
11757}
11758
11759#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11761#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11762#[prefix = "channels.matrix"]
11763pub struct MatrixConfig {
11764 #[tab(Behavior)]
11769 #[serde(default)]
11770 pub enabled: bool,
11771 #[tab(Connection)]
11773 pub homeserver: String,
11774 #[secret]
11777 #[credential_class = "encrypted_secret"]
11778 #[tab(Connection)]
11779 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11780 #[serde(default)]
11781 pub access_token: Option<String>,
11782 #[tab(Connection)]
11784 #[serde(default)]
11785 pub user_id: Option<String>,
11786 #[tab(Connection)]
11788 #[serde(default)]
11789 pub device_id: Option<String>,
11790 #[tab(Behavior)]
11796 #[serde(default)]
11797 pub allowed_rooms: Vec<String>,
11798 #[tab(Behavior)]
11800 #[serde(default)]
11801 pub interrupt_on_new_message: bool,
11802 #[tab(Behavior)]
11806 #[serde(default)]
11807 pub stream_mode: StreamMode,
11808 #[tab(Behavior)]
11810 #[serde(default = "default_matrix_draft_update_interval_ms")]
11811 pub draft_update_interval_ms: u64,
11812 #[tab(Behavior)]
11814 #[serde(default = "default_multi_message_delay_ms")]
11815 pub multi_message_delay_ms: u64,
11816 #[tab(Behavior)]
11819 #[serde(default)]
11820 pub mention_only: bool,
11821 #[secret]
11824 #[credential_class = "encrypted_secret"]
11825 #[tab(Connection)]
11826 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11827 #[serde(default)]
11828 pub recovery_key: Option<String>,
11829 #[secret]
11831 #[credential_class = "encrypted_secret"]
11832 #[tab(Connection)]
11833 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11834 #[serde(default)]
11835 pub password: Option<String>,
11836 #[tab(Behavior)]
11838 #[serde(default = "default_channel_approval_timeout_secs")]
11839 pub approval_timeout_secs: u64,
11840 #[tab(Behavior)]
11843 #[serde(default = "default_true")]
11844 pub reply_in_thread: bool,
11845 #[tab(Behavior)]
11850 #[serde(default)]
11851 pub ack_reactions: Option<bool>,
11852
11853 #[tab(Behavior)]
11856 #[serde(default)]
11857 pub excluded_tools: Vec<String>,
11858 #[serde(default)]
11861 pub reply_min_interval_secs: u64,
11862 #[serde(default)]
11868 pub reply_queue_depth_max: u16,
11869}
11870
11871impl ChannelConfig for MatrixConfig {
11872 fn name() -> &'static str {
11873 "Matrix"
11874 }
11875 fn desc() -> &'static str {
11876 "self-hosted chat"
11877 }
11878}
11879
11880#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11881#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11882#[prefix = "channels.signal"]
11883pub struct SignalConfig {
11884 #[tab(Behavior)]
11889 #[serde(default)]
11890 pub enabled: bool,
11891 #[tab(Connection)]
11893 pub http_url: String,
11894 #[tab(Connection)]
11896 pub account: String,
11897 #[tab(Advanced)]
11902 #[serde(default)]
11903 pub group_ids: Vec<String>,
11904 #[tab(Advanced)]
11908 #[serde(default)]
11909 pub dm_only: bool,
11910 #[tab(Advanced)]
11912 #[serde(default)]
11913 pub ignore_attachments: bool,
11914 #[tab(Advanced)]
11916 #[serde(default)]
11917 pub ignore_stories: bool,
11918 #[tab(Advanced)]
11921 #[serde(default)]
11922 pub proxy_url: Option<String>,
11923 #[tab(Behavior)]
11925 #[serde(default = "default_channel_approval_timeout_secs")]
11926 pub approval_timeout_secs: u64,
11927
11928 #[tab(Behavior)]
11931 #[serde(default)]
11932 pub excluded_tools: Vec<String>,
11933 #[serde(default)]
11936 pub reply_min_interval_secs: u64,
11937 #[serde(default)]
11943 pub reply_queue_depth_max: u16,
11944}
11945
11946impl ChannelConfig for SignalConfig {
11947 fn name() -> &'static str {
11948 "Signal"
11949 }
11950 fn desc() -> &'static str {
11951 "An open-source, encrypted messaging service"
11952 }
11953}
11954
11955#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
11962#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11963#[serde(rename_all = "snake_case")]
11964pub enum WhatsAppWebMode {
11965 #[default]
11967 Business,
11968 Personal,
11970}
11971
11972#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
11975#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11976#[serde(rename_all = "snake_case")]
11977pub enum WhatsAppChatPolicy {
11978 #[default]
11980 Allowlist,
11981 Ignore,
11983 All,
11985}
11986
11987#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11991#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11992#[prefix = "channels.whatsapp"]
11993pub struct WhatsAppConfig {
11994 #[tab(Behavior)]
11999 #[serde(default)]
12000 pub enabled: bool,
12001 #[serde(default)]
12003 #[secret]
12004 #[tab(Connection)]
12005 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12006 pub access_token: Option<String>,
12007 #[tab(Connection)]
12009 #[serde(default)]
12010 pub phone_number_id: Option<String>,
12011 #[serde(default)]
12014 #[secret]
12015 #[tab(Connection)]
12016 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12017 pub verify_token: Option<String>,
12018 #[serde(default)]
12022 #[secret]
12023 #[tab(Connection)]
12024 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12025 pub app_secret: Option<String>,
12026 #[tab(Connection)]
12029 #[serde(default)]
12030 pub session_path: Option<String>,
12031 #[tab(Connection)]
12035 #[serde(default)]
12036 pub pair_phone: Option<String>,
12037 #[tab(Connection)]
12040 #[serde(default)]
12041 pub pair_code: Option<String>,
12042 #[tab(Connection)]
12046 #[serde(default)]
12047 pub ws_url: Option<String>,
12048 #[tab(Behavior)]
12052 #[serde(default)]
12053 pub mention_only: bool,
12054 #[serde(default)]
12057 pub interrupt_on_new_message: bool,
12058 #[tab(Advanced)]
12062 #[serde(default)]
12063 pub mode: WhatsAppWebMode,
12064 #[tab(Advanced)]
12067 #[serde(default)]
12068 pub dm_policy: WhatsAppChatPolicy,
12069 #[tab(Advanced)]
12072 #[serde(default)]
12073 pub group_policy: WhatsAppChatPolicy,
12074 #[tab(Advanced)]
12077 #[serde(default)]
12078 pub self_chat_mode: bool,
12079 #[tab(Advanced)]
12084 #[serde(default)]
12085 pub dm_mention_patterns: Vec<String>,
12086 #[tab(Advanced)]
12091 #[serde(default)]
12092 pub group_mention_patterns: Vec<String>,
12093 #[tab(Advanced)]
12096 #[serde(default)]
12097 pub proxy_url: Option<String>,
12098 #[tab(Behavior)]
12100 #[serde(default = "default_channel_approval_timeout_secs")]
12101 pub approval_timeout_secs: u64,
12102
12103 #[tab(Behavior)]
12106 #[serde(default)]
12107 pub excluded_tools: Vec<String>,
12108 #[serde(default)]
12111 pub reply_min_interval_secs: u64,
12112 #[serde(default)]
12118 pub reply_queue_depth_max: u16,
12119}
12120
12121impl ChannelConfig for WhatsAppConfig {
12122 fn name() -> &'static str {
12123 "WhatsApp"
12124 }
12125 fn desc() -> &'static str {
12126 "Business Cloud API"
12127 }
12128}
12129
12130impl_reply_pacing!(
12131 TelegramConfig,
12132 DiscordConfig,
12133 SlackConfig,
12134 MattermostConfig,
12135 WebhookConfig,
12136 IMessageConfig,
12137 MatrixConfig,
12138 SignalConfig,
12139 WhatsAppConfig,
12140);
12141
12142#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12143#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12144#[prefix = "channels.linq"]
12145pub struct LinqConfig {
12146 #[tab(Behavior)]
12151 #[serde(default)]
12152 pub enabled: bool,
12153 #[secret]
12155 #[tab(Connection)]
12156 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12157 pub api_token: String,
12158 #[tab(Advanced)]
12160 pub from_phone: String,
12161 #[serde(default)]
12163 #[secret]
12164 #[tab(Connection)]
12165 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12166 pub signing_secret: Option<String>,
12167
12168 #[tab(Behavior)]
12171 #[serde(default)]
12172 pub excluded_tools: Vec<String>,
12173}
12174
12175impl ChannelConfig for LinqConfig {
12176 fn name() -> &'static str {
12177 "Linq"
12178 }
12179 fn desc() -> &'static str {
12180 "iMessage/RCS/SMS via Linq API"
12181 }
12182}
12183
12184#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12186#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12187#[prefix = "channels.wati"]
12188pub struct WatiConfig {
12189 #[tab(Behavior)]
12194 #[serde(default)]
12195 pub enabled: bool,
12196 #[secret]
12198 #[tab(Connection)]
12199 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12200 pub api_token: String,
12201 #[tab(Advanced)]
12203 #[serde(default = "default_wati_api_url")]
12204 pub api_url: String,
12205 #[tab(Advanced)]
12207 #[serde(default)]
12208 pub tenant_id: Option<String>,
12209 #[tab(Advanced)]
12212 #[serde(default)]
12213 pub proxy_url: Option<String>,
12214
12215 #[tab(Behavior)]
12218 #[serde(default)]
12219 pub excluded_tools: Vec<String>,
12220}
12221
12222fn default_wati_api_url() -> String {
12223 "https://live-mt-server.wati.io".to_string()
12224}
12225
12226impl ChannelConfig for WatiConfig {
12227 fn name() -> &'static str {
12228 "WATI"
12229 }
12230 fn desc() -> &'static str {
12231 "WhatsApp via WATI Business API"
12232 }
12233}
12234
12235#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12237#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12238#[prefix = "channels.nextcloud_talk"]
12239pub struct NextcloudTalkConfig {
12240 #[tab(Behavior)]
12245 #[serde(default)]
12246 pub enabled: bool,
12247 #[tab(Connection)]
12249 pub base_url: String,
12250 #[secret]
12252 #[tab(Connection)]
12253 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12254 pub app_token: String,
12255 #[serde(default)]
12259 #[secret]
12260 #[tab(Connection)]
12261 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12262 pub webhook_secret: Option<String>,
12263 #[tab(Advanced)]
12266 #[serde(default)]
12267 pub proxy_url: Option<String>,
12268 #[tab(Advanced)]
12272 #[serde(default)]
12273 pub bot_name: Option<String>,
12274 #[tab(Behavior)]
12277 #[serde(default)]
12278 pub excluded_tools: Vec<String>,
12279 #[tab(Behavior)]
12285 #[serde(default)]
12286 pub stream_mode: StreamMode,
12287 #[tab(Behavior)]
12290 #[serde(default = "default_draft_update_interval_ms")]
12291 pub draft_update_interval_ms: u64,
12292}
12293
12294impl ChannelConfig for NextcloudTalkConfig {
12295 fn name() -> &'static str {
12296 "NextCloud Talk"
12297 }
12298 fn desc() -> &'static str {
12299 "NextCloud Talk platform"
12300 }
12301}
12302
12303impl WhatsAppConfig {
12304 pub fn backend_type(&self) -> &'static str {
12307 if self.phone_number_id.is_some() {
12308 "cloud"
12309 } else if self.session_path.is_some() {
12310 "web"
12311 } else {
12312 "cloud"
12314 }
12315 }
12316
12317 pub fn is_cloud_config(&self) -> bool {
12319 self.phone_number_id.is_some() && self.access_token.is_some() && self.verify_token.is_some()
12320 }
12321
12322 pub fn is_web_config(&self) -> bool {
12324 self.session_path.is_some()
12325 }
12326
12327 pub fn is_ambiguous_config(&self) -> bool {
12331 self.phone_number_id.is_some() && self.session_path.is_some()
12332 }
12333}
12334
12335#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12340#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12341#[prefix = "channels.mqtt"]
12342pub struct MqttConfig {
12343 #[tab(Behavior)]
12348 #[serde(default)]
12349 pub enabled: bool,
12350 #[tab(Connection)]
12353 pub broker_url: String,
12354 #[tab(Advanced)]
12356 pub client_id: String,
12357 #[tab(Advanced)]
12360 #[serde(default)]
12361 pub topics: Vec<String>,
12362 #[tab(Advanced)]
12364 #[serde(default = "default_mqtt_qos")]
12365 pub qos: u8,
12366 #[tab(Connection)]
12368 pub username: Option<String>,
12369 #[secret]
12371 #[tab(Connection)]
12372 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12373 pub password: Option<String>,
12374 #[tab(Advanced)]
12378 #[serde(default)]
12379 pub use_tls: bool,
12380 #[tab(Advanced)]
12382 #[serde(default = "default_mqtt_keep_alive_secs")]
12383 pub keep_alive_secs: u64,
12384
12385 #[tab(Behavior)]
12388 #[serde(default)]
12389 pub excluded_tools: Vec<String>,
12390}
12391
12392impl MqttConfig {
12393 pub fn validate(&self) -> anyhow::Result<()> {
12402 if self.qos > 2 {
12404 anyhow::bail!("qos must be 0, 1, or 2, got {}", self.qos);
12405 }
12406
12407 let is_tls_scheme = self.broker_url.starts_with("mqtts://");
12409 let is_mqtt_scheme = self.broker_url.starts_with("mqtt://");
12410
12411 if !is_tls_scheme && !is_mqtt_scheme {
12412 anyhow::bail!(
12413 "broker_url must start with 'mqtt://' or 'mqtts://', got: {}",
12414 self.broker_url
12415 );
12416 }
12417
12418 if is_mqtt_scheme && self.use_tls {
12420 anyhow::bail!("use_tls is true but broker_url uses 'mqtt://' (not 'mqtts://')");
12421 }
12422
12423 if is_tls_scheme && !self.use_tls {
12424 anyhow::bail!(
12425 "use_tls is false but broker_url uses 'mqtts://' (requires use_tls: true)"
12426 );
12427 }
12428
12429 if self.topics.is_empty() {
12431 anyhow::bail!("at least one topic must be configured");
12432 }
12433
12434 if self.client_id.is_empty() {
12436 validation_bail!(
12437 RequiredFieldEmpty,
12438 "client_id",
12439 "client_id must not be empty"
12440 );
12441 }
12442
12443 Ok(())
12444 }
12445}
12446
12447impl ChannelConfig for MqttConfig {
12448 fn name() -> &'static str {
12449 "MQTT"
12450 }
12451 fn desc() -> &'static str {
12452 "MQTT SOP Listener"
12453 }
12454}
12455
12456fn default_mqtt_qos() -> u8 {
12457 1
12458}
12459
12460fn default_mqtt_keep_alive_secs() -> u64 {
12461 30
12462}
12463
12464#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12472#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12473#[prefix = "channels.amqp"]
12474pub struct AmqpConfig {
12475 #[tab(Behavior)]
12480 #[serde(default)]
12481 pub enabled: bool,
12482 #[tab(Connection)]
12485 pub amqp_url: String,
12486 #[tab(Advanced)]
12488 pub exchange: String,
12489 #[tab(Advanced)]
12492 #[serde(default)]
12493 pub routing_keys: Vec<String>,
12494 #[tab(Advanced)]
12498 pub queue: Option<String>,
12499 #[tab(Connection)]
12501 pub ca_cert: Option<PathBuf>,
12502 #[tab(Connection)]
12505 pub client_cert: Option<PathBuf>,
12506 #[tab(Connection)]
12508 pub client_key: Option<PathBuf>,
12509 #[tab(Behavior)]
12513 #[serde(default = "default_amqp_sender_label")]
12514 pub sender_label: String,
12515 #[tab(Behavior)]
12519 #[serde(default)]
12520 pub content_template: String,
12521 #[tab(Behavior)]
12525 #[serde(default)]
12526 pub thread_id_field: String,
12527 #[tab(Behavior)]
12534 #[serde(default = "default_amqp_durable_ack")]
12535 pub durable_ack: bool,
12536 #[tab(Behavior)]
12539 #[serde(default)]
12540 pub excluded_tools: Vec<String>,
12541}
12542
12543impl AmqpConfig {
12544 pub fn validate(&self) -> anyhow::Result<()> {
12553 let is_tls = self.amqp_url.starts_with("amqps://");
12554 let is_plain = self.amqp_url.starts_with("amqp://");
12555
12556 if !is_tls && !is_plain {
12557 anyhow::bail!(
12558 "amqp_url must start with 'amqp://' or 'amqps://', got: {}",
12559 self.amqp_url
12560 );
12561 }
12562
12563 if is_tls && self.ca_cert.is_none() {
12564 anyhow::bail!("amqps:// requires ca_cert to verify the broker");
12565 }
12566
12567 match (self.client_cert.is_some(), self.client_key.is_some()) {
12568 (true, false) => {
12569 anyhow::bail!(
12570 "client_cert is set but client_key is missing (both are required for mutual TLS)"
12571 )
12572 }
12573 (false, true) => {
12574 anyhow::bail!(
12575 "client_key is set but client_cert is missing (both are required for mutual TLS)"
12576 )
12577 }
12578 _ => {}
12579 }
12580
12581 if self.exchange.is_empty() {
12582 validation_bail!(RequiredFieldEmpty, "exchange", "exchange must not be empty");
12583 }
12584
12585 if self.routing_keys.is_empty() {
12586 anyhow::bail!("at least one routing key must be configured");
12587 }
12588
12589 Ok(())
12590 }
12591}
12592
12593impl ChannelConfig for AmqpConfig {
12594 fn name() -> &'static str {
12595 "AMQP"
12596 }
12597 fn desc() -> &'static str {
12598 "AMQP topic consumer"
12599 }
12600}
12601
12602fn default_amqp_sender_label() -> String {
12603 "amqp".to_string()
12604}
12605
12606fn default_amqp_durable_ack() -> bool {
12607 true
12608}
12609
12610#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12612#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12613#[prefix = "channels.irc"]
12614pub struct IrcConfig {
12615 #[tab(Behavior)]
12620 #[serde(default)]
12621 pub enabled: bool,
12622 #[tab(Advanced)]
12624 pub server: String,
12625 #[tab(Advanced)]
12627 #[serde(default = "default_irc_port")]
12628 pub port: u16,
12629 #[tab(Advanced)]
12631 pub nickname: String,
12632 #[tab(Connection)]
12634 pub username: Option<String>,
12635 #[tab(Advanced)]
12637 #[serde(default)]
12638 pub channels: Vec<String>,
12639 #[secret]
12641 #[tab(Connection)]
12642 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12643 pub server_password: Option<String>,
12644 #[secret]
12646 #[tab(Connection)]
12647 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12648 pub nickserv_password: Option<String>,
12649 #[secret]
12651 #[tab(Connection)]
12652 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12653 pub sasl_password: Option<String>,
12654 #[tab(Advanced)]
12656 pub verify_tls: Option<bool>,
12657 #[tab(Behavior)]
12660 #[serde(default)]
12661 pub mention_only: bool,
12662
12663 #[tab(Behavior)]
12666 #[serde(default)]
12667 pub excluded_tools: Vec<String>,
12668}
12669
12670impl ChannelConfig for IrcConfig {
12671 fn name() -> &'static str {
12672 "IRC"
12673 }
12674 fn desc() -> &'static str {
12675 "IRC over TLS"
12676 }
12677}
12678
12679#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12682#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12683#[prefix = "channels.twitch"]
12684pub struct TwitchConfig {
12685 #[tab(Behavior)]
12688 #[serde(default)]
12689 pub enabled: bool,
12690 #[tab(Connection)]
12693 pub bot_username: String,
12694 #[secret]
12698 #[tab(Connection)]
12699 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12700 pub oauth_token: String,
12701 #[tab(Advanced)]
12705 #[serde(default)]
12706 pub channels: Vec<String>,
12707 #[tab(Behavior)]
12710 #[serde(default)]
12711 pub mention_only: bool,
12712}
12713
12714impl ChannelConfig for TwitchConfig {
12715 fn name() -> &'static str {
12716 "Twitch"
12717 }
12718 fn desc() -> &'static str {
12719 "Twitch chat (IRC)"
12720 }
12721}
12722
12723fn default_irc_port() -> u16 {
12724 6697
12725}
12726
12727#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
12732#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12733#[serde(rename_all = "lowercase")]
12734pub enum LarkReceiveMode {
12735 #[default]
12736 Websocket,
12737 Webhook,
12738}
12739
12740#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12743#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12744#[prefix = "channels.lark"]
12745pub struct LarkConfig {
12746 #[tab(Behavior)]
12751 #[serde(default)]
12752 pub enabled: bool,
12753 #[tab(Connection)]
12755 pub app_id: String,
12756 #[secret]
12758 #[tab(Connection)]
12759 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12760 pub app_secret: String,
12761 #[serde(default)]
12763 #[secret]
12764 #[tab(Connection)]
12765 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12766 pub encrypt_key: Option<String>,
12767 #[serde(default)]
12769 #[secret]
12770 #[tab(Connection)]
12771 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12772 pub verification_token: Option<String>,
12773 #[tab(Behavior)]
12776 #[serde(default)]
12777 pub mention_only: bool,
12778 #[tab(Advanced)]
12780 #[serde(default)]
12781 pub use_feishu: bool,
12782 #[tab(Advanced)]
12784 #[serde(default)]
12785 pub receive_mode: LarkReceiveMode,
12786 #[tab(Advanced)]
12789 #[serde(default)]
12790 pub port: Option<u16>,
12791 #[tab(Advanced)]
12794 #[serde(default)]
12795 pub proxy_url: Option<String>,
12796
12797 #[tab(Behavior)]
12800 #[serde(default)]
12801 pub excluded_tools: Vec<String>,
12802
12803 #[tab(Behavior)]
12806 #[serde(default = "default_channel_approval_timeout_secs")]
12807 pub approval_timeout_secs: u64,
12808 #[tab(Behavior)]
12814 #[serde(default)]
12815 pub per_user_session: bool,
12816
12817 #[tab(Behavior)]
12823 #[serde(default)]
12824 pub stream_mode: StreamMode,
12825
12826 #[tab(Behavior)]
12830 #[serde(default = "default_draft_update_interval_ms")]
12831 pub draft_update_interval_ms: u64,
12832}
12833
12834impl ChannelConfig for LarkConfig {
12835 fn name() -> &'static str {
12836 "Lark"
12837 }
12838 fn desc() -> &'static str {
12839 "Lark Bot"
12840 }
12841}
12842
12843#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
12845#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12846#[serde(rename_all = "lowercase")]
12847pub enum LineDmPolicy {
12848 Open,
12850 #[default]
12853 Pairing,
12854 Allowlist,
12856}
12857
12858#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
12860#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12861#[serde(rename_all = "lowercase")]
12862pub enum LineGroupPolicy {
12863 Open,
12865 #[default]
12867 Mention,
12868 Disabled,
12870}
12871
12872#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12874#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12875#[prefix = "channels.line"]
12876pub struct LineConfig {
12877 #[tab(Behavior)]
12882 #[serde(default)]
12883 pub enabled: bool,
12884 #[serde(default)]
12888 #[secret]
12889 #[tab(Connection)]
12890 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12891 pub channel_access_token: String,
12892 #[serde(default)]
12896 #[secret]
12897 #[tab(Connection)]
12898 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12899 pub channel_secret: String,
12900 #[tab(Advanced)]
12906 #[serde(default)]
12907 pub dm_policy: LineDmPolicy,
12908 #[tab(Advanced)]
12914 #[serde(default)]
12915 pub group_policy: LineGroupPolicy,
12916 #[tab(Advanced)]
12918 #[serde(default = "default_line_webhook_port")]
12919 pub webhook_port: u16,
12920 #[tab(Advanced)]
12923 #[serde(default)]
12924 pub proxy_url: Option<String>,
12925
12926 #[tab(Behavior)]
12929 #[serde(default)]
12930 pub excluded_tools: Vec<String>,
12931}
12932
12933fn default_line_webhook_port() -> u16 {
12934 8443
12935}
12936
12937impl ChannelConfig for LineConfig {
12938 fn name() -> &'static str {
12939 "LINE"
12940 }
12941 fn desc() -> &'static str {
12942 "connect your LINE bot"
12943 }
12944}
12945
12946#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
12954#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12955#[prefix = "security"]
12956pub struct SecurityConfig {
12957 #[serde(default)]
12959 #[nested]
12960 pub audit: AuditConfig,
12961
12962 #[serde(default)]
12964 #[nested]
12965 pub otp: OtpConfig,
12966
12967 #[serde(default)]
12969 #[nested]
12970 pub estop: EstopConfig,
12971
12972 #[serde(default)]
12974 #[nested]
12975 pub nevis: NevisConfig,
12976
12977 #[serde(default)]
12979 #[nested]
12980 pub webauthn: WebAuthnConfig,
12981}
12982
12983#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12988#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12989#[prefix = "security.webauthn"]
12990pub struct WebAuthnConfig {
12991 #[serde(default)]
12993 pub enabled: bool,
12994 #[serde(default = "default_webauthn_rp_id")]
12996 pub rp_id: String,
12997 #[serde(default = "default_webauthn_rp_origin")]
12999 pub rp_origin: String,
13000 #[serde(default = "default_webauthn_rp_name")]
13002 pub rp_name: String,
13003}
13004
13005impl Default for WebAuthnConfig {
13006 fn default() -> Self {
13007 Self {
13008 enabled: false,
13009 rp_id: default_webauthn_rp_id(),
13010 rp_origin: default_webauthn_rp_origin(),
13011 rp_name: default_webauthn_rp_name(),
13012 }
13013 }
13014}
13015
13016fn default_webauthn_rp_id() -> String {
13017 "localhost".into()
13018}
13019
13020fn default_webauthn_rp_origin() -> String {
13021 "http://localhost:42617".into()
13022}
13023
13024fn default_webauthn_rp_name() -> String {
13025 "ZeroClaw".into()
13026}
13027
13028#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
13030#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13031#[serde(rename_all = "kebab-case")]
13032pub enum OtpMethod {
13033 #[default]
13035 Totp,
13036 Pairing,
13038 CliPrompt,
13040}
13041
13042#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13044#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13045#[prefix = "security.otp"]
13046#[serde(deny_unknown_fields)]
13047pub struct OtpConfig {
13048 #[serde(default)]
13050 pub enabled: bool,
13051
13052 #[serde(default)]
13054 pub method: OtpMethod,
13055
13056 #[serde(default = "default_otp_token_ttl_secs")]
13058 pub token_ttl_secs: u64,
13059
13060 #[serde(default = "default_otp_cache_valid_secs")]
13062 pub cache_valid_secs: u64,
13063
13064 #[serde(default = "default_otp_gated_actions")]
13068 pub gated_actions: Vec<String>,
13069
13070 #[serde(default)]
13072 pub gated_domains: Vec<String>,
13073
13074 #[serde(default)]
13076 pub gated_domain_categories: Vec<String>,
13077
13078 #[serde(default = "default_otp_challenge_max_attempts")]
13080 pub challenge_max_attempts: u32,
13081}
13082
13083fn default_otp_token_ttl_secs() -> u64 {
13084 30
13085}
13086
13087fn default_otp_cache_valid_secs() -> u64 {
13088 300
13089}
13090
13091fn default_otp_challenge_max_attempts() -> u32 {
13092 3
13093}
13094
13095fn default_otp_gated_actions() -> Vec<String> {
13096 vec![
13097 "shell".to_string(),
13098 "file_write".to_string(),
13099 "browser_open".to_string(),
13100 "browser".to_string(),
13101 "memory_forget".to_string(),
13102 ]
13103}
13104
13105impl Default for OtpConfig {
13106 fn default() -> Self {
13107 Self {
13108 enabled: false,
13109 method: OtpMethod::Totp,
13110 token_ttl_secs: default_otp_token_ttl_secs(),
13111 cache_valid_secs: default_otp_cache_valid_secs(),
13112 gated_actions: default_otp_gated_actions(),
13113 gated_domains: Vec::new(),
13114 gated_domain_categories: Vec::new(),
13115 challenge_max_attempts: default_otp_challenge_max_attempts(),
13116 }
13117 }
13118}
13119
13120#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13122#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13123#[prefix = "security.estop"]
13124#[serde(deny_unknown_fields)]
13125pub struct EstopConfig {
13126 #[serde(default)]
13128 pub enabled: bool,
13129
13130 #[serde(default = "default_estop_state_file")]
13132 pub state_file: String,
13133
13134 #[serde(default = "default_true")]
13136 pub require_otp_to_resume: bool,
13137}
13138
13139fn default_estop_state_file() -> String {
13140 default_path_under_config_dir("estop-state.json")
13141}
13142
13143impl Default for EstopConfig {
13144 fn default() -> Self {
13145 Self {
13146 enabled: false,
13147 state_file: default_estop_state_file(),
13148 require_otp_to_resume: true,
13149 }
13150 }
13151}
13152
13153#[derive(Clone, Serialize, Deserialize, Configurable)]
13158#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13159#[prefix = "security.nevis"]
13160#[serde(deny_unknown_fields)]
13161pub struct NevisConfig {
13162 #[serde(default)]
13164 pub enabled: bool,
13165
13166 #[serde(default)]
13168 pub instance_url: String,
13169
13170 #[serde(default = "default_nevis_realm")]
13172 pub realm: String,
13173
13174 #[serde(default)]
13176 pub client_id: String,
13177
13178 #[serde(default)]
13180 #[secret]
13181 #[credential_class = "encrypted_secret"]
13182 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13183 pub client_secret: Option<String>,
13184
13185 #[serde(default = "default_nevis_token_validation")]
13187 pub token_validation: String,
13188
13189 #[serde(default)]
13191 pub jwks_url: Option<String>,
13192
13193 #[serde(default)]
13195 pub role_mapping: Vec<NevisRoleMappingConfig>,
13196
13197 #[serde(default)]
13199 pub require_mfa: bool,
13200
13201 #[serde(default = "default_nevis_session_timeout_secs")]
13203 pub session_timeout_secs: u64,
13204}
13205
13206impl std::fmt::Debug for NevisConfig {
13207 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13208 f.debug_struct("NevisConfig")
13209 .field("enabled", &self.enabled)
13210 .field("instance_url", &self.instance_url)
13211 .field("realm", &self.realm)
13212 .field("client_id", &self.client_id)
13213 .field(
13214 "client_secret",
13215 &self.client_secret.as_ref().map(|_| "[REDACTED]"),
13216 )
13217 .field("token_validation", &self.token_validation)
13218 .field("jwks_url", &self.jwks_url)
13219 .field("role_mapping", &self.role_mapping)
13220 .field("require_mfa", &self.require_mfa)
13221 .field("session_timeout_secs", &self.session_timeout_secs)
13222 .finish()
13223 }
13224}
13225
13226impl NevisConfig {
13227 pub fn validate(&self) -> Result<(), String> {
13232 if !self.enabled {
13233 return Ok(());
13234 }
13235
13236 if self.instance_url.trim().is_empty() {
13237 return Err("nevis.instance_url is required when Nevis IAM is enabled".into());
13238 }
13239
13240 if self.client_id.trim().is_empty() {
13241 return Err("nevis.client_id is required when Nevis IAM is enabled".into());
13242 }
13243
13244 if self.realm.trim().is_empty() {
13245 return Err("nevis.realm is required when Nevis IAM is enabled".into());
13246 }
13247
13248 match self.token_validation.as_str() {
13249 "local" | "remote" => {}
13250 other => {
13251 return Err(format!(
13252 "nevis.token_validation has invalid value '{other}': \
13253 expected 'local' or 'remote'"
13254 ));
13255 }
13256 }
13257
13258 if self.token_validation == "local" && self.jwks_url.is_none() {
13259 return Err("nevis.jwks_url is required when token_validation is 'local'".into());
13260 }
13261
13262 if self.session_timeout_secs == 0 {
13263 return Err("nevis.session_timeout_secs must be greater than 0".into());
13264 }
13265
13266 Ok(())
13267 }
13268}
13269
13270fn default_nevis_realm() -> String {
13271 "master".into()
13272}
13273
13274fn default_nevis_token_validation() -> String {
13275 "local".into()
13276}
13277
13278fn default_nevis_session_timeout_secs() -> u64 {
13279 3600
13280}
13281
13282impl Default for NevisConfig {
13283 fn default() -> Self {
13284 Self {
13285 enabled: false,
13286 instance_url: String::new(),
13287 realm: default_nevis_realm(),
13288 client_id: String::new(),
13289 client_secret: None,
13290 token_validation: default_nevis_token_validation(),
13291 jwks_url: None,
13292 role_mapping: Vec::new(),
13293 require_mfa: false,
13294 session_timeout_secs: default_nevis_session_timeout_secs(),
13295 }
13296 }
13297}
13298
13299#[derive(Debug, Clone, Serialize, Deserialize)]
13301#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13302#[serde(deny_unknown_fields)]
13303pub struct NevisRoleMappingConfig {
13304 pub nevis_role: String,
13306
13307 #[serde(default)]
13309 pub zeroclaw_permissions: Vec<String>,
13310
13311 #[serde(default)]
13313 pub workspace_access: Vec<String>,
13314}
13315
13316#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13318#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13319#[prefix = "security.sandbox"]
13320pub struct SandboxConfig {
13321 #[serde(default)]
13323 pub enabled: Option<bool>,
13324
13325 #[serde(default)]
13327 pub backend: SandboxBackend,
13328
13329 #[serde(default)]
13331 pub firejail_args: Vec<String>,
13332}
13333
13334impl Default for SandboxConfig {
13335 fn default() -> Self {
13336 Self {
13337 enabled: None, backend: SandboxBackend::Auto,
13339 firejail_args: Vec::new(),
13340 }
13341 }
13342}
13343
13344#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13346#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13347#[serde(rename_all = "lowercase")]
13348pub enum SandboxBackend {
13349 #[default]
13351 Auto,
13352 Landlock,
13354 Firejail,
13356 Bubblewrap,
13358 Docker,
13360 #[serde(alias = "sandbox-exec")]
13362 SandboxExec,
13363 None,
13365}
13366
13367#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13369#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13370#[prefix = "security.audit"]
13371pub struct AuditConfig {
13372 #[serde(default = "default_audit_enabled")]
13374 pub enabled: bool,
13375
13376 #[serde(default = "default_audit_log_path")]
13378 pub log_path: String,
13379
13380 #[serde(default = "default_audit_max_size_mb")]
13382 pub max_size_mb: u32,
13383
13384 #[serde(default)]
13386 pub sign_events: bool,
13387}
13388
13389fn default_audit_enabled() -> bool {
13390 true
13391}
13392
13393fn default_audit_log_path() -> String {
13394 "audit.log".to_string()
13395}
13396
13397fn default_audit_max_size_mb() -> u32 {
13398 100
13399}
13400
13401impl Default for AuditConfig {
13402 fn default() -> Self {
13403 Self {
13404 enabled: default_audit_enabled(),
13405 log_path: default_audit_log_path(),
13406 max_size_mb: default_audit_max_size_mb(),
13407 sign_events: false,
13408 }
13409 }
13410}
13411
13412#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13414#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13415#[prefix = "channels.dingtalk"]
13416pub struct DingTalkConfig {
13417 #[tab(Behavior)]
13422 #[serde(default)]
13423 pub enabled: bool,
13424 #[tab(Connection)]
13426 pub client_id: String,
13427 #[secret]
13429 #[tab(Connection)]
13430 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13431 pub client_secret: String,
13432 #[tab(Advanced)]
13435 #[serde(default)]
13436 pub proxy_url: Option<String>,
13437
13438 #[tab(Behavior)]
13441 #[serde(default)]
13442 pub excluded_tools: Vec<String>,
13443}
13444
13445impl ChannelConfig for DingTalkConfig {
13446 fn name() -> &'static str {
13447 "DingTalk"
13448 }
13449 fn desc() -> &'static str {
13450 "DingTalk Stream Mode"
13451 }
13452}
13453
13454#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13456#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13457#[prefix = "channels.wecom"]
13458pub struct WeComConfig {
13459 #[tab(Behavior)]
13464 #[serde(default)]
13465 pub enabled: bool,
13466 #[secret]
13468 #[tab(Connection)]
13469 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13470 pub webhook_key: String,
13471
13472 #[tab(Behavior)]
13475 #[serde(default)]
13476 pub excluded_tools: Vec<String>,
13477}
13478
13479impl ChannelConfig for WeComConfig {
13480 fn name() -> &'static str {
13481 "WeCom"
13482 }
13483 fn desc() -> &'static str {
13484 "WeCom Bot Webhook"
13485 }
13486}
13487
13488fn default_wecom_ws_file_retention_days() -> u32 {
13489 7
13490}
13491
13492fn default_wecom_ws_max_file_size_mb() -> u64 {
13493 20
13494}
13495
13496fn default_wecom_ws_stream_mode() -> StreamMode {
13497 StreamMode::Partial
13498}
13499
13500#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13505#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13506#[prefix = "channels.wecom_ws"]
13507pub struct WeComWsConfig {
13508 #[serde(default)]
13513 pub enabled: bool,
13514 pub bot_id: String,
13516 #[secret]
13518 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13519 pub secret: String,
13520 #[serde(default)]
13522 pub allowed_users: Vec<String>,
13523 #[serde(default)]
13525 pub allowed_groups: Vec<String>,
13526 #[serde(default)]
13532 pub bot_name: Option<String>,
13533 #[serde(default = "default_wecom_ws_file_retention_days")]
13535 pub file_retention_days: u32,
13536 #[serde(default = "default_wecom_ws_max_file_size_mb")]
13538 pub max_file_size_mb: u64,
13539 #[serde(default = "default_wecom_ws_stream_mode")]
13541 pub stream_mode: StreamMode,
13542 #[serde(default)]
13544 pub proxy_url: Option<String>,
13545 #[serde(default)]
13548 pub excluded_tools: Vec<String>,
13549}
13550
13551impl Default for WeComWsConfig {
13552 fn default() -> Self {
13553 Self {
13554 enabled: false,
13555 bot_id: String::new(),
13556 secret: String::new(),
13557 allowed_users: Vec::new(),
13558 allowed_groups: Vec::new(),
13559 bot_name: None,
13560 file_retention_days: default_wecom_ws_file_retention_days(),
13561 max_file_size_mb: default_wecom_ws_max_file_size_mb(),
13562 stream_mode: default_wecom_ws_stream_mode(),
13563 proxy_url: None,
13564 excluded_tools: Vec::new(),
13565 }
13566 }
13567}
13568
13569impl ChannelConfig for WeComWsConfig {
13570 fn name() -> &'static str {
13571 "WeCom WebSocket"
13572 }
13573 fn desc() -> &'static str {
13574 "WeCom AI Bot long connection"
13575 }
13576}
13577
13578#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13584#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13585#[prefix = "channels.wechat"]
13586pub struct WeChatConfig {
13587 #[tab(Behavior)]
13592 #[serde(default)]
13593 pub enabled: bool,
13594 #[tab(Advanced)]
13596 #[serde(default)]
13597 pub api_base_url: Option<String>,
13598 #[tab(Advanced)]
13600 #[serde(default)]
13601 pub cdn_base_url: Option<String>,
13602 #[tab(Advanced)]
13605 #[serde(default)]
13606 pub state_dir: Option<String>,
13607
13608 #[tab(Behavior)]
13611 #[serde(default)]
13612 pub excluded_tools: Vec<String>,
13613}
13614
13615impl ChannelConfig for WeChatConfig {
13616 fn name() -> &'static str {
13617 "WeChat"
13618 }
13619 fn desc() -> &'static str {
13620 "WeChat iLink Bot"
13621 }
13622}
13623
13624#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13626#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13627#[prefix = "channels.qq"]
13628pub struct QQConfig {
13629 #[tab(Behavior)]
13634 #[serde(default)]
13635 pub enabled: bool,
13636 #[tab(Connection)]
13638 pub app_id: String,
13639 #[secret]
13641 #[tab(Connection)]
13642 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13643 pub app_secret: String,
13644 #[tab(Advanced)]
13647 #[serde(default)]
13648 pub proxy_url: Option<String>,
13649
13650 #[tab(Behavior)]
13653 #[serde(default)]
13654 pub excluded_tools: Vec<String>,
13655}
13656
13657impl ChannelConfig for QQConfig {
13658 fn name() -> &'static str {
13659 "QQ Official"
13660 }
13661 fn desc() -> &'static str {
13662 "Tencent QQ Bot"
13663 }
13664}
13665
13666#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13668#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13669#[prefix = "channels.twitter"]
13670pub struct TwitterConfig {
13671 #[tab(Behavior)]
13676 #[serde(default)]
13677 pub enabled: bool,
13678 #[secret]
13680 #[tab(Connection)]
13681 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13682 pub bearer_token: String,
13683
13684 #[tab(Behavior)]
13687 #[serde(default)]
13688 pub excluded_tools: Vec<String>,
13689}
13690
13691impl ChannelConfig for TwitterConfig {
13692 fn name() -> &'static str {
13693 "X/Twitter"
13694 }
13695 fn desc() -> &'static str {
13696 "X/Twitter Bot via API v2"
13697 }
13698}
13699
13700#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13702#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13703#[prefix = "channels.mochat"]
13704pub struct MochatConfig {
13705 #[tab(Behavior)]
13710 #[serde(default)]
13711 pub enabled: bool,
13712 #[tab(Advanced)]
13714 pub api_url: String,
13715 #[secret]
13717 #[tab(Connection)]
13718 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13719 pub api_token: String,
13720 #[tab(Advanced)]
13722 #[serde(default = "default_mochat_poll_interval")]
13723 pub poll_interval_secs: u64,
13724
13725 #[tab(Behavior)]
13728 #[serde(default)]
13729 pub excluded_tools: Vec<String>,
13730}
13731
13732fn default_mochat_poll_interval() -> u64 {
13733 5
13734}
13735
13736impl ChannelConfig for MochatConfig {
13737 fn name() -> &'static str {
13738 "Mochat"
13739 }
13740 fn desc() -> &'static str {
13741 "Mochat Customer Service"
13742 }
13743}
13744
13745#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13747#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13748#[prefix = "channels.reddit"]
13749pub struct RedditConfig {
13750 #[tab(Behavior)]
13755 #[serde(default)]
13756 pub enabled: bool,
13757 #[tab(Connection)]
13759 pub client_id: String,
13760 #[secret]
13762 #[tab(Connection)]
13763 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13764 pub client_secret: String,
13765 #[secret]
13767 #[tab(Connection)]
13768 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13769 pub refresh_token: String,
13770 #[tab(Advanced)]
13772 pub username: String,
13773 #[tab(Advanced)]
13777 #[serde(default)]
13778 pub subreddits: Vec<String>,
13779
13780 #[tab(Behavior)]
13783 #[serde(default)]
13784 pub excluded_tools: Vec<String>,
13785}
13786
13787impl ChannelConfig for RedditConfig {
13788 fn name() -> &'static str {
13789 "Reddit"
13790 }
13791 fn desc() -> &'static str {
13792 "Reddit bot (OAuth2)"
13793 }
13794}
13795
13796#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13798#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13799#[prefix = "channels.bluesky"]
13800pub struct BlueskyConfig {
13801 #[tab(Behavior)]
13806 #[serde(default)]
13807 pub enabled: bool,
13808 #[tab(Connection)]
13810 pub handle: String,
13811 #[secret]
13813 #[tab(Connection)]
13814 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13815 pub app_password: String,
13816
13817 #[tab(Behavior)]
13820 #[serde(default)]
13821 pub excluded_tools: Vec<String>,
13822}
13823
13824impl ChannelConfig for BlueskyConfig {
13825 fn name() -> &'static str {
13826 "Bluesky"
13827 }
13828 fn desc() -> &'static str {
13829 "AT Protocol"
13830 }
13831}
13832
13833#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
13838#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13839pub struct VoiceDuplexConfig {
13840 #[serde(default)]
13845 pub enabled: bool,
13846 #[serde(default)]
13849 pub excluded_tools: Vec<String>,
13850}
13851
13852#[derive(Debug, Clone, Serialize, Deserialize, zeroclaw_macros::Configurable)]
13858#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13859#[prefix = "voice_wake"]
13860pub struct VoiceWakeConfig {
13861 #[serde(default)]
13866 pub enabled: bool,
13867 #[serde(default = "default_voice_wake_word")]
13870 pub wake_word: String,
13871 #[serde(default = "default_voice_wake_silence_timeout_ms")]
13874 pub silence_timeout_ms: u32,
13875 #[serde(default = "default_voice_wake_energy_threshold")]
13878 pub energy_threshold: f32,
13879 #[serde(default = "default_voice_wake_max_capture_secs")]
13882 pub max_capture_secs: u32,
13883
13884 #[serde(default)]
13887 pub excluded_tools: Vec<String>,
13888}
13889
13890fn default_voice_wake_word() -> String {
13891 "hey zeroclaw".into()
13892}
13893
13894fn default_voice_wake_silence_timeout_ms() -> u32 {
13895 2000
13896}
13897
13898fn default_voice_wake_energy_threshold() -> f32 {
13899 0.01
13900}
13901
13902fn default_voice_wake_max_capture_secs() -> u32 {
13903 30
13904}
13905
13906impl Default for VoiceWakeConfig {
13907 fn default() -> Self {
13908 Self {
13909 enabled: false,
13910 wake_word: default_voice_wake_word(),
13911 silence_timeout_ms: default_voice_wake_silence_timeout_ms(),
13912 energy_threshold: default_voice_wake_energy_threshold(),
13913 max_capture_secs: default_voice_wake_max_capture_secs(),
13914 excluded_tools: Vec::new(),
13915 }
13916 }
13917}
13918
13919impl ChannelConfig for VoiceWakeConfig {
13920 fn name() -> &'static str {
13921 "VoiceWake"
13922 }
13923 fn desc() -> &'static str {
13924 "voice wake word detection"
13925 }
13926}
13927
13928#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13930#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13931#[prefix = "channels.nostr"]
13932pub struct NostrConfig {
13933 #[tab(Behavior)]
13938 #[serde(default)]
13939 pub enabled: bool,
13940 #[secret]
13942 #[tab(Connection)]
13943 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13944 pub private_key: String,
13945 #[tab(Advanced)]
13947 #[serde(default = "default_nostr_relays")]
13948 pub relays: Vec<String>,
13949
13950 #[tab(Behavior)]
13953 #[serde(default)]
13954 pub excluded_tools: Vec<String>,
13955}
13956
13957impl ChannelConfig for NostrConfig {
13958 fn name() -> &'static str {
13959 "Nostr"
13960 }
13961 fn desc() -> &'static str {
13962 "Nostr DMs"
13963 }
13964}
13965
13966pub fn default_nostr_relays() -> Vec<String> {
13967 vec![
13968 "wss://relay.damus.io".to_string(),
13969 "wss://nos.lol".to_string(),
13970 "wss://relay.primal.net".to_string(),
13971 "wss://relay.snort.social".to_string(),
13972 ]
13973}
13974
13975#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13983#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13984#[prefix = "notion"]
13985pub struct NotionConfig {
13986 #[serde(default)]
13987 pub enabled: bool,
13988 #[serde(default)]
13989 #[secret]
13990 #[credential_class = "encrypted_secret"]
13991 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13992 pub api_key: String,
13993 #[serde(default)]
13994 pub database_id: String,
13995 #[serde(default = "default_notion_poll_interval")]
13996 pub poll_interval_secs: u64,
13997 #[serde(default = "default_notion_status_prop")]
13998 pub status_property: String,
13999 #[serde(default = "default_notion_input_prop")]
14000 pub input_property: String,
14001 #[serde(default = "default_notion_result_prop")]
14002 pub result_property: String,
14003 #[serde(default = "default_notion_max_concurrent")]
14004 pub max_concurrent: usize,
14005 #[serde(default = "default_notion_recover_stale")]
14006 pub recover_stale: bool,
14007}
14008
14009fn default_notion_poll_interval() -> u64 {
14010 5
14011}
14012fn default_notion_status_prop() -> String {
14013 "Status".into()
14014}
14015fn default_notion_input_prop() -> String {
14016 "Input".into()
14017}
14018fn default_notion_result_prop() -> String {
14019 "Result".into()
14020}
14021fn default_notion_max_concurrent() -> usize {
14022 4
14023}
14024fn default_notion_recover_stale() -> bool {
14025 true
14026}
14027
14028impl Default for NotionConfig {
14029 fn default() -> Self {
14030 Self {
14031 enabled: false,
14032 api_key: String::new(),
14033 database_id: String::new(),
14034 poll_interval_secs: default_notion_poll_interval(),
14035 status_property: default_notion_status_prop(),
14036 input_property: default_notion_input_prop(),
14037 result_property: default_notion_result_prop(),
14038 max_concurrent: default_notion_max_concurrent(),
14039 recover_stale: default_notion_recover_stale(),
14040 }
14041 }
14042}
14043
14044#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
14062#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
14063#[prefix = "jira"]
14064pub struct JiraConfig {
14065 #[serde(default)]
14067 pub enabled: bool,
14068 #[serde(default)]
14070 pub base_url: String,
14071 #[serde(
14079 default,
14080 skip_serializing_if = "Option::is_none",
14081 deserialize_with = "deserialize_optional_email_skip_empty"
14082 )]
14083 pub email: Option<String>,
14084 #[serde(default)]
14086 #[secret]
14087 #[credential_class = "encrypted_secret"]
14088 #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
14089 pub api_token: String,
14090 #[serde(default = "default_jira_allowed_actions")]
14096 pub allowed_actions: Vec<String>,
14097 #[serde(default = "default_jira_timeout_secs")]
14099 pub timeout_secs: u64,
14100}
14101
14102fn default_jira_allowed_actions() -> Vec<String> {
14103 vec!["get_ticket".to_string()]
14104}
14105
14106fn default_jira_timeout_secs() -> u64 {
14107 30
14108}
14109
14110impl Default for JiraConfig {
14111 fn default() -> Self {
14112 Self {
14113 enabled: false,
14114 base_url: String::new(),
14115 email: None,
14116 api_token: String::new(),
14117 allowed_actions: default_jira_allowed_actions(),
14118 timeout_secs: default_jira_timeout_secs(),
14119 }
14120 }
14121}
14122
14123#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
14127#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
14128#[prefix = "cloud_ops"]
14129pub struct CloudOpsConfig {
14130 #[serde(default)]
14132 pub enabled: bool,
14133 #[serde(default = "default_cloud_ops_cloud")]
14135 pub default_cloud: String,
14136 #[serde(default = "default_cloud_ops_supported_clouds")]
14138 pub supported_clouds: Vec<String>,
14139 #[serde(default = "default_cloud_ops_iac_tools")]
14141 pub iac_tools: Vec<String>,
14142 #[serde(default = "default_cloud_ops_cost_threshold")]
14144 pub cost_threshold_monthly_usd: f64,
14145 #[serde(default = "default_cloud_ops_waf")]
14147 pub well_architected_frameworks: Vec<String>,
14148}
14149
14150impl Default for CloudOpsConfig {
14151 fn default() -> Self {
14152 Self {
14153 enabled: false,
14154 default_cloud: default_cloud_ops_cloud(),
14155 supported_clouds: default_cloud_ops_supported_clouds(),
14156 iac_tools: default_cloud_ops_iac_tools(),
14157 cost_threshold_monthly_usd: default_cloud_ops_cost_threshold(),
14158 well_architected_frameworks: default_cloud_ops_waf(),
14159 }
14160 }
14161}
14162
14163impl CloudOpsConfig {
14164 pub fn validate(&self) -> Result<()> {
14165 if self.enabled {
14166 if self.default_cloud.trim().is_empty() {
14167 anyhow::bail!(
14168 "cloud_ops.default_cloud must not be empty when cloud_ops is enabled"
14169 );
14170 }
14171 if self.supported_clouds.is_empty() {
14172 anyhow::bail!(
14173 "cloud_ops.supported_clouds must not be empty when cloud_ops is enabled"
14174 );
14175 }
14176 for (i, cloud) in self.supported_clouds.iter().enumerate() {
14177 if cloud.trim().is_empty() {
14178 validation_bail!(
14179 RequiredFieldEmpty,
14180 format!("cloud_ops.supported_clouds[{i}]"),
14181 "cloud_ops.supported_clouds[{i}] must not be empty"
14182 );
14183 }
14184 }
14185 if !self.supported_clouds.contains(&self.default_cloud) {
14186 anyhow::bail!(
14187 "cloud_ops.default_cloud '{}' is not in cloud_ops.supported_clouds {:?}",
14188 self.default_cloud,
14189 self.supported_clouds
14190 );
14191 }
14192 if self.cost_threshold_monthly_usd < 0.0 {
14193 anyhow::bail!(
14194 "cloud_ops.cost_threshold_monthly_usd must be non-negative, got {}",
14195 self.cost_threshold_monthly_usd
14196 );
14197 }
14198 if self.iac_tools.is_empty() {
14199 anyhow::bail!("cloud_ops.iac_tools must not be empty when cloud_ops is enabled");
14200 }
14201 }
14202 Ok(())
14203 }
14204}
14205
14206fn default_cloud_ops_cloud() -> String {
14207 "aws".into()
14208}
14209
14210fn default_cloud_ops_supported_clouds() -> Vec<String> {
14211 vec!["aws".into(), "azure".into(), "gcp".into()]
14212}
14213
14214fn default_cloud_ops_iac_tools() -> Vec<String> {
14215 vec!["terraform".into()]
14216}
14217
14218fn default_cloud_ops_cost_threshold() -> f64 {
14219 100.0
14220}
14221
14222fn default_cloud_ops_waf() -> Vec<String> {
14223 vec!["aws-waf".into()]
14224}
14225
14226fn default_conversational_ai_language() -> String {
14229 "en".into()
14230}
14231
14232fn default_conversational_ai_supported_languages() -> Vec<String> {
14233 vec!["en".into(), "de".into(), "fr".into(), "it".into()]
14234}
14235
14236fn default_conversational_ai_escalation_threshold() -> f64 {
14237 0.3
14238}
14239
14240fn default_conversational_ai_max_turns() -> usize {
14241 50
14242}
14243
14244fn default_conversational_ai_timeout_secs() -> u64 {
14245 1800
14246}
14247
14248#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
14253#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
14254#[prefix = "conversational_ai"]
14255pub struct ConversationalAiConfig {
14256 #[serde(default)]
14258 pub enabled: bool,
14259 #[serde(default = "default_conversational_ai_language")]
14261 pub default_language: String,
14262 #[serde(default = "default_conversational_ai_supported_languages")]
14264 pub supported_languages: Vec<String>,
14265 #[serde(default = "default_true")]
14267 pub auto_detect_language: bool,
14268 #[serde(default = "default_conversational_ai_escalation_threshold")]
14270 pub escalation_confidence_threshold: f64,
14271 #[serde(default = "default_conversational_ai_max_turns")]
14273 pub max_conversation_turns: usize,
14274 #[serde(default = "default_conversational_ai_timeout_secs")]
14276 pub conversation_timeout_secs: u64,
14277 #[serde(default)]
14279 pub analytics_enabled: bool,
14280 #[serde(default)]
14282 pub knowledge_base_tool: Option<String>,
14283}
14284
14285impl ConversationalAiConfig {
14286 pub fn is_disabled(&self) -> bool {
14292 !self.enabled
14293 }
14294}
14295
14296impl Default for ConversationalAiConfig {
14297 fn default() -> Self {
14298 Self {
14299 enabled: false,
14300 default_language: default_conversational_ai_language(),
14301 supported_languages: default_conversational_ai_supported_languages(),
14302 auto_detect_language: true,
14303 escalation_confidence_threshold: default_conversational_ai_escalation_threshold(),
14304 max_conversation_turns: default_conversational_ai_max_turns(),
14305 conversation_timeout_secs: default_conversational_ai_timeout_secs(),
14306 analytics_enabled: false,
14307 knowledge_base_tool: None,
14308 }
14309 }
14310}
14311
14312#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
14316#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
14317#[prefix = "security_ops"]
14318pub struct SecurityOpsConfig {
14319 #[serde(default)]
14321 pub enabled: bool,
14322 #[serde(default = "default_playbooks_dir")]
14324 pub playbooks_dir: String,
14325 #[serde(default)]
14327 pub auto_triage: bool,
14328 #[serde(default = "default_require_approval")]
14330 pub require_approval_for_actions: bool,
14331 #[serde(default = "default_max_auto_severity")]
14334 pub max_auto_severity: String,
14335 #[serde(default = "default_report_output_dir")]
14337 pub report_output_dir: String,
14338 #[serde(default)]
14340 pub siem_integration: Option<String>,
14341}
14342
14343fn default_playbooks_dir() -> String {
14344 default_path_under_config_dir("playbooks")
14345}
14346
14347fn default_require_approval() -> bool {
14348 true
14349}
14350
14351fn default_max_auto_severity() -> String {
14352 "low".into()
14353}
14354
14355fn default_report_output_dir() -> String {
14356 default_path_under_config_dir("security-reports")
14357}
14358
14359impl Default for SecurityOpsConfig {
14360 fn default() -> Self {
14361 Self {
14362 enabled: false,
14363 playbooks_dir: default_playbooks_dir(),
14364 auto_triage: false,
14365 require_approval_for_actions: true,
14366 max_auto_severity: default_max_auto_severity(),
14367 report_output_dir: default_report_output_dir(),
14368 siem_integration: None,
14369 }
14370 }
14371}
14372
14373impl Default for Config {
14376 fn default() -> Self {
14377 let home =
14378 UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
14379 let zeroclaw_dir = home.join(".zeroclaw");
14380
14381 Self {
14382 data_dir: zeroclaw_dir.join("data"),
14383 config_path: zeroclaw_dir.join("config.toml"),
14384 env_overridden_paths: std::collections::HashSet::new(),
14385 pre_override_snapshots: std::collections::HashMap::new(),
14386 onepassword_reference_snapshots: std::collections::HashMap::new(),
14387 dirty_paths: std::collections::HashSet::new(),
14388 degraded_security: Vec::new(),
14389 schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
14390 providers: crate::providers::Providers::default(),
14391 model_routes: Vec::new(),
14392 embedding_routes: Vec::new(),
14393 observability: ObservabilityConfig::default(),
14394 trust: crate::scattered_types::TrustConfig::default(),
14395 backup: BackupConfig::default(),
14396 data_retention: DataRetentionConfig::default(),
14397 cloud_ops: CloudOpsConfig::default(),
14398 conversational_ai: ConversationalAiConfig::default(),
14399 security: SecurityConfig::default(),
14400 security_ops: SecurityOpsConfig::default(),
14401 runtime: RuntimeConfig::default(),
14402 reliability: ReliabilityConfig::default(),
14403 scheduler: SchedulerConfig::default(),
14404 pacing: PacingConfig::default(),
14405 skills: SkillsConfig::default(),
14406 pipeline: PipelineConfig::default(),
14407 heartbeat: HeartbeatConfig::default(),
14408 cron: HashMap::new(),
14409 acp: AcpConfig::default(),
14410 channels: ChannelsConfig::default(),
14411 memory: MemoryConfig::default(),
14412 storage: StorageConfig::default(),
14413 tunnel: TunnelConfig::default(),
14414 gateway: GatewayConfig::default(),
14415 wss: WssConfig::default(),
14416 composio: ComposioConfig::default(),
14417 microsoft365: Microsoft365Config::default(),
14418 secrets: SecretsConfig::default(),
14419 browser: BrowserConfig::default(),
14420 browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
14421 http_request: HttpRequestConfig::default(),
14422 multimodal: MultimodalConfig::default(),
14423 media_pipeline: MediaPipelineConfig::default(),
14424 web_fetch: WebFetchConfig::default(),
14425 link_enricher: LinkEnricherConfig::default(),
14426 text_browser: TextBrowserConfig::default(),
14427 web_search: WebSearchConfig::default(),
14428 project_intel: ProjectIntelConfig::default(),
14429 google_workspace: GoogleWorkspaceConfig::default(),
14430 proxy: ProxyConfig::default(),
14431 cost: CostConfig::default(),
14432 peripherals: PeripheralsConfig::default(),
14433 delegate: DelegateToolConfig::default(),
14434 agents: HashMap::new(),
14435 risk_profiles: HashMap::new(),
14436 runtime_profiles: HashMap::new(),
14437 skill_bundles: HashMap::new(),
14438 knowledge_bundles: HashMap::new(),
14439 mcp_bundles: HashMap::new(),
14440 peer_groups: HashMap::new(),
14441 hooks: HooksConfig::default(),
14442 hardware: HardwareConfig::default(),
14443 query_classification: QueryClassificationConfig::default(),
14444 transcription: TranscriptionConfig::default(),
14445 tts: TtsConfig::default(),
14446 mcp: McpConfig::default(),
14447 nodes: NodesConfig::default(),
14448 onboard_state: OnboardStateConfig::default(),
14449 notion: NotionConfig::default(),
14450 jira: JiraConfig::default(),
14451 node_transport: NodeTransportConfig::default(),
14452 knowledge: KnowledgeConfig::default(),
14453 linkedin: LinkedInConfig::default(),
14454 image_gen: ImageGenConfig::default(),
14455 file_upload: FileUploadConfig::default(),
14456 file_upload_bundle: FileUploadBundleConfig::default(),
14457 file_download: FileDownloadConfig::default(),
14458 plugins: PluginsConfig::default(),
14459 locale: None,
14460 verifiable_intent: VerifiableIntentConfig::default(),
14461 claude_code: ClaudeCodeConfig::default(),
14462 claude_code_runner: ClaudeCodeRunnerConfig::default(),
14463 codex_cli: CodexCliConfig::default(),
14464 gemini_cli: GeminiCliConfig::default(),
14465 opencode_cli: OpenCodeCliConfig::default(),
14466 sop: SopConfig::default(),
14467 shell_tool: ShellToolConfig::default(),
14468 escalation: EscalationConfig::default(),
14469 }
14470 }
14471}
14472
14473fn default_config_and_data_dirs() -> Result<(PathBuf, PathBuf)> {
14474 let config_dir = default_config_dir()?;
14475 Ok((config_dir.clone(), config_dir.join("data")))
14480}
14481
14482fn default_config_dir() -> Result<PathBuf> {
14483 if let Ok(custom) = std::env::var("ZEROCLAW_CONFIG_DIR") {
14484 let custom = custom.trim();
14485 if !custom.is_empty() {
14486 return Ok(expand_tilde_path(custom));
14487 }
14488 }
14489
14490 if let Ok(home) = std::env::var("HOME")
14491 && !home.is_empty()
14492 {
14493 return Ok(PathBuf::from(home).join(".zeroclaw"));
14494 }
14495
14496 let home = UserDirs::new()
14497 .map(|u| u.home_dir().to_path_buf())
14498 .context("Could not find home directory")?;
14499 Ok(home.join(".zeroclaw"))
14500}
14501
14502pub fn ftl_locale_dir(locale: &str) -> Result<PathBuf> {
14509 Ok(default_config_dir()?.join("data").join("ftl").join(locale))
14510}
14511
14512pub const FTL_CATALOGS: &[(&str, &str, &str)] = &[
14518 (
14519 "cli",
14520 "crates/zeroclaw-runtime/locales/{locale}/cli.ftl",
14521 "cli.ftl",
14522 ),
14523 (
14524 "tools",
14525 "crates/zeroclaw-runtime/locales/{locale}/tools.ftl",
14526 "tools.ftl",
14527 ),
14528 (
14529 "zerocode",
14530 "apps/zerocode/locales/{locale}/zerocode.ftl",
14531 "zerocode.ftl",
14532 ),
14533];
14534
14535fn default_path_under_config_dir(relative: &str) -> String {
14549 match default_config_dir() {
14550 Ok(dir) => dir.join(relative).to_string_lossy().into_owned(),
14551 Err(_) => format!("~/.zeroclaw/{relative}"),
14552 }
14553}
14554
14555pub fn resolve_config_dir_for_data(data_dir: &Path) -> (PathBuf, PathBuf) {
14556 let data_config_dir = data_dir.to_path_buf();
14557 if data_config_dir.join("config.toml").exists() {
14558 return (data_config_dir.clone(), data_config_dir.join("data"));
14559 }
14560
14561 let legacy_config_dir = data_dir.parent().map(|parent| parent.join(".zeroclaw"));
14562 if let Some(legacy_dir) = legacy_config_dir {
14563 if legacy_dir.join("config.toml").exists() {
14564 return (legacy_dir, data_config_dir);
14565 }
14566
14567 if data_dir.file_name().is_some_and(|name| {
14572 name == std::ffi::OsStr::new("data") || name == std::ffi::OsStr::new("workspace")
14573 }) {
14574 return (legacy_dir, data_config_dir);
14575 }
14576 }
14577
14578 (data_config_dir.clone(), data_config_dir.join("data"))
14579}
14580
14581pub async fn resolve_runtime_dirs() -> Result<(PathBuf, PathBuf)> {
14587 let (default_zeroclaw_dir, default_data_dir) = default_config_and_data_dirs()?;
14588 let (config_dir, data_dir, _) =
14589 resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_data_dir).await?;
14590 Ok((config_dir, data_dir))
14591}
14592
14593#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14594enum ConfigResolutionSource {
14595 EnvConfigDir,
14596 EnvDataDir,
14597 EnvWorkspaceLegacy,
14598 DefaultConfigDir,
14599 HomebrewConfigDir,
14600}
14601
14602impl ConfigResolutionSource {
14603 const fn as_str(self) -> &'static str {
14604 match self {
14605 Self::EnvConfigDir => "ZEROCLAW_CONFIG_DIR",
14606 Self::EnvDataDir => "ZEROCLAW_DATA_DIR",
14607 Self::EnvWorkspaceLegacy => "ZEROCLAW_WORKSPACE",
14608 Self::DefaultConfigDir => "default",
14609 Self::HomebrewConfigDir => "homebrew",
14610 }
14611 }
14612}
14613
14614fn expand_tilde_path(path: &str) -> PathBuf {
14620 let expanded = shellexpand::tilde(path);
14621 let expanded_str = expanded.as_ref();
14622
14623 if expanded_str.starts_with('~') {
14625 if let Some(user_dirs) = UserDirs::new() {
14626 let home = user_dirs.home_dir();
14627 if let Some(rest) = expanded_str.strip_prefix('~') {
14629 return home.join(rest.trim_start_matches(['/', '\\']));
14630 }
14631 }
14632 ::zeroclaw_log::record!(
14634 WARN,
14635 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14636 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
14637 .with_attrs(::serde_json::json!({"path": path})),
14638 "Failed to expand tilde: HOME environment variable is not set and UserDirs failed. \
14639 In cron/non-TTY environments, use absolute paths or set HOME explicitly."
14640 );
14641 }
14642
14643 PathBuf::from(expanded_str)
14644}
14645
14646async fn try_resolve_macos_homebrew_config_dir(exe: &Path) -> Option<PathBuf> {
14652 let parts = exe.iter().collect::<Vec<_>>();
14653 let prefix = match parts.as_slice() {
14654 [prefix @ .., cellar, formula, _version, bin, exe_name]
14655 if *cellar == std::ffi::OsStr::new("Cellar")
14656 && *formula == std::ffi::OsStr::new("zeroclaw")
14657 && *bin == std::ffi::OsStr::new("bin")
14658 && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
14659 {
14660 prefix.iter().collect::<PathBuf>()
14661 }
14662 [prefix @ .., opt, formula, bin, exe_name]
14663 if *opt == std::ffi::OsStr::new("opt")
14664 && *formula == std::ffi::OsStr::new("zeroclaw")
14665 && *bin == std::ffi::OsStr::new("bin")
14666 && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
14667 {
14668 let prefix = prefix.iter().collect::<PathBuf>();
14669 if !prefix.as_os_str().is_empty()
14670 && fs::metadata(prefix.join("Cellar"))
14671 .await
14672 .is_ok_and(|metadata| metadata.is_dir())
14673 {
14674 prefix
14675 } else {
14676 return None;
14677 }
14678 }
14679 [prefix @ .., bin, exe_name]
14680 if *bin == std::ffi::OsStr::new("bin")
14681 && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
14682 {
14683 let prefix = prefix.iter().collect::<PathBuf>();
14684 if !prefix.as_os_str().is_empty()
14685 && fs::metadata(prefix.join("Cellar"))
14686 .await
14687 .is_ok_and(|metadata| metadata.is_dir())
14688 {
14689 prefix
14690 } else {
14691 return None;
14692 }
14693 }
14694 _ => return None,
14695 };
14696 Some(prefix.join("var").join("zeroclaw"))
14697}
14698
14699async fn resolve_runtime_config_dirs(
14700 default_zeroclaw_dir: &Path,
14701 default_data_dir: &Path,
14702) -> Result<(PathBuf, PathBuf, ConfigResolutionSource)> {
14703 if let Ok(custom_config_dir) = std::env::var("ZEROCLAW_CONFIG_DIR") {
14704 let custom_config_dir = custom_config_dir.trim();
14705 if !custom_config_dir.is_empty() {
14706 if std::env::var("ZEROCLAW_DATA_DIR")
14710 .ok()
14711 .filter(|v| !v.trim().is_empty())
14712 .is_some()
14713 {
14714 ::zeroclaw_log::record!(
14715 WARN,
14716 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14717 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
14718 "ZEROCLAW_CONFIG_DIR is set; ZEROCLAW_DATA_DIR is ignored \
14719 (CONFIG_DIR pins both the config directory and the data \
14720 directory under it)."
14721 );
14722 }
14723 if std::env::var("ZEROCLAW_WORKSPACE")
14724 .ok()
14725 .filter(|v| !v.is_empty())
14726 .is_some()
14727 {
14728 ::zeroclaw_log::record!(
14729 WARN,
14730 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14731 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
14732 "ZEROCLAW_CONFIG_DIR is set; ZEROCLAW_WORKSPACE (deprecated) \
14733 is ignored. ZEROCLAW_WORKSPACE will be removed in a future \
14734 release; switch any remaining references to ZEROCLAW_DATA_DIR."
14735 );
14736 }
14737 let zeroclaw_dir = expand_tilde_path(custom_config_dir);
14738 return Ok((
14739 zeroclaw_dir.clone(),
14740 zeroclaw_dir.join("data"),
14741 ConfigResolutionSource::EnvConfigDir,
14742 ));
14743 }
14744 }
14745
14746 if let Ok(custom_data) = std::env::var("ZEROCLAW_DATA_DIR")
14747 && !custom_data.trim().is_empty()
14748 {
14749 if std::env::var("ZEROCLAW_WORKSPACE")
14750 .ok()
14751 .filter(|v| !v.is_empty())
14752 .is_some()
14753 {
14754 ::zeroclaw_log::record!(
14755 WARN,
14756 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14757 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
14758 "ZEROCLAW_DATA_DIR and ZEROCLAW_WORKSPACE are both set; \
14759 ZEROCLAW_WORKSPACE (deprecated) is ignored. \
14760 ZEROCLAW_WORKSPACE will be removed in a future release."
14761 );
14762 }
14763 let expanded = expand_tilde_path(&custom_data);
14764 let (zeroclaw_dir, data_dir) = resolve_config_dir_for_data(&expanded);
14765 return Ok((zeroclaw_dir, data_dir, ConfigResolutionSource::EnvDataDir));
14766 }
14767
14768 if let Ok(custom_workspace) = std::env::var("ZEROCLAW_WORKSPACE")
14769 && !custom_workspace.is_empty()
14770 {
14771 ::zeroclaw_log::record!(
14772 WARN,
14773 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14774 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
14775 "ZEROCLAW_WORKSPACE is deprecated; use ZEROCLAW_DATA_DIR instead. \
14776 ZEROCLAW_WORKSPACE will be removed in a future release."
14777 );
14778 let expanded = expand_tilde_path(&custom_workspace);
14779 let (zeroclaw_dir, data_dir) = resolve_config_dir_for_data(&expanded);
14780 return Ok((
14781 zeroclaw_dir,
14782 data_dir,
14783 ConfigResolutionSource::EnvWorkspaceLegacy,
14784 ));
14785 }
14786
14787 if cfg!(target_os = "macos")
14788 && let Ok(exe) = std::env::current_exe()
14789 && let Some(homebrew_config_dir) = try_resolve_macos_homebrew_config_dir(&exe).await
14790 {
14791 return Ok((
14792 homebrew_config_dir.clone(),
14793 homebrew_config_dir.join("workspace"),
14794 ConfigResolutionSource::HomebrewConfigDir,
14795 ));
14796 }
14797
14798 Ok((
14799 default_zeroclaw_dir.to_path_buf(),
14800 default_data_dir.to_path_buf(),
14801 ConfigResolutionSource::DefaultConfigDir,
14802 ))
14803}
14804
14805fn config_dir_creation_error(path: &Path) -> String {
14806 format!(
14807 "Failed to create config directory: {}. If running as an OpenRC service, \
14808 ensure this path is writable by user 'zeroclaw'.",
14809 path.display()
14810 )
14811}
14812
14813const SAVE_PRESERVE_KEYS: &[&str] = &["schema_version"];
14819
14820fn ensure_blank_line_before_sections(toml: &str) -> String {
14826 let mut out = String::with_capacity(toml.len() + 64);
14827 let mut prev_line_blank = true; for line in toml.lines() {
14829 let is_section_header = line.starts_with('[');
14830 if is_section_header && !prev_line_blank {
14831 out.push('\n');
14832 }
14833 out.push_str(line);
14834 out.push('\n');
14835 prev_line_blank = line.trim().is_empty();
14836 }
14837 out
14838}
14839
14840fn prune_default_values(actual: &mut toml::Table, defaults: &toml::Table) {
14850 let keys: Vec<String> = actual.keys().cloned().collect();
14851 for key in keys {
14852 if SAVE_PRESERVE_KEYS.contains(&key.as_str()) {
14853 continue;
14854 }
14855 let Some(default_value) = defaults.get(&key) else {
14856 continue;
14860 };
14861 let Some(child) = actual.remove(&key) else {
14862 continue;
14863 };
14864 let pruned = match (child, default_value) {
14865 (toml::Value::Table(mut child_table), toml::Value::Table(default_subtable)) => {
14866 prune_default_values(&mut child_table, default_subtable);
14867 if child_table.is_empty() {
14868 None
14869 } else {
14870 Some(toml::Value::Table(child_table))
14871 }
14872 }
14873 (child, default_value) => {
14874 if &child == default_value {
14875 None
14876 } else {
14877 Some(child)
14878 }
14879 }
14880 };
14881 if let Some(value) = pruned {
14882 actual.insert(key, value);
14883 }
14884 }
14885}
14886
14887fn is_local_ollama_endpoint(api_url: Option<&str>) -> bool {
14888 let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
14889 return true;
14890 };
14891
14892 reqwest::Url::parse(raw)
14893 .ok()
14894 .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
14895 .is_some_and(|host| matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1" | "0.0.0.0"))
14896}
14897
14898fn is_official_ollama_cloud_endpoint(api_url: Option<&str>) -> bool {
14899 let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
14900 return false;
14901 };
14902
14903 reqwest::Url::parse(raw)
14904 .ok()
14905 .and_then(|url| {
14906 url.host_str().map(|host| {
14907 host.eq_ignore_ascii_case("ollama.com")
14908 || host.eq_ignore_ascii_case("api.ollama.com")
14909 })
14910 })
14911 .unwrap_or(false)
14912}
14913
14914fn has_ollama_cloud_credential(config_api_key: Option<&str>) -> bool {
14915 config_api_key
14916 .map(str::trim)
14917 .is_some_and(|value| !value.is_empty())
14918}
14919
14920pub async fn ensure_bootstrap_files(workspace_dir: &Path) -> Result<()> {
14926 let defaults: &[(&str, &str)] = &[
14927 (
14928 "IDENTITY.md",
14929 "# IDENTITY.md — Who Am I?\n\n\
14930 I am ZeroClaw, an autonomous AI agent.\n\n\
14931 ## Traits\n\
14932 - Helpful, precise, and safety-conscious\n\
14933 - I prioritize clarity and correctness\n",
14934 ),
14935 (
14936 "SOUL.md",
14937 "# SOUL.md — Who You Are\n\n\
14938 You are ZeroClaw, an autonomous AI agent.\n\n\
14939 ## Core Principles\n\
14940 - Be helpful and accurate\n\
14941 - Respect user intent and boundaries\n\
14942 - Ask before taking destructive actions\n\
14943 - Prefer safe, reversible operations\n",
14944 ),
14945 ];
14946
14947 for (filename, content) in defaults {
14948 let path = workspace_dir.join(filename);
14949 if !path.exists() {
14950 fs::write(&path, content)
14951 .await
14952 .with_context(|| format!("Failed to create default {filename} in workspace"))?;
14953 }
14954 }
14955
14956 Ok(())
14957}
14958
14959impl Config {
14960 pub fn channel_external_peers(&self, channel_type: &str, alias: &str) -> Vec<String> {
14967 let mut out: Vec<String> = Vec::new();
14968 let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
14969 for group in self.peer_groups.values() {
14970 let group_matches = match group.channel.split_once('.') {
14971 Some((ty, al)) => ty == channel_type && al == alias,
14972 None => group.channel == channel_type,
14973 };
14974 if !group_matches {
14975 continue;
14976 }
14977 for peer in &group.external_peers {
14978 let username = peer.as_str().to_string();
14979 if seen.insert(username.clone()) {
14980 out.push(username);
14981 }
14982 }
14983 }
14984 out
14985 }
14986
14987 pub fn integration_descriptors(&self) -> Vec<crate::config::IntegrationDescriptor> {
14993 vec![
15001 self.browser.integration_descriptor(),
15002 self.google_workspace.integration_descriptor(),
15003 crate::config::IntegrationDescriptor {
15004 display_name: "Cron",
15005 description: "Scheduled tasks",
15006 category: "ToolsAutomation",
15007 active: !self.cron.is_empty(),
15008 },
15009 ]
15010 }
15011
15012 pub fn unknown_keys(raw_toml: &str) -> Vec<String> {
15020 let raw: toml::Table = match raw_toml.parse() {
15021 Ok(t) => t,
15022 Err(_) => return Vec::new(),
15023 };
15024 static DEFAULTS: OnceLock<toml::Table> = OnceLock::new();
15025 let defaults = DEFAULTS.get_or_init(|| {
15026 toml::to_string(&Config::default())
15027 .ok()
15028 .and_then(|s| s.parse().ok())
15029 .unwrap_or_default()
15030 });
15031 raw.keys()
15032 .filter(|key| {
15033 if defaults.contains_key(key.as_str()) {
15034 return false;
15035 }
15036 if crate::migration::V1_LEGACY_KEYS.contains(&key.as_str()) {
15037 return false;
15038 }
15039 let mut t = toml::Table::new();
15040 t.insert((*key).clone(), raw[key.as_str()].clone());
15041 let consumed = toml::to_string(&t)
15042 .ok()
15043 .and_then(|s| toml::from_str::<Config>(&s).ok())
15044 .and_then(|c| toml::to_string(&c).ok())
15045 .and_then(|s| s.parse::<toml::Table>().ok())
15046 .is_some_and(|t| t != *defaults);
15047 !consumed
15048 })
15049 .cloned()
15050 .collect()
15051 }
15052
15053 pub fn unknown_provider_families(raw_toml: &str) -> Vec<String> {
15060 let raw: toml::Table = match raw_toml.parse() {
15061 Ok(t) => t,
15062 Err(_) => return Vec::new(),
15063 };
15064 let Some(kinds) = raw.get("providers").and_then(toml::Value::as_table) else {
15065 return Vec::new();
15066 };
15067 let kind_slots: &[(&str, &[&str])] = &[
15068 ("models", crate::providers::ModelProviders::slot_names()),
15069 ("tts", crate::providers::TtsProviders::slot_names()),
15070 (
15071 "transcription",
15072 crate::providers::TranscriptionProviders::slot_names(),
15073 ),
15074 ];
15075 let mut out = Vec::new();
15076 for (kind, slots) in kind_slots {
15077 let Some(families) = kinds.get(*kind).and_then(toml::Value::as_table) else {
15078 continue;
15079 };
15080 out.extend(
15081 families
15082 .keys()
15083 .filter(|k| !slots.contains(&k.as_str()))
15084 .map(|k| format!("{kind}.{k}")),
15085 );
15086 }
15087 out
15088 }
15089
15090 pub fn prop_is_env_overridden(&self, path: &str) -> bool {
15094 self.env_overridden_paths.contains(path)
15095 }
15096
15097 pub async fn load_or_init() -> Result<Self> {
15098 let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_data_dirs()?;
15099
15100 let (zeroclaw_dir, _legacy_workspace_dir, resolution_source) =
15106 resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
15107
15108 let config_toml_path = zeroclaw_dir.join("config.toml");
15121 let needs_fs_migration = config_toml_path.is_file()
15122 && matches!(
15123 std::fs::read_to_string(&config_toml_path)
15124 .ok()
15125 .and_then(|raw| toml::from_str::<toml::Value>(&raw).ok())
15126 .and_then(|v| crate::migration::detect_version(&v).ok()),
15127 Some(v) if v < crate::migration::CURRENT_SCHEMA_VERSION
15128 );
15129 if needs_fs_migration
15130 && let Err(e) = crate::schema::v2::migrate_v2_to_v3_install_filesystem(&zeroclaw_dir)
15131 {
15132 ::zeroclaw_log::record!(
15133 WARN,
15134 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15135 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15136 .with_attrs(::serde_json::json!({
15137 "install": zeroclaw_dir.display().to_string(),
15138 "error": format!("{}", e),
15139 })),
15140 "[system] filesystem migration failed; continuing with legacy layout"
15141 );
15142 } else if !needs_fs_migration
15143 && let Err(e) =
15144 crate::schema::v2::relocate_default_agent_skills_to_shared(&zeroclaw_dir)
15145 {
15146 ::zeroclaw_log::record!(
15147 WARN,
15148 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15149 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15150 .with_attrs(::serde_json::json!({
15151 "install": zeroclaw_dir.display().to_string(),
15152 "error": format!("{}", e),
15153 })),
15154 "[system] skills relocation to shared workspace failed; continuing"
15155 );
15156 }
15157
15158 let config_path = zeroclaw_dir.join("config.toml");
15159
15160 let data_dir = zeroclaw_dir.join("data");
15178 fs::create_dir_all(&data_dir).await.with_context(|| {
15179 format!(
15180 "Failed to create data directory: {}",
15181 data_dir.display().to_string()
15182 )
15183 })?;
15184 let workspace_dir = data_dir;
15187
15188 let shared_dir = zeroclaw_dir.join("shared");
15193 fs::create_dir_all(&shared_dir).await.with_context(|| {
15194 format!(
15195 "Failed to create shared workspace directory: {}",
15196 shared_dir.display()
15197 )
15198 })?;
15199
15200 fs::create_dir_all(&zeroclaw_dir)
15201 .await
15202 .with_context(|| config_dir_creation_error(&zeroclaw_dir))?;
15203
15204 if config_path.exists() {
15205 #[cfg(unix)]
15207 {
15208 use std::os::unix::fs::PermissionsExt;
15209 if let Ok(meta) = fs::metadata(&config_path).await
15210 && meta.permissions().mode() & 0o004 != 0
15211 {
15212 ::zeroclaw_log::record!(
15213 WARN,
15214 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15215 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
15216 &format!(
15217 "Config file {:?} is world-readable (mode {:o}). \
15218 Consider restricting with: chmod 600 {:?}",
15219 config_path,
15220 meta.permissions().mode() & 0o777,
15221 config_path
15222 )
15223 );
15224 }
15225 }
15226
15227 let contents = fs::read_to_string(&config_path)
15228 .await
15229 .context("Failed to read config file")?;
15230
15231 let stale_version = toml::from_str::<toml::Value>(&contents)
15253 .ok()
15254 .as_ref()
15255 .and_then(|v| crate::migration::detect_version(v).ok())
15256 .filter(|n| *n != crate::migration::CURRENT_SCHEMA_VERSION);
15257 let salvage = crate::migration::migrate_to_current_salvaged(&contents);
15263 let mut config: Config = salvage.config;
15264 config.degraded_security = salvage.dropped_security;
15265 if let Some(from_version) = stale_version {
15266 ::zeroclaw_log::record!(
15267 WARN,
15268 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15269 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
15270 &format!(
15271 "Config at {} is schema_version {from_version}; auto-migrated to {} in memory. \
15272 Run `zeroclaw config migrate` to commit the migration to disk. \
15273 V0.8.0 also replaced the env-var override grammar; see \
15274 https://github.com/zeroclaw-labs/zeroclaw/blob/master/docs/book/src/reference/env-vars.md \
15275 for the migration recipes.",
15276 config_path.display().to_string(),
15277 crate::migration::CURRENT_SCHEMA_VERSION
15278 )
15279 );
15280 }
15281
15282 if let Some(default_profile) = config.risk_profiles.get_mut("default") {
15302 default_profile.ensure_default_auto_approve();
15303 }
15304
15305 for key in Self::unknown_keys(&contents) {
15310 ::zeroclaw_log::record!(
15311 WARN,
15312 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15313 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15314 .with_attrs(::serde_json::json!({"key": key})),
15315 "Unknown config key ignored: \"\". Check config.toml for typos or deprecated options."
15316 );
15317 }
15318 for entry in Self::unknown_provider_families(&contents) {
15322 let (kind, family) = entry.split_once('.').unwrap_or(("models", entry.as_str()));
15323 let reference = if kind == "models" {
15324 "any agents.*.model_provider referencing them will fail to resolve; \
15325 run `zeroclaw providers` for valid family names"
15326 } else {
15327 "references to its aliases will fail to resolve"
15328 };
15329 ::zeroclaw_log::record!(
15330 WARN,
15331 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15332 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15333 .with_attrs(::serde_json::json!({"kind": kind, "family": family})),
15334 &format!(
15335 "[providers.{kind}.{family}] section dropped: not a known {kind} \
15336 provider family. Its aliases will not load and {reference}."
15337 )
15338 );
15339 }
15340 config.config_path = config_path.clone();
15342 config.data_dir = workspace_dir;
15343
15344 let install_root = config.install_root_dir();
15348 for alias in config.skill_bundles.keys().cloned().collect::<Vec<_>>() {
15349 if let Ok(dir) =
15350 crate::skill_bundles::resolve_directory(&config, &install_root, &alias)
15351 && let Err(e) = std::fs::create_dir_all(&dir)
15352 {
15353 ::zeroclaw_log::record!(
15354 WARN,
15355 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15356 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
15357 &format!(
15358 "skill-bundle '{alias}' directory creation failed at {}: {e}",
15359 dir.display().to_string()
15360 )
15361 );
15362 }
15363 }
15364
15365 let store = crate::secrets::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt);
15366 config.onepassword_reference_snapshots =
15367 collect_onepassword_reference_snapshots(&config);
15368 config = tokio::task::spawn_blocking(move || {
15370 config.decrypt_secrets(&store)?;
15371 Ok::<_, anyhow::Error>(config)
15372 })
15373 .await
15374 .context("Config secret decryption task failed")??;
15375
15376 let applied = crate::env_overrides::apply_env_overrides(&mut config)?;
15381 config.env_overridden_paths = applied.paths;
15382 config.pre_override_snapshots = applied.snapshots;
15383
15384 if let Err(e) = config.validate() {
15391 ::zeroclaw_log::record!(
15392 WARN,
15393 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15394 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15395 .with_attrs(::serde_json::json!({"error": format!("{e:#}")})),
15396 "[system] config has validation errors — booting anyway so you \
15397 can fix them via /config or `zeroclaw config set`"
15398 );
15399 }
15400 ::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");
15401 Ok(config)
15402 } else {
15403 let mut config = Config {
15404 config_path: config_path.clone(),
15405 data_dir: workspace_dir,
15406 ..Config::default()
15407 };
15408 config.save().await?;
15412
15413 #[cfg(unix)]
15415 {
15416 use std::{fs::Permissions, os::unix::fs::PermissionsExt};
15417 let _ = fs::set_permissions(&config_path, Permissions::from_mode(0o600)).await;
15418 }
15419
15420 let applied = crate::env_overrides::apply_env_overrides(&mut config)?;
15421 config.env_overridden_paths = applied.paths;
15422 config.pre_override_snapshots = applied.snapshots;
15423
15424 if let Err(e) = config.validate() {
15428 ::zeroclaw_log::record!(
15429 WARN,
15430 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15431 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15432 .with_attrs(::serde_json::json!({"error": format!("{e:#}")})),
15433 "[system] freshly-initialized config has validation errors — \
15434 booting anyway so you can fix them via /config"
15435 );
15436 }
15437 ::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");
15438 Ok(config)
15439 }
15440 }
15441
15442 pub fn collect_warnings(&self) -> Vec<crate::validation_warnings::ValidationWarning> {
15455 let mut warnings = Vec::new();
15456 self.collect_fallback_warnings(&mut warnings);
15457 for (family, alias, entry) in self.providers.models.iter_entries() {
15462 if entry.wire_api.is_some() && !crate::provider_aliases::family_honors_wire_api(family)
15463 {
15464 warnings.push(crate::validation_warnings::ValidationWarning::new(
15465 "wire_api_not_supported_for_family",
15466 format!(
15467 "wire_api is set on `{family}.{alias}` but the `{family}` family has a \
15468 fixed wire protocol and ignores it. wire_api only takes effect on the \
15469 openai, llamacpp, and custom (openai-compatible) families."
15470 ),
15471 format!("providers.models.{family}.{alias}.wire_api"),
15472 ));
15473 }
15474 }
15475 warnings
15476 }
15477
15478 fn collect_fallback_warnings(
15483 &self,
15484 warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
15485 ) {
15486 for (family, alias, cfg) in self.providers.models.iter_entries() {
15487 self.collect_fallback_model_warnings(family, alias, cfg, warnings);
15488 if cfg.fallback.is_empty() {
15489 continue;
15490 }
15491 let root = format!("{family}.{alias}");
15492 let mut visited: Vec<String> = vec![root.clone()];
15493 self.walk_fallback(&root, &cfg.fallback, &mut visited, 1, warnings);
15494 }
15495 }
15496
15497 fn collect_fallback_model_warnings(
15502 &self,
15503 family: &str,
15504 alias: &str,
15505 cfg: &ModelProviderConfig,
15506 warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
15507 ) {
15508 let Some(primary) = cfg.model.as_deref() else {
15509 return;
15510 };
15511 for (i, model) in cfg.fallback_models.iter().enumerate() {
15512 let path = format!("providers.models.{family}.{alias}.fallback_models[{i}]");
15513 if model.trim().is_empty() {
15514 warnings.push(crate::validation_warnings::ValidationWarning::new(
15515 "empty_fallback_model",
15516 format!(
15517 "fallback_models entry {i} on {family}.{alias} is empty; \
15518 it is skipped at runtime"
15519 ),
15520 path,
15521 ));
15522 } else if model == primary {
15523 warnings.push(crate::validation_warnings::ValidationWarning::new(
15524 "fallback_model_duplicates_primary",
15525 format!(
15526 "fallback_models entry {model:?} on {family}.{alias} duplicates the \
15527 primary model; it is skipped at runtime"
15528 ),
15529 path,
15530 ));
15531 }
15532 }
15533 }
15534
15535 fn walk_fallback(
15536 &self,
15537 from: &str,
15538 refs: &[crate::providers::ModelProviderRef],
15539 visited: &mut Vec<String>,
15540 depth: usize,
15541 warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
15542 ) {
15543 if depth > crate::providers::MAX_FALLBACK_DEPTH {
15544 warnings.push(crate::validation_warnings::ValidationWarning::new(
15545 "max_fallback_depth_exceeded",
15546 format!(
15547 "fallback chain from {from} exceeds the maximum depth of {}; \
15548 deeper links are pruned at runtime",
15549 crate::providers::MAX_FALLBACK_DEPTH
15550 ),
15551 format!("providers.models.{from}.fallback"),
15552 ));
15553 return;
15554 }
15555 for (i, fallback_ref) in refs.iter().enumerate() {
15556 let raw = fallback_ref.as_str().trim();
15557 if raw.is_empty() {
15558 continue;
15559 }
15560 let path = format!("providers.models.{from}.fallback[{i}]");
15561 let Some((family, alias, cfg)) = self.providers.models.find_by_name(raw) else {
15562 warnings.push(crate::validation_warnings::ValidationWarning::new(
15563 "dangling_fallback_ref",
15564 format!(
15565 "fallback {raw:?} on {from} does not resolve to a configured \
15566 providers.models entry; this fallback link is skipped at runtime"
15567 ),
15568 path,
15569 ));
15570 continue;
15571 };
15572 let resolved = format!("{family}.{alias}");
15573 if visited.iter().any(|v| v == &resolved) {
15574 warnings.push(crate::validation_warnings::ValidationWarning::new(
15575 "fallback_cycle",
15576 format!(
15577 "fallback {raw:?} on {from} closes a cycle \
15578 ({} -> {resolved}); the cycle edge is pruned at runtime",
15579 visited.join(" -> ")
15580 ),
15581 path,
15582 ));
15583 continue;
15584 }
15585 visited.push(resolved.clone());
15586 self.walk_fallback(&resolved, &cfg.fallback, visited, depth + 1, warnings);
15587 visited.pop();
15588 }
15589 }
15590
15591 pub fn reply_pacing_entries(&self) -> Vec<(String, &dyn HasReplyPacing)> {
15596 let c = &self.channels;
15597 fn rows<'a, C: HasReplyPacing>(
15598 ch_type: &'static str,
15599 map: &'a std::collections::HashMap<String, C>,
15600 ) -> impl Iterator<Item = (String, &'a dyn HasReplyPacing)> + 'a {
15601 map.iter().map(move |(alias, cfg)| {
15602 (
15603 format!("channels.{ch_type}.{alias}"),
15604 cfg as &dyn HasReplyPacing,
15605 )
15606 })
15607 }
15608 rows("telegram", &c.telegram)
15609 .chain(rows("discord", &c.discord))
15610 .chain(rows("slack", &c.slack))
15611 .chain(rows("mattermost", &c.mattermost))
15612 .chain(rows("webhook", &c.webhook))
15613 .chain(rows("imessage", &c.imessage))
15614 .chain(rows("matrix", &c.matrix))
15615 .chain(rows("signal", &c.signal))
15616 .chain(rows("whatsapp", &c.whatsapp))
15617 .collect()
15618 }
15619
15620 pub fn validate(&self) -> Result<()> {
15625 if self.tunnel.tunnel_provider.trim() == "openvpn" {
15627 let openvpn = self.tunnel.openvpn.as_ref().ok_or_else(|| {
15628 ::zeroclaw_log::record!(
15629 WARN,
15630 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
15631 .with_outcome(::zeroclaw_log::EventOutcome::Failure),
15632 "tunnel.tunnel_provider='openvpn' rejected: [tunnel.openvpn] block missing"
15633 );
15634 anyhow::Error::msg("tunnel.tunnel_provider='openvpn' requires [tunnel.openvpn]")
15635 })?;
15636
15637 if openvpn.config_file.trim().is_empty() {
15638 validation_bail!(
15639 RequiredFieldEmpty,
15640 "tunnel.openvpn.config_file",
15641 "tunnel.openvpn.config_file must not be empty"
15642 );
15643 }
15644 if openvpn.connect_timeout_secs == 0 {
15645 validation_bail!(
15646 InvalidNumericRange,
15647 "tunnel.openvpn.connect_timeout_secs",
15648 "tunnel.openvpn.connect_timeout_secs must be greater than 0"
15649 );
15650 }
15651 }
15652
15653 for (path_prefix, cfg) in self.reply_pacing_entries() {
15657 let secs = cfg.reply_min_interval_secs();
15658 if secs > REPLY_MIN_INTERVAL_MAX_SECS {
15659 let path = format!("{path_prefix}.reply_min_interval_secs");
15660 validation_bail!(
15661 InvalidNumericRange,
15662 path,
15663 "{path} = {secs} is out of range; must be 0..={REPLY_MIN_INTERVAL_MAX_SECS}"
15664 );
15665 }
15666 let depth = cfg.reply_queue_depth_max();
15667 if depth > REPLY_QUEUE_DEPTH_CEILING {
15668 let path = format!("{path_prefix}.reply_queue_depth_max");
15669 validation_bail!(
15670 InvalidNumericRange,
15671 path,
15672 "{path} = {depth} is out of range; must be 0..={REPLY_QUEUE_DEPTH_CEILING}"
15673 );
15674 }
15675 }
15676
15677 for name in self.http_request.secrets.keys() {
15678 if name.is_empty()
15679 || name.len() > 64
15680 || !name
15681 .chars()
15682 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
15683 {
15684 validation_bail!(
15685 InvalidFormat,
15686 format!("http_request.secrets.{name}"),
15687 "http_request.secrets key {name:?} must contain 1..=64 ASCII letters, numbers, underscores, or hyphens"
15688 );
15689 }
15690 }
15691
15692 if self.gateway.host.trim().is_empty() {
15694 validation_bail!(
15695 RequiredFieldEmpty,
15696 "gateway.host",
15697 "gateway.host must not be empty"
15698 );
15699 }
15700 if matches!(self.transcription.max_audio_bytes, Some(0)) {
15701 validation_bail!(
15702 InvalidNumericRange,
15703 "transcription.max_audio_bytes",
15704 "transcription.max_audio_bytes must be greater than zero"
15705 );
15706 }
15707 if self.channels.max_concurrent_per_channel == 0 {
15708 validation_bail!(
15709 InvalidNumericRange,
15710 "channels.max_concurrent_per_channel",
15711 "channels.max_concurrent_per_channel must be greater than 0"
15712 );
15713 }
15714 if self.heartbeat.enabled {
15717 let hb_agent = self.heartbeat.agent.trim();
15718 if hb_agent.is_empty() {
15719 validation_bail!(
15720 RequiredFieldEmpty,
15721 "heartbeat.agent",
15722 "heartbeat.agent must reference a configured agent when heartbeat.enabled = true"
15723 );
15724 }
15725 if !self.agents.contains_key(hb_agent) {
15726 validation_bail!(
15727 DanglingReference,
15728 "heartbeat.agent",
15729 "heartbeat.agent = {hb_agent:?} but no [agents.{hb_agent}] entry is configured"
15730 );
15731 }
15732 }
15733 if let Some(ref prefix) = self.gateway.path_prefix {
15734 if !prefix.is_empty() {
15737 if !prefix.starts_with('/') {
15738 validation_bail!(
15739 InvalidFormat,
15740 "gateway.path_prefix",
15741 "gateway.path_prefix must start with '/'"
15742 );
15743 }
15744 if prefix.ends_with('/') {
15745 validation_bail!(
15746 InvalidFormat,
15747 "gateway.path_prefix",
15748 "gateway.path_prefix must not end with '/' (including bare '/')"
15749 );
15750 }
15751 if let Some(bad) = prefix.chars().find(|c| {
15754 !matches!(c, '/' | '-' | '_' | '.' | '~'
15755 | 'a'..='z' | 'A'..='Z' | '0'..='9'
15756 | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '='
15757 | ':' | '@')
15758 }) {
15759 anyhow::bail!(
15760 "gateway.path_prefix contains invalid character '{bad}'; \
15761 only unreserved and sub-delim URI characters are allowed"
15762 );
15763 }
15764 }
15765 }
15766
15767 if !self.skill_bundles.is_empty() {
15773 let install_root = self.install_root_dir();
15774 for alias in self.skill_bundles.keys() {
15775 let dir = crate::skill_bundles::resolve_directory(self, &install_root, alias)
15776 .map_err(|e| {
15777 ::zeroclaw_log::record!(
15778 WARN,
15779 ::zeroclaw_log::Event::new(
15780 module_path!(),
15781 ::zeroclaw_log::Action::Reject
15782 )
15783 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
15784 .with_attrs(::serde_json::json!({
15785 "skill_bundle": alias,
15786 "error": format!("{}", e),
15787 })),
15788 "skill_bundles.<alias>.directory could not be resolved"
15789 );
15790 anyhow::Error::msg(e.to_string())
15791 })?;
15792 if let Err(e) = crate::skill_bundles::validate_directory(&dir, &install_root) {
15793 validation_bail!(
15794 InvalidFormat,
15795 format!("skill-bundles.{alias}.directory"),
15796 "{e}"
15797 );
15798 }
15799 }
15800 if let Err(e) = crate::skill_bundles::validate_uniqueness(self, &install_root) {
15801 validation_bail!(InvalidFormat, "skill_bundles", "{e}");
15802 }
15803 }
15804
15805 let mut profile_aliases: Vec<&String> = self.risk_profiles.keys().collect();
15809 profile_aliases.sort();
15810 for profile_alias in profile_aliases {
15811 let profile = &self.risk_profiles[profile_alias];
15812 for (i, env_name) in profile.shell_env_passthrough.iter().enumerate() {
15813 if !is_valid_env_var_name(env_name) {
15814 anyhow::bail!(
15815 "risk_profiles.{profile_alias}.shell_env_passthrough[{i}] is invalid ({env_name}); expected [A-Za-z_][A-Za-z0-9_]*"
15816 );
15817 }
15818 }
15819 }
15820
15821 if self.security.otp.challenge_max_attempts == 0 {
15823 validation_bail!(
15824 InvalidNumericRange,
15825 "security.otp.challenge_max_attempts",
15826 "security.otp.challenge_max_attempts must be greater than 0"
15827 );
15828 }
15829 if self.security.otp.token_ttl_secs == 0 {
15830 validation_bail!(
15831 InvalidNumericRange,
15832 "security.otp.token_ttl_secs",
15833 "security.otp.token_ttl_secs must be greater than 0"
15834 );
15835 }
15836 if self.security.otp.cache_valid_secs == 0 {
15837 validation_bail!(
15838 InvalidNumericRange,
15839 "security.otp.cache_valid_secs",
15840 "security.otp.cache_valid_secs must be greater than 0"
15841 );
15842 }
15843 if self.security.otp.cache_valid_secs < self.security.otp.token_ttl_secs {
15844 anyhow::bail!(
15845 "security.otp.cache_valid_secs must be greater than or equal to security.otp.token_ttl_secs"
15846 );
15847 }
15848 if self.security.otp.challenge_max_attempts == 0 {
15849 validation_bail!(
15850 InvalidNumericRange,
15851 "security.otp.challenge_max_attempts",
15852 "security.otp.challenge_max_attempts must be greater than 0"
15853 );
15854 }
15855 for (i, action) in self.security.otp.gated_actions.iter().enumerate() {
15856 let normalized = action.trim();
15857 if normalized.is_empty() {
15858 validation_bail!(
15859 RequiredFieldEmpty,
15860 format!("security.otp.gated_actions[{i}]"),
15861 "security.otp.gated_actions[{i}] must not be empty"
15862 );
15863 }
15864 if !normalized
15865 .chars()
15866 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
15867 {
15868 anyhow::bail!(
15869 "security.otp.gated_actions[{i}] contains invalid characters: {normalized}"
15870 );
15871 }
15872 if !default_otp_gated_actions()
15873 .iter()
15874 .any(|known| known == normalized)
15875 {
15876 ::zeroclaw_log::record!(
15877 WARN,
15878 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15879 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15880 .with_attrs(::serde_json::json!({
15881 "action": normalized,
15882 "known_actions": default_otp_gated_actions(),
15883 })),
15884 "security.otp.gated_actions entry does not match a known gated action and will not be enforced: "
15885 );
15886 }
15887 }
15888 DomainMatcher::new(
15889 &self.security.otp.gated_domains,
15890 &self.security.otp.gated_domain_categories,
15891 )
15892 .with_context(
15893 || "Invalid security.otp.gated_domains or security.otp.gated_domain_categories",
15894 )?;
15895 if self.security.estop.state_file.trim().is_empty() {
15896 validation_bail!(
15897 RequiredFieldEmpty,
15898 "security.estop.state_file",
15899 "security.estop.state_file must not be empty"
15900 );
15901 }
15902
15903 if self.scheduler.max_concurrent == 0 {
15905 validation_bail!(
15906 InvalidNumericRange,
15907 "scheduler.max_concurrent",
15908 "scheduler.max_concurrent must be greater than 0"
15909 );
15910 }
15911 if self.scheduler.max_tasks == 0 {
15912 validation_bail!(
15913 InvalidNumericRange,
15914 "scheduler.max_tasks",
15915 "scheduler.max_tasks must be greater than 0"
15916 );
15917 }
15918
15919 for (i, route) in self.model_routes.iter().enumerate() {
15921 if route.hint.trim().is_empty() {
15922 validation_bail!(
15923 RequiredFieldEmpty,
15924 format!("model_routes[{i}].hint"),
15925 "model_routes[{i}].hint must not be empty"
15926 );
15927 }
15928 let mp = route.model_provider.trim();
15929 if mp.is_empty() {
15930 validation_bail!(
15931 RequiredFieldEmpty,
15932 format!("model_routes[{i}].model_provider"),
15933 "model_routes[{i}].model_provider must not be empty"
15934 );
15935 }
15936 match mp.split_once('.') {
15941 Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
15942 if self.providers.models.find(ty, inner).is_none() {
15943 validation_bail!(
15944 DanglingReference,
15945 format!("model_routes[{i}].model_provider"),
15946 "model_routes[{i}].model_provider = {mp:?} but providers.models.{ty}.{inner} is not configured",
15947 );
15948 }
15949 }
15950 _ => validation_bail!(
15951 InvalidFormat,
15952 format!("model_routes[{i}].model_provider"),
15953 "model_routes[{i}].model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
15954 ),
15955 }
15956 if route.model.trim().is_empty() {
15957 validation_bail!(
15958 RequiredFieldEmpty,
15959 format!("model_routes[{i}].model"),
15960 "model_routes[{i}].model must not be empty"
15961 );
15962 }
15963 }
15964
15965 for (i, route) in self.embedding_routes.iter().enumerate() {
15967 if route.hint.trim().is_empty() {
15968 validation_bail!(
15969 RequiredFieldEmpty,
15970 format!("embedding_routes[{i}].hint"),
15971 "embedding_routes[{i}].hint must not be empty"
15972 );
15973 }
15974 let mp = route.model_provider.trim();
15975 if mp.is_empty() {
15976 validation_bail!(
15977 RequiredFieldEmpty,
15978 format!("embedding_routes[{i}].model_provider"),
15979 "embedding_routes[{i}].model_provider must not be empty"
15980 );
15981 }
15982 match mp.split_once('.') {
15985 Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
15986 if self.providers.models.find(ty, inner).is_none() {
15987 validation_bail!(
15988 DanglingReference,
15989 format!("embedding_routes[{i}].model_provider"),
15990 "embedding_routes[{i}].model_provider = {mp:?} but providers.models.{ty}.{inner} is not configured",
15991 );
15992 }
15993 }
15994 _ => validation_bail!(
15995 InvalidFormat,
15996 format!("embedding_routes[{i}].model_provider"),
15997 "embedding_routes[{i}].model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
15998 ),
15999 }
16000 if route.model.trim().is_empty() {
16001 validation_bail!(
16002 RequiredFieldEmpty,
16003 format!("embedding_routes[{i}].model"),
16004 "embedding_routes[{i}].model must not be empty"
16005 );
16006 }
16007 }
16008
16009 for (type_key, alias_key, profile) in self.providers.models.iter_entries() {
16010 let profile_name = format!("{type_key}.{alias_key}");
16011
16012 let has_uri = profile
16013 .uri
16014 .as_deref()
16015 .map(str::trim)
16016 .is_some_and(|value| !value.is_empty());
16017
16018 let has_api_key = profile
16029 .api_key
16030 .as_deref()
16031 .is_some_and(|v| !v.trim().is_empty());
16032 let has_model = profile
16033 .model
16034 .as_deref()
16035 .is_some_and(|v| !v.trim().is_empty());
16036 if !has_uri && !has_api_key && !has_model {
16037 ::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). \
16038 Skipping at runtime; run `zeroclaw quickstart` (or use the dashboard) \
16039 to make this model_provider usable.");
16040 continue;
16041 }
16042
16043 if let Some(uri) = profile.uri.as_deref().map(str::trim)
16044 && !uri.is_empty()
16045 {
16046 let parsed = reqwest::Url::parse(uri).with_context(|| {
16047 format!("providers.models.{profile_name}.uri is not a valid URL")
16048 })?;
16049 if !matches!(parsed.scheme(), "http" | "https") {
16050 anyhow::bail!("providers.models.{profile_name}.uri must use http/https");
16051 }
16052 }
16053
16054 if let Some(temp) = profile.temperature {
16055 validate_temperature(temp).map_err(|e| {
16056 ::zeroclaw_log::record!(
16057 WARN,
16058 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
16059 .with_outcome(::zeroclaw_log::EventOutcome::Failure)
16060 .with_attrs(::serde_json::json!({
16061 "profile": profile_name,
16062 "temperature": temp,
16063 "error": format!("{}", e),
16064 })),
16065 "providers.models.<alias>.temperature rejected"
16066 );
16067 anyhow::Error::msg(format!("providers.models.{profile_name}.temperature: {e}"))
16068 })?;
16069 }
16070
16071 for (key, value) in &profile.pricing {
16072 if value.is_nan() {
16073 anyhow::bail!(
16074 "providers.models.{profile_name}.pricing.{key}: value must not be NaN"
16075 );
16076 }
16077 if *value < 0.0 {
16078 anyhow::bail!(
16079 "providers.models.{profile_name}.pricing.{key}: value must be >= 0.0 (got {value})"
16080 );
16081 }
16082 }
16083 }
16084
16085 for w in self.collect_warnings() {
16091 ::zeroclaw_log::record!(
16092 WARN,
16093 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
16094 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
16095 .with_attrs(::serde_json::json!({"path": w.path, "code": w.code})),
16096 &format!("{}", w.message)
16097 );
16098 }
16099
16100 for (alias, cfg) in &self.providers.models.ollama {
16102 let entry = &cfg.base;
16103 if !entry
16104 .model
16105 .as_deref()
16106 .is_some_and(|model| model.trim().ends_with(":cloud"))
16107 {
16108 continue;
16109 }
16110
16111 if is_local_ollama_endpoint(entry.uri.as_deref()) {
16112 anyhow::bail!(
16113 "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)."
16114 );
16115 }
16116 if is_official_ollama_cloud_endpoint(entry.uri.as_deref())
16117 && !has_ollama_cloud_credential(entry.api_key.as_deref())
16118 {
16119 anyhow::bail!(
16120 "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>)."
16121 );
16122 }
16123 }
16124
16125 if self.microsoft365.enabled {
16127 let tenant = self
16128 .microsoft365
16129 .tenant_id
16130 .as_deref()
16131 .map(str::trim)
16132 .filter(|s| !s.is_empty());
16133 if tenant.is_none() {
16134 anyhow::bail!(
16135 "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
16136 );
16137 }
16138 let client = self
16139 .microsoft365
16140 .client_id
16141 .as_deref()
16142 .map(str::trim)
16143 .filter(|s| !s.is_empty());
16144 if client.is_none() {
16145 anyhow::bail!(
16146 "microsoft365.client_id must not be empty when microsoft365 is enabled"
16147 );
16148 }
16149 let flow = self.microsoft365.auth_flow.trim();
16150 if flow != "client_credentials" && flow != "device_code" {
16151 anyhow::bail!(
16152 "microsoft365.auth_flow must be 'client_credentials' or 'device_code'"
16153 );
16154 }
16155 if flow == "client_credentials"
16156 && self
16157 .microsoft365
16158 .client_secret
16159 .as_deref()
16160 .is_none_or(|s| s.trim().is_empty())
16161 {
16162 anyhow::bail!(
16163 "microsoft365.client_secret must not be empty when auth_flow is 'client_credentials'"
16164 );
16165 }
16166 }
16167
16168 if self.microsoft365.enabled {
16170 let tenant = self
16171 .microsoft365
16172 .tenant_id
16173 .as_deref()
16174 .map(str::trim)
16175 .filter(|s| !s.is_empty());
16176 if tenant.is_none() {
16177 anyhow::bail!(
16178 "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
16179 );
16180 }
16181 let client = self
16182 .microsoft365
16183 .client_id
16184 .as_deref()
16185 .map(str::trim)
16186 .filter(|s| !s.is_empty());
16187 if client.is_none() {
16188 anyhow::bail!(
16189 "microsoft365.client_id must not be empty when microsoft365 is enabled"
16190 );
16191 }
16192 let flow = self.microsoft365.auth_flow.trim();
16193 if flow != "client_credentials" && flow != "device_code" {
16194 anyhow::bail!("microsoft365.auth_flow must be client_credentials or device_code");
16195 }
16196 if flow == "client_credentials"
16197 && self
16198 .microsoft365
16199 .client_secret
16200 .as_deref()
16201 .is_none_or(|s| s.trim().is_empty())
16202 {
16203 anyhow::bail!(
16204 "microsoft365.client_secret must not be empty when auth_flow is client_credentials"
16205 );
16206 }
16207 }
16208
16209 if self.mcp.enabled {
16211 validate_mcp_config(&self.mcp)?;
16212 }
16213
16214 if self.knowledge.enabled {
16216 if self.knowledge.max_nodes == 0 {
16217 validation_bail!(
16218 InvalidNumericRange,
16219 "knowledge.max_nodes",
16220 "knowledge.max_nodes must be greater than 0"
16221 );
16222 }
16223 if self.knowledge.db_path.trim().is_empty() {
16224 validation_bail!(
16225 RequiredFieldEmpty,
16226 "knowledge.db_path",
16227 "knowledge.db_path must not be empty"
16228 );
16229 }
16230 }
16231
16232 let mut seen_gws_services = std::collections::HashSet::new();
16234 for (i, service) in self.google_workspace.allowed_services.iter().enumerate() {
16235 let normalized = service.trim();
16236 if normalized.is_empty() {
16237 validation_bail!(
16238 RequiredFieldEmpty,
16239 format!("google_workspace.allowed_services[{i}]"),
16240 "google_workspace.allowed_services[{i}] must not be empty"
16241 );
16242 }
16243 if !normalized
16244 .chars()
16245 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
16246 {
16247 anyhow::bail!(
16248 "google_workspace.allowed_services[{i}] contains invalid characters: {normalized}"
16249 );
16250 }
16251 if !seen_gws_services.insert(normalized.to_string()) {
16252 anyhow::bail!(
16253 "google_workspace.allowed_services contains duplicate entry: {normalized}"
16254 );
16255 }
16256 }
16257
16258 let effective_services: std::collections::HashSet<&str> =
16263 if self.google_workspace.allowed_services.is_empty() {
16264 DEFAULT_GWS_SERVICES.iter().copied().collect()
16265 } else {
16266 self.google_workspace
16267 .allowed_services
16268 .iter()
16269 .map(|s| s.trim())
16270 .collect()
16271 };
16272
16273 let mut seen_gws_operations = std::collections::HashSet::new();
16274 for (i, operation) in self.google_workspace.allowed_operations.iter().enumerate() {
16275 let service = operation.service.trim();
16276 let resource = operation.resource.trim();
16277
16278 if service.is_empty() {
16279 validation_bail!(
16280 RequiredFieldEmpty,
16281 format!("google_workspace.allowed_operations[{i}].service"),
16282 "google_workspace.allowed_operations[{i}].service must not be empty"
16283 );
16284 }
16285 if resource.is_empty() {
16286 anyhow::bail!(
16287 "google_workspace.allowed_operations[{i}].resource must not be empty"
16288 );
16289 }
16290
16291 if !effective_services.contains(service) {
16292 anyhow::bail!(
16293 "google_workspace.allowed_operations[{i}].service '{service}' is not in the \
16294 effective allowed_services; this entry can never match at runtime"
16295 );
16296 }
16297 if !service
16298 .chars()
16299 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
16300 {
16301 anyhow::bail!(
16302 "google_workspace.allowed_operations[{i}].service contains invalid characters: {service}"
16303 );
16304 }
16305 if !resource
16306 .chars()
16307 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
16308 {
16309 anyhow::bail!(
16310 "google_workspace.allowed_operations[{i}].resource contains invalid characters: {resource}"
16311 );
16312 }
16313
16314 if let Some(ref sub_resource) = operation.sub_resource {
16315 let sub = sub_resource.trim();
16316 if sub.is_empty() {
16317 anyhow::bail!(
16318 "google_workspace.allowed_operations[{i}].sub_resource must not be empty when present"
16319 );
16320 }
16321 if !sub
16322 .chars()
16323 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
16324 {
16325 anyhow::bail!(
16326 "google_workspace.allowed_operations[{i}].sub_resource contains invalid characters: {sub}"
16327 );
16328 }
16329 }
16330
16331 if operation.methods.is_empty() {
16332 validation_bail!(
16333 RequiredFieldEmpty,
16334 format!("google_workspace.allowed_operations[{i}].methods"),
16335 "google_workspace.allowed_operations[{i}].methods must not be empty"
16336 );
16337 }
16338
16339 let mut seen_methods = std::collections::HashSet::new();
16340 for (j, method) in operation.methods.iter().enumerate() {
16341 let normalized = method.trim();
16342 if normalized.is_empty() {
16343 anyhow::bail!(
16344 "google_workspace.allowed_operations[{i}].methods[{j}] must not be empty"
16345 );
16346 }
16347 if !normalized
16348 .chars()
16349 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
16350 {
16351 anyhow::bail!(
16352 "google_workspace.allowed_operations[{i}].methods[{j}] contains invalid characters: {normalized}"
16353 );
16354 }
16355 if !seen_methods.insert(normalized.to_string()) {
16356 anyhow::bail!(
16357 "google_workspace.allowed_operations[{i}].methods contains duplicate entry: {normalized}"
16358 );
16359 }
16360 }
16361
16362 let sub_key = operation
16363 .sub_resource
16364 .as_deref()
16365 .map(str::trim)
16366 .unwrap_or("");
16367 let operation_key = format!("{service}:{resource}:{sub_key}");
16368 if !seen_gws_operations.insert(operation_key.clone()) {
16369 anyhow::bail!(
16370 "google_workspace.allowed_operations contains duplicate service/resource/sub_resource entry: {operation_key}"
16371 );
16372 }
16373 }
16374
16375 if self.project_intel.enabled {
16377 let lang = &self.project_intel.default_language;
16378 if !["en", "de", "fr", "it"].contains(&lang.as_str()) {
16379 anyhow::bail!(
16380 "project_intel.default_language must be one of: en, de, fr, it (got '{lang}')"
16381 );
16382 }
16383 let sens = &self.project_intel.risk_sensitivity;
16384 if !["low", "medium", "high"].contains(&sens.as_str()) {
16385 anyhow::bail!(
16386 "project_intel.risk_sensitivity must be one of: low, medium, high (got '{sens}')"
16387 );
16388 }
16389 if let Some(ref tpl_dir) = self.project_intel.templates_dir
16390 && !std::path::Path::new(tpl_dir).exists()
16391 {
16392 anyhow::bail!("project_intel.templates_dir path does not exist: {tpl_dir}");
16393 }
16394 }
16395
16396 self.proxy.validate()?;
16398 self.cloud_ops.validate()?;
16399
16400 if self.notion.enabled {
16402 if self.notion.database_id.trim().is_empty() {
16403 anyhow::bail!("notion.database_id must not be empty when notion.enabled = true");
16404 }
16405 if self.notion.poll_interval_secs == 0 {
16406 validation_bail!(
16407 InvalidNumericRange,
16408 "notion.poll_interval_secs",
16409 "notion.poll_interval_secs must be greater than 0"
16410 );
16411 }
16412 if self.notion.max_concurrent == 0 {
16413 validation_bail!(
16414 InvalidNumericRange,
16415 "notion.max_concurrent",
16416 "notion.max_concurrent must be greater than 0"
16417 );
16418 }
16419 if self.notion.status_property.trim().is_empty() {
16420 validation_bail!(
16421 RequiredFieldEmpty,
16422 "notion.status_property",
16423 "notion.status_property must not be empty"
16424 );
16425 }
16426 if self.notion.input_property.trim().is_empty() {
16427 validation_bail!(
16428 RequiredFieldEmpty,
16429 "notion.input_property",
16430 "notion.input_property must not be empty"
16431 );
16432 }
16433 if self.notion.result_property.trim().is_empty() {
16434 validation_bail!(
16435 RequiredFieldEmpty,
16436 "notion.result_property",
16437 "notion.result_property must not be empty"
16438 );
16439 }
16440 }
16441
16442 if let Some(ref pinggy) = self.tunnel.pinggy
16444 && let Some(ref region) = pinggy.region
16445 {
16446 let r = region.trim().to_ascii_lowercase();
16447 if !r.is_empty() && !matches!(r.as_str(), "us" | "eu" | "ap" | "br" | "au") {
16448 anyhow::bail!(
16449 "tunnel.pinggy.region must be one of: us, eu, ap, br, au (or omitted for auto)"
16450 );
16451 }
16452 }
16453
16454 if self.jira.enabled {
16456 if self.jira.base_url.trim().is_empty() {
16457 anyhow::bail!("jira.base_url must not be empty when jira.enabled = true");
16458 }
16459 if self.jira.api_token.trim().is_empty()
16460 && std::env::var("JIRA_API_TOKEN")
16461 .unwrap_or_default()
16462 .trim()
16463 .is_empty()
16464 {
16465 anyhow::bail!(
16466 "jira.api_token must be set (or JIRA_API_TOKEN env var) when jira.enabled = true"
16467 );
16468 }
16469 let valid_actions = [
16470 "get_ticket",
16471 "search_tickets",
16472 "comment_ticket",
16473 "list_projects",
16474 "myself",
16475 "list_transitions",
16476 "transition_ticket",
16477 "create_ticket",
16478 ];
16479 for action in &self.jira.allowed_actions {
16480 if !valid_actions.contains(&action.as_str()) {
16481 anyhow::bail!(
16482 "jira.allowed_actions contains unknown action: '{}'. \
16483 Valid: get_ticket, search_tickets, comment_ticket, list_projects, myself, list_transitions, transition_ticket, create_ticket",
16484 action
16485 );
16486 }
16487 }
16488 }
16489
16490 if let Err(msg) = self.security.nevis.validate() {
16492 anyhow::bail!("security.nevis: {msg}");
16493 }
16494
16495 if self.delegate.timeout_secs == 0 {
16497 validation_bail!(
16498 InvalidNumericRange,
16499 "delegate.timeout_secs",
16500 "delegate.timeout_secs must be greater than 0"
16501 );
16502 }
16503 if self.delegate.agentic_timeout_secs == 0 {
16504 validation_bail!(
16505 InvalidNumericRange,
16506 "delegate.agentic_timeout_secs",
16507 "delegate.agentic_timeout_secs must be greater than 0"
16508 );
16509 }
16510
16511 let mut agent_aliases: Vec<&String> = self.agents.keys().collect();
16516 agent_aliases.sort();
16517 for alias in agent_aliases {
16518 let agent = &self.agents[alias];
16519
16520 let mp = agent.model_provider.trim();
16523 if mp.is_empty() {
16524 validation_bail!(
16525 RequiredFieldEmpty,
16526 format!("agents.{alias}.model_provider"),
16527 "agents.{alias}.model_provider must reference a configured model model_provider (e.g. \"anthropic.default\")",
16528 );
16529 }
16530 match mp.split_once('.') {
16531 Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
16532 if !crate::providers::ModelProviders::slot_names().contains(&ty) {
16533 validation_bail!(
16534 DanglingReference,
16535 format!("agents.{alias}.model_provider"),
16536 "agents.{alias}.model_provider = {mp:?} but {ty:?} is not a known provider family; check [providers.models.<family>.<alias>] in config.toml (valid families: `zeroclaw providers`)",
16537 );
16538 }
16539 let exists = self
16540 .get_map_keys(&format!("providers.models.{ty}"))
16541 .is_some_and(|keys| keys.iter().any(|k| k == inner));
16542 if !exists {
16543 validation_bail!(
16544 DanglingReference,
16545 format!("agents.{alias}.model_provider"),
16546 "agents.{alias}.model_provider = {mp:?} but [providers.models.{ty}.{inner}] is not configured",
16547 );
16548 }
16549 }
16550 _ => validation_bail!(
16551 InvalidFormat,
16552 format!("agents.{alias}.model_provider"),
16553 "agents.{alias}.model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
16554 ),
16555 }
16556
16557 for (i, ch) in agent.channels.iter().enumerate() {
16562 let trimmed = ch.trim();
16563 match trimmed.split_once('.') {
16564 Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
16565 let exists = self
16570 .get_map_keys(&format!("channels.{ty}"))
16571 .is_some_and(|keys| keys.iter().any(|k| k == inner));
16572 if !exists {
16573 validation_bail!(
16574 DanglingReference,
16575 format!("agents.{alias}.channels[{i}]"),
16576 "agents.{alias}.channels[{i}] = {trimmed:?} but channels.{ty}.{inner} is not configured",
16577 );
16578 }
16579 }
16580 _ => validation_bail!(
16581 InvalidFormat,
16582 format!("agents.{alias}.channels[{i}]"),
16583 "agents.{alias}.channels[{i}] must be dotted form `<type>.<alias>` (got {trimmed:?})",
16584 ),
16585 }
16586 }
16587
16588 let typed_provider_refs: &[(&str, &str, &str)] = &[
16596 ("providers.tts", "tts_provider", agent.tts_provider.trim()),
16597 (
16598 "providers.transcription",
16599 "transcription_provider",
16600 agent.transcription_provider.trim(),
16601 ),
16602 (
16604 "providers.models",
16605 "classifier_provider",
16606 agent.classifier_provider.trim(),
16607 ),
16608 ];
16609 for (section_prefix, field, value) in typed_provider_refs {
16610 if value.is_empty() {
16611 continue;
16612 }
16613 match value.split_once('.') {
16614 Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
16615 let exists = self
16616 .get_map_keys(&format!("{section_prefix}.{ty}"))
16617 .is_some_and(|keys| keys.iter().any(|k| k == inner));
16618 if !exists {
16619 validation_bail!(
16620 DanglingReference,
16621 format!("agents.{alias}.{field}"),
16622 "agents.{alias}.{field} = {value:?} but {section_prefix}.{ty}.{inner} is not configured",
16623 );
16624 }
16625 }
16626 _ => validation_bail!(
16627 InvalidFormat,
16628 format!("agents.{alias}.{field}"),
16629 "agents.{alias}.{field} must be dotted form `<type>.<alias>` (got {value:?})",
16630 ),
16631 }
16632 }
16633
16634 let bare_multi: &[(&str, &str, &[String])] = &[
16642 ("skill_bundles", "skill_bundles", &agent.skill_bundles),
16643 (
16644 "knowledge_bundles",
16645 "knowledge_bundles",
16646 &agent.knowledge_bundles,
16647 ),
16648 ("mcp_bundles", "mcp_bundles", &agent.mcp_bundles),
16649 ];
16650 for (section, field, values) in bare_multi {
16651 for (i, key) in values.iter().enumerate() {
16652 let trimmed = key.trim();
16653 if trimmed.is_empty() {
16654 continue;
16655 }
16656 let exists = self
16657 .get_map_keys(section)
16658 .is_some_and(|keys| keys.iter().any(|k| k == trimmed));
16659 if !exists {
16660 validation_bail!(
16661 DanglingReference,
16662 format!("agents.{alias}.{field}[{i}]"),
16663 "agents.{alias}.{field}[{i}] = {trimmed:?} but {section}.{trimmed} is not configured",
16664 );
16665 }
16666 }
16667 }
16668 let bare_single: &[(&str, &str, &str)] = &[
16669 ("risk_profiles", "risk_profile", agent.risk_profile.as_str()),
16670 (
16671 "runtime_profiles",
16672 "runtime_profile",
16673 agent.runtime_profile.as_str(),
16674 ),
16675 ];
16676 for (section, field, raw) in bare_single {
16677 let trimmed = raw.trim();
16678 if trimmed.is_empty() {
16679 continue;
16680 }
16681 let exists = self
16682 .get_map_keys(section)
16683 .is_some_and(|keys| keys.iter().any(|k| k == trimmed));
16684 if !exists {
16685 validation_bail!(
16686 DanglingReference,
16687 format!("agents.{alias}.{field}"),
16688 "agents.{alias}.{field} = {trimmed:?} but {section}.{trimmed} is not configured",
16689 );
16690 }
16691 }
16692
16693 if agent.enabled && agent.risk_profile.trim().is_empty() {
16698 validation_bail!(
16699 RequiredFieldEmpty,
16700 format!("agents.{alias}.risk_profile"),
16701 "agents.{alias}.risk_profile must reference a configured [risk_profiles.<alias>] entry",
16702 );
16703 }
16704
16705 for (target, mode) in &agent.workspace.access {
16708 let target_str = target.as_str();
16709 if target_str == alias.as_str() {
16710 validation_bail!(
16711 InvalidFormat,
16712 format!("agents.{alias}.workspace.access.{target_str}"),
16713 "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",
16714 );
16715 }
16716 if !self.agents.contains_key(target_str) {
16717 validation_bail!(
16718 DanglingReference,
16719 format!("agents.{alias}.workspace.access.{target_str}"),
16720 "agents.{alias}.workspace.access.{target_str} = {mode:?} but agents.{target_str} is not configured",
16721 );
16722 }
16723 }
16724
16725 let agent_backend = agent.memory.backend;
16731 for (i, target) in agent.workspace.read_memory_from.iter().enumerate() {
16732 let target_str = target.as_str();
16733 if target_str == alias.as_str() {
16734 validation_bail!(
16735 InvalidFormat,
16736 format!("agents.{alias}.workspace.read_memory_from[{i}]"),
16737 "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",
16738 );
16739 }
16740 let Some(target_agent) = self.agents.get(target_str) else {
16741 validation_bail!(
16742 DanglingReference,
16743 format!("agents.{alias}.workspace.read_memory_from[{i}]"),
16744 "agents.{alias}.workspace.read_memory_from[{i}] = {target_str:?} but agents.{target_str} is not configured",
16745 );
16746 };
16747 if target_agent.memory.backend != agent_backend {
16748 let target_backend = target_agent.memory.backend;
16749 validation_bail!(
16750 InvalidFormat,
16751 format!("agents.{alias}.workspace.read_memory_from[{i}]"),
16752 "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",
16753 );
16754 }
16755 }
16756 }
16757
16758 let mut peer_group_names: Vec<&String> = self.peer_groups.keys().collect();
16765 peer_group_names.sort();
16766 for group_name in peer_group_names {
16767 let group = &self.peer_groups[group_name];
16768 let group_channel = group.channel.trim();
16769 if group_channel.is_empty() {
16770 validation_bail!(
16771 RequiredFieldEmpty,
16772 format!("peer_groups.{group_name}.channel"),
16773 "peer_groups.{group_name}.channel must name a channel type (e.g. \"discord\") or dotted alias (e.g. \"discord.work\")",
16774 );
16775 }
16776 let (group_channel_type, group_channel_alias) = match group_channel.split_once('.') {
16779 Some((ty, al)) => (ty, Some(al)),
16780 None => (group_channel, None),
16781 };
16782 let channel_aliases = self.get_map_keys(&format!("channels.{group_channel_type}"));
16783 if channel_aliases.is_none() {
16784 validation_bail!(
16785 DanglingReference,
16786 format!("peer_groups.{group_name}.channel"),
16787 "peer_groups.{group_name}.channel = {group_channel:?} but no [channels.{group_channel_type}.*] block is configured",
16788 );
16789 }
16790 if let Some(alias) = group_channel_alias {
16791 let exists = channel_aliases
16792 .as_ref()
16793 .is_some_and(|keys| keys.iter().any(|k| k == alias));
16794 if !exists {
16795 validation_bail!(
16796 DanglingReference,
16797 format!("peer_groups.{group_name}.channel"),
16798 "peer_groups.{group_name}.channel = {group_channel:?} but [channels.{group_channel_type}.{alias}] is not configured",
16799 );
16800 }
16801 }
16802 for (i, member) in group.agents.iter().enumerate() {
16803 let member_str = member.as_str();
16804 let Some(member_agent) = self.agents.get(member_str) else {
16805 validation_bail!(
16806 DanglingReference,
16807 format!("peer_groups.{group_name}.agents[{i}]"),
16808 "peer_groups.{group_name}.agents[{i}] = {member_str:?} but agents.{member_str} is not configured",
16809 );
16810 };
16811 let has_channel_match = member_agent.channels.iter().any(|ch| {
16812 let ch_str = ch.as_str();
16813 match group_channel_alias {
16814 Some(alias) => ch_str == format!("{group_channel_type}.{alias}"),
16815 None => ch_str.starts_with(&format!("{group_channel_type}.")),
16816 }
16817 });
16818 if !has_channel_match {
16819 let needs_msg = match group_channel_alias {
16820 Some(alias) => format!("entry for {group_channel_type}.{alias}"),
16821 None => format!("entry of type {group_channel_type:?}"),
16822 };
16823 validation_bail!(
16824 InvalidFormat,
16825 format!("peer_groups.{group_name}.agents[{i}]"),
16826 "peer_groups.{group_name}.agents[{i}] = {member_str:?} but agents.{member_str}.channels has no {needs_msg}",
16827 );
16828 }
16829 }
16830 }
16831
16832 Ok(())
16833 }
16834
16835 pub fn mark_dirty(&mut self, path: &str) {
16836 self.dirty_paths.insert(path.to_string());
16837 }
16838
16839 pub fn ensure_map_key_for_path(&mut self, path: &str) {
16840 use crate::traits::MapKeyKind;
16841 let mut best: Option<&'static str> = None;
16842 for s in Self::map_key_sections()
16843 .iter()
16844 .filter(|s| s.kind == MapKeyKind::Map)
16845 {
16846 let prefix = format!("{}.", s.path);
16847 if path.starts_with(&prefix)
16848 && path.len() > prefix.len()
16849 && best.is_none_or(|b| s.path.len() > b.len())
16850 {
16851 best = Some(s.path);
16852 }
16853 }
16854 let Some(section) = best else {
16855 return;
16856 };
16857 let rest = &path[section.len() + 1..];
16858 let Some(alias) = rest.split('.').next().filter(|a| !a.is_empty()) else {
16859 return;
16860 };
16861 if self
16862 .get_map_keys(section)
16863 .is_some_and(|keys| keys.iter().any(|k| k == alias))
16864 {
16865 return;
16866 }
16867 let _ = self.create_map_key(section, alias);
16868 }
16869
16870 pub fn clear_dirty(&mut self) {
16871 self.dirty_paths.clear();
16872 }
16873
16874 pub fn set_prop_persistent(&mut self, name: &str, value_str: &str) -> Result<()> {
16875 self.set_prop(name, value_str)?;
16876 self.mark_dirty(name);
16877 Ok(())
16878 }
16879
16880 pub fn set_secret_persistent(&mut self, name: &str, value: String) -> Result<()> {
16881 self.set_secret(name, value)?;
16882 self.mark_dirty(name);
16883 Ok(())
16884 }
16885
16886 async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
16887 if self
16888 .config_path
16889 .parent()
16890 .is_some_and(|parent| !parent.as_os_str().is_empty())
16891 {
16892 return Ok(self.config_path.clone());
16893 }
16894
16895 let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_data_dirs()?;
16896 let (zeroclaw_dir, _workspace_dir, source) =
16897 resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
16898 let file_name = self
16899 .config_path
16900 .file_name()
16901 .filter(|name| !name.is_empty())
16902 .unwrap_or_else(|| std::ffi::OsStr::new("config.toml"));
16903 let resolved = zeroclaw_dir.join(file_name);
16904 ::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");
16905 Ok(resolved)
16906 }
16907
16908 pub async fn save(&self) -> Result<()> {
16909 let mut config_to_save = self.clone();
16911 config_to_save.schema_version = crate::migration::CURRENT_SCHEMA_VERSION;
16916 let config_path = self.resolve_config_path_for_save().await?;
16917 let zeroclaw_dir = config_path
16918 .parent()
16919 .context("Config path must have a parent directory")?;
16920 let store = crate::secrets::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
16921
16922 if !self.pre_override_snapshots.is_empty() {
16929 crate::env_overrides::mask_env_overrides_for_save(
16930 &mut config_to_save,
16931 &self.pre_override_snapshots,
16932 )?;
16933 }
16934 restore_onepassword_references_for_save(
16935 &mut config_to_save,
16936 &self.onepassword_reference_snapshots,
16937 &self.dirty_paths,
16938 )?;
16939
16940 config_to_save.encrypt_secrets(&store)?;
16942
16943 let mut new_table: toml::Table = toml::Value::try_from(&config_to_save)
16950 .context("Failed to serialize config to TOML value")?
16951 .try_into()
16952 .context("Serialized config is not a TOML table")?;
16953 let default_table: toml::Table = toml::Value::try_from(Config::default())
16954 .ok()
16955 .and_then(|v| v.try_into().ok())
16956 .unwrap_or_default();
16957 prune_default_values(&mut new_table, &default_table);
16958 let new_toml = ensure_blank_line_before_sections(
16959 &toml::to_string_pretty(&new_table).context("Failed to serialize pruned config")?,
16960 );
16961
16962 let toml_str = if config_path.exists() {
16965 let existing = fs::read_to_string(&config_path).await.unwrap_or_default();
16966 if existing.is_empty() {
16967 new_toml
16968 } else {
16969 let mut doc: toml_edit::DocumentMut = existing
16970 .parse()
16971 .context("Failed to parse existing config for comment preservation")?;
16972 crate::migration::sync_table(doc.as_table_mut(), &new_table);
16973 ensure_blank_line_before_sections(&doc.to_string())
16977 }
16978 } else {
16979 new_toml
16980 };
16981
16982 write_config_atomically(&config_path, &toml_str).await
16983 }
16984
16985 pub async fn save_dirty(&mut self) -> Result<()> {
16992 if self.dirty_paths.is_empty() {
16993 return Ok(());
16994 }
16995
16996 let config_path = self.resolve_config_path_for_save().await?;
16997 if !config_path.exists() {
16998 let result = self.save().await;
16999 if result.is_ok() {
17000 self.clear_dirty();
17001 }
17002 return result;
17003 }
17004
17005 let mut config_to_save = self.clone();
17006 let zeroclaw_dir = config_path
17007 .parent()
17008 .context("Config path must have a parent directory")?;
17009 let store = crate::secrets::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
17010
17011 if !self.pre_override_snapshots.is_empty() {
17012 crate::env_overrides::mask_env_overrides_for_save(
17013 &mut config_to_save,
17014 &self.pre_override_snapshots,
17015 )?;
17016 }
17017 restore_onepassword_references_for_save(
17018 &mut config_to_save,
17019 &self.onepassword_reference_snapshots,
17020 &self.dirty_paths,
17021 )?;
17022 config_to_save.encrypt_secrets(&store)?;
17023
17024 let full_table: toml::Table = toml::Value::try_from(&config_to_save)
17025 .context("Failed to serialize config to TOML value")?
17026 .try_into()
17027 .context("Serialized config is not a TOML table")?;
17028 let default_table: toml::Table = toml::Value::try_from(Config::default())
17029 .ok()
17030 .and_then(|v| v.try_into().ok())
17031 .unwrap_or_default();
17032
17033 let existing = fs::read_to_string(&config_path).await.with_context(|| {
17034 format!(
17035 "Failed to read existing config for incremental save: {}",
17036 config_path.display()
17037 )
17038 })?;
17039 let mut doc: toml_edit::DocumentMut = existing
17040 .parse()
17041 .context("Failed to parse existing config for incremental save")?;
17042
17043 for path in &self.dirty_paths {
17044 apply_dirty_path(doc.as_table_mut(), path, &full_table, &default_table);
17045 }
17046
17047 doc.as_table_mut().insert(
17056 "schema_version",
17057 toml_edit::value(i64::from(crate::migration::CURRENT_SCHEMA_VERSION)),
17058 );
17059
17060 let toml_str = ensure_blank_line_before_sections(&doc.to_string());
17061
17062 write_config_atomically(&config_path, &toml_str).await?;
17063 self.clear_dirty();
17064 Ok(())
17065 }
17066}
17067
17068fn collect_onepassword_reference_snapshots(
17069 config: &Config,
17070) -> std::collections::HashMap<String, String> {
17071 config
17072 .prop_fields()
17073 .into_iter()
17074 .filter(|field| field.is_secret)
17075 .filter_map(|field| {
17076 let value = crate::env_overrides::raw_value_for_path(config, &field.name)?;
17077 crate::secrets::SecretStore::is_onepassword_ref(&value).then_some((field.name, value))
17078 })
17079 .collect()
17080}
17081
17082fn restore_onepassword_references_for_save(
17083 config_to_save: &mut Config,
17084 snapshots: &std::collections::HashMap<String, String>,
17085 dirty_paths: &std::collections::HashSet<String>,
17086) -> Result<()> {
17087 for (path, value) in snapshots {
17088 if dirty_paths.contains(path) {
17089 continue;
17090 }
17091 if let Err(err) = config_to_save.set_prop(path, value) {
17092 ::zeroclaw_log::record!(
17093 WARN,
17094 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
17095 .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
17096 .with_attrs(::serde_json::json!({"path": path, "error": format!("{}", err)})),
17097 "1Password reference save-restore failed; field retains resolved value"
17098 );
17099 }
17100 }
17101 Ok(())
17102}
17103
17104async fn write_config_atomically(config_path: &Path, toml_str: &str) -> Result<()> {
17106 let parent_dir = config_path
17107 .parent()
17108 .context("Config path must have a parent directory")?;
17109
17110 fs::create_dir_all(parent_dir).await.with_context(|| {
17111 format!(
17112 "Failed to create config directory: {}",
17113 parent_dir.display()
17114 )
17115 })?;
17116
17117 let file_name = config_path
17118 .file_name()
17119 .and_then(|v| v.to_str())
17120 .unwrap_or("config.toml");
17121 let temp_path = parent_dir.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
17122 let backup_path = parent_dir.join(format!("{file_name}.bak"));
17123
17124 let mut temp_file = OpenOptions::new()
17125 .create_new(true)
17126 .write(true)
17127 .open(&temp_path)
17128 .await
17129 .with_context(|| {
17130 format!(
17131 "Failed to create temporary config file: {}",
17132 temp_path.display()
17133 )
17134 })?;
17135 temp_file
17136 .write_all(toml_str.as_bytes())
17137 .await
17138 .context("Failed to write temporary config contents")?;
17139 temp_file
17140 .sync_all()
17141 .await
17142 .context("Failed to fsync temporary config file")?;
17143 drop(temp_file);
17144
17145 let had_existing_config = config_path.exists();
17146 if had_existing_config {
17147 fs::copy(config_path, &backup_path).await.with_context(|| {
17148 format!(
17149 "Failed to create config backup before atomic replace: {}",
17150 backup_path.display()
17151 )
17152 })?;
17153 }
17154
17155 if let Err(e) = fs::rename(&temp_path, config_path).await {
17156 let _ = fs::remove_file(&temp_path).await;
17157 if had_existing_config && backup_path.exists() {
17158 fs::copy(&backup_path, config_path)
17159 .await
17160 .context("Failed to restore config backup")?;
17161 }
17162 anyhow::bail!("Failed to atomically replace config file: {e}");
17163 }
17164
17165 #[cfg(unix)]
17166 {
17167 use std::{fs::Permissions, os::unix::fs::PermissionsExt};
17168 if let Err(err) = fs::set_permissions(config_path, Permissions::from_mode(0o600)).await {
17169 ::zeroclaw_log::record!(
17170 WARN,
17171 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
17172 .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
17173 &format!(
17174 "Failed to harden config permissions to 0600 at {}: {}",
17175 config_path.display().to_string(),
17176 err
17177 )
17178 );
17179 }
17180 }
17181
17182 sync_directory(parent_dir).await?;
17183
17184 if had_existing_config {
17185 let _ = fs::remove_file(&backup_path).await;
17186 }
17187
17188 Ok(())
17189}
17190
17191fn apply_dirty_path(
17195 root: &mut toml_edit::Table,
17196 dotted: &str,
17197 full_table: &toml::Table,
17198 default_table: &toml::Table,
17199) {
17200 let raw: Vec<&str> = dotted.split('.').collect();
17201 if raw.is_empty() {
17202 return;
17203 }
17204 let segments: Vec<String> = resolve_dirty_segments(full_table, &raw);
17211 let segs: Vec<&str> = segments.iter().map(String::as_str).collect();
17212
17213 let mem_val = lookup_path_in_table(full_table, &segs);
17214 let default_val = lookup_path_in_table(default_table, &segs);
17215
17216 let should_delete = match (mem_val, default_val) {
17217 (None, _) => true,
17218 (Some(m), Some(d)) if m == d => true,
17219 _ => false,
17220 };
17221
17222 if should_delete {
17223 delete_path_in_doc(root, &segs);
17224 } else if let Some(value) = mem_val {
17225 let mut pruned = value.clone();
17226 prune_empty_leaves(&mut pruned);
17227 set_path_in_doc(root, &segs, &pruned);
17228 }
17229}
17230
17231fn prune_empty_leaves(value: &mut toml::Value) {
17238 match value {
17239 toml::Value::Table(t) => {
17240 let keys: Vec<String> = t.keys().cloned().collect();
17241 for key in keys {
17242 if let Some(inner) = t.get_mut(&key) {
17243 prune_empty_leaves(inner);
17244 }
17245 let drop = match t.get(&key) {
17246 Some(toml::Value::Array(arr)) => arr.is_empty(),
17247 Some(toml::Value::Table(inner)) => inner.is_empty(),
17248 Some(toml::Value::String(s)) => s.is_empty(),
17249 _ => false,
17250 };
17251 if drop {
17252 t.remove(&key);
17253 }
17254 }
17255 }
17256 toml::Value::Array(arr) => {
17257 for item in arr.iter_mut() {
17258 prune_empty_leaves(item);
17259 }
17260 }
17261 _ => {}
17262 }
17263}
17264
17265fn resolve_dirty_segments(root: &toml::Table, raw: &[&str]) -> Vec<String> {
17266 let mut out: Vec<String> = Vec::with_capacity(raw.len());
17267 let mut current: Option<&toml::Value> = None;
17268 for seg in raw {
17269 let table_opt: Option<&toml::Table> = if out.is_empty() {
17270 Some(root)
17271 } else {
17272 current.and_then(|v| v.as_table())
17273 };
17274 let resolved = match table_opt {
17275 Some(t) if t.contains_key(*seg) => (*seg).to_string(),
17276 Some(t) => {
17277 let snake = seg.replace('-', "_");
17278 if t.contains_key(&snake) {
17279 snake
17280 } else {
17281 (*seg).to_string()
17282 }
17283 }
17284 None => (*seg).to_string(),
17285 };
17286 current = table_opt.and_then(|t| t.get(&resolved));
17287 out.push(resolved);
17288 }
17289 out
17290}
17291
17292fn lookup_path_in_table<'a>(root: &'a toml::Table, segs: &[&str]) -> Option<&'a toml::Value> {
17293 let mut current: Option<&toml::Value> = None;
17294 for (i, seg) in segs.iter().enumerate() {
17295 let table = if i == 0 { root } else { current?.as_table()? };
17296 current = table.get(*seg);
17297 }
17298 current
17299}
17300
17301fn delete_path_in_doc(root: &mut toml_edit::Table, segs: &[&str]) {
17302 let Some((last, parents)) = segs.split_last() else {
17303 return;
17304 };
17305 let mut cursor: &mut toml_edit::Table = root;
17306 for seg in parents {
17307 cursor = match cursor.get_mut(seg).and_then(|i| i.as_table_mut()) {
17308 Some(t) => t,
17309 None => return,
17310 };
17311 }
17312 cursor.remove(last);
17313}
17314
17315fn set_path_in_doc(root: &mut toml_edit::Table, segs: &[&str], value: &toml::Value) {
17316 let Some((last, parents)) = segs.split_last() else {
17317 return;
17318 };
17319 let mut cursor: &mut toml_edit::Table = root;
17320 for seg in parents {
17321 if !cursor.contains_key(seg) {
17322 cursor.insert(seg, toml_edit::Item::Table(toml_edit::Table::new()));
17323 }
17324 cursor = match cursor.get_mut(seg).and_then(|i| i.as_table_mut()) {
17325 Some(t) => t,
17326 None => return,
17327 };
17328 }
17329 let new_item = crate::migration::toml_value_to_edit_item(value);
17330 cursor.insert(last, new_item);
17331}
17332
17333#[allow(clippy::unused_async)] async fn sync_directory(path: &Path) -> Result<()> {
17335 #[cfg(unix)]
17336 {
17337 let dir = File::open(path).await.with_context(|| {
17338 format!(
17339 "Failed to open directory for fsync: {}",
17340 path.display().to_string()
17341 )
17342 })?;
17343 dir.sync_all().await.with_context(|| {
17344 format!(
17345 "Failed to fsync directory metadata: {}",
17346 path.display().to_string()
17347 )
17348 })?;
17349 Ok(())
17350 }
17351
17352 #[cfg(windows)]
17353 {
17354 use std::os::windows::fs::OpenOptionsExt;
17355 const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x02000000;
17356 let dir = std::fs::OpenOptions::new()
17357 .read(true)
17358 .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
17359 .open(path)
17360 .with_context(|| {
17361 format!(
17362 "Failed to open directory for fsync: {}",
17363 path.display().to_string()
17364 )
17365 })?;
17366 if let Err(e) = dir.sync_all() {
17371 if e.raw_os_error() == Some(5) {
17372 ::zeroclaw_log::record!(
17373 TRACE,
17374 ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
17375 &format!(
17376 "Ignoring expected ACCESS_DENIED when fsyncing directory on Windows: {}",
17377 path.display().to_string()
17378 )
17379 );
17380 } else {
17381 return Err(e).with_context(|| {
17382 format!(
17383 "Failed to fsync directory metadata: {}",
17384 path.display().to_string()
17385 )
17386 });
17387 }
17388 }
17389 Ok(())
17390 }
17391
17392 #[cfg(not(any(unix, windows)))]
17393 {
17394 let _ = path;
17395 Ok(())
17396 }
17397}
17398
17399#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
17407#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
17408#[prefix = "sop"]
17409pub struct SopConfig {
17410 #[serde(default)]
17415 pub sops_dir: Option<String>,
17416
17417 #[serde(default = "default_sop_execution_mode")]
17421 pub default_execution_mode: String,
17422
17423 #[serde(default = "default_sop_max_concurrent_total")]
17425 pub max_concurrent_total: usize,
17426
17427 #[serde(default = "default_sop_approval_timeout_secs")]
17431 pub approval_timeout_secs: u64,
17432
17433 #[serde(default = "default_sop_max_finished_runs")]
17436 pub max_finished_runs: usize,
17437}
17438
17439fn default_sop_execution_mode() -> String {
17440 "supervised".to_string()
17441}
17442
17443fn default_sop_max_concurrent_total() -> usize {
17444 4
17445}
17446
17447fn default_sop_approval_timeout_secs() -> u64 {
17448 300
17449}
17450
17451fn default_sop_max_finished_runs() -> usize {
17452 100
17453}
17454
17455impl Default for SopConfig {
17456 fn default() -> Self {
17457 Self {
17458 sops_dir: None,
17459 default_execution_mode: default_sop_execution_mode(),
17460 max_concurrent_total: default_sop_max_concurrent_total(),
17461 approval_timeout_secs: default_sop_approval_timeout_secs(),
17462 max_finished_runs: default_sop_max_finished_runs(),
17463 }
17464 }
17465}
17466
17467macro_rules! impl_enum_prop_kind {
17471 ($($ty:ty),+ $(,)?) => {
17472 $(impl HasPropKind for $ty { const PROP_KIND: PropKind = PropKind::Enum; })+
17473 };
17474}
17475impl_enum_prop_kind!(
17476 WireApi,
17477 HardwareTransport,
17478 McpTransport,
17479 ToolFilterGroupMode,
17480 SkillsPromptInjectionMode,
17481 FirecrawlMode,
17482 ProxyScope,
17483 SearchMode,
17484 CronScheduleDecl,
17485 StreamMode,
17486 WhatsAppWebMode,
17487 WhatsAppChatPolicy,
17488 LineDmPolicy,
17489 LineGroupPolicy,
17490 LarkReceiveMode,
17491 OtpMethod,
17492 SandboxBackend,
17493 AutonomyLevel,
17494 DelegationPolicy,
17495 AuthMode,
17496 OpenAIEndpoint,
17497 AzureEndpoint,
17498 AnthropicEndpoint,
17499 MoonshotEndpoint,
17500 QwenEndpoint,
17501 BedrockEndpoint,
17502 OpenRouterEndpoint,
17503 OllamaEndpoint,
17504 TogetherEndpoint,
17505 FireworksEndpoint,
17506 GroqEndpoint,
17507 MistralEndpoint,
17508 DeepseekEndpoint,
17509 CohereEndpoint,
17510 PerplexityEndpoint,
17511 XaiEndpoint,
17512 CerebrasEndpoint,
17513 SambanovaEndpoint,
17514 HyperbolicEndpoint,
17515 DeepinfraEndpoint,
17516 HuggingfaceEndpoint,
17517 Ai21Endpoint,
17518 RekaEndpoint,
17519 BasetenEndpoint,
17520 NscaleEndpoint,
17521 AnyscaleEndpoint,
17522 NebiusEndpoint,
17523 FriendliEndpoint,
17524 StepfunEndpoint,
17525 AihubmixEndpoint,
17526 SiliconflowEndpoint,
17527 AstraiEndpoint,
17528 AvianEndpoint,
17529 DeepmystEndpoint,
17530 VeniceEndpoint,
17531 NovitaEndpoint,
17532 NvidiaEndpoint,
17533 TelnyxEndpoint,
17534 VercelEndpoint,
17535 CloudflareEndpoint,
17536 OvhEndpoint,
17537 CopilotEndpoint,
17538 OpenAITtsEndpoint,
17539 ElevenLabsTtsEndpoint,
17540 GoogleTtsEndpoint,
17541 EdgeTtsEndpoint,
17542 PiperTtsEndpoint,
17543 GlmEndpoint,
17544 MinimaxEndpoint,
17545 ZaiEndpoint,
17546 DoubaoEndpoint,
17547 YiEndpoint,
17548 HunyuanEndpoint,
17549 QianfanEndpoint,
17550 BaichuanEndpoint,
17551 GeminiEndpoint,
17552 GeminiCliEndpoint,
17553 LmstudioEndpoint,
17554 LlamacppEndpoint,
17555 SglangEndpoint,
17556 VllmEndpoint,
17557 OsaurusEndpoint,
17558 LitellmEndpoint,
17559 LeptonEndpoint,
17560 MorphEndpoint,
17561 GithubModelsEndpoint,
17562 UpstageEndpoint,
17563 FeatherlessEndpoint,
17564 ArceeEndpoint,
17565 LambdaAiEndpoint,
17566 InceptionEndpoint,
17567 SyntheticEndpoint,
17568 OpencodeEndpoint,
17569 KiloCliEndpoint,
17570 KiloEndpoint,
17571 CustomEndpoint,
17572);
17573
17574impl HasPropKind for serde_json::Value {
17575 const PROP_KIND: PropKind = PropKind::String;
17586}
17587
17588#[cfg(test)]
17589mod tests {
17590
17591 #[test]
17592 async fn amqp_validate_requires_paired_client_cert_and_key() {
17593 let base = AmqpConfig {
17594 enabled: true,
17595 amqp_url: "amqps://broker.example.org:5671/%2Fpublic".into(),
17596 exchange: "amq.topic".into(),
17597 routing_keys: vec!["org.example.release".into()],
17598 ca_cert: Some(std::path::PathBuf::from("/etc/ssl/ca.pem")),
17599 ..AmqpConfig::default()
17600 };
17601
17602 assert!(base.validate().is_ok());
17604
17605 let cert_only = AmqpConfig {
17607 client_cert: Some(std::path::PathBuf::from("/etc/ssl/client.pem")),
17608 ..base.clone()
17609 };
17610 assert!(cert_only.validate().is_err());
17611
17612 let key_only = AmqpConfig {
17614 client_key: Some(std::path::PathBuf::from("/etc/ssl/client.key")),
17615 ..base.clone()
17616 };
17617 assert!(key_only.validate().is_err());
17618
17619 let both = AmqpConfig {
17621 client_cert: Some(std::path::PathBuf::from("/etc/ssl/client.pem")),
17622 client_key: Some(std::path::PathBuf::from("/etc/ssl/client.key")),
17623 ..base
17624 };
17625 assert!(both.validate().is_ok());
17626 }
17627 use super::*;
17628 use std::ffi::OsString;
17629 #[cfg(unix)]
17630 use std::os::unix::fs::PermissionsExt;
17631 #[cfg(unix)]
17632 use std::path::Path;
17633 use std::path::PathBuf;
17634 use tempfile::TempDir;
17635 use tokio::sync::MutexGuard;
17636 use tokio::test;
17637
17638 struct EnvValueGuard {
17639 key: &'static str,
17640 previous: Option<OsString>,
17641 }
17642
17643 impl EnvValueGuard {
17644 fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
17645 let previous = std::env::var_os(key);
17646 unsafe { std::env::set_var(key, value) };
17648 Self { key, previous }
17649 }
17650
17651 fn remove(key: &'static str) -> Self {
17652 let previous = std::env::var_os(key);
17653 unsafe { std::env::remove_var(key) };
17655 Self { key, previous }
17656 }
17657 }
17658
17659 impl Drop for EnvValueGuard {
17660 fn drop(&mut self) {
17661 unsafe {
17663 if let Some(previous) = &self.previous {
17664 std::env::set_var(self.key, previous);
17665 } else {
17666 std::env::remove_var(self.key);
17667 }
17668 }
17669 }
17670 }
17671
17672 #[cfg(unix)]
17673 fn write_fake_op(bin_dir: &Path, script: &str) -> PathBuf {
17674 let op_path = bin_dir.join("op");
17675 std::fs::write(&op_path, script).expect("write fake op");
17676 let mut perms = std::fs::metadata(&op_path).unwrap().permissions();
17677 perms.set_mode(0o755);
17678 std::fs::set_permissions(&op_path, perms).unwrap();
17679 op_path
17680 }
17681
17682 #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
17683 #[prefix = "test.object_array.entries"]
17684 struct ObjectArraySecretEntry {
17685 pub name: String,
17686 #[secret]
17687 pub token: Option<String>,
17688 #[secret]
17689 pub headers: HashMap<String, String>,
17690 }
17691
17692 impl crate::config::HasPropKind for Vec<ObjectArraySecretEntry> {
17693 const PROP_KIND: crate::config::PropKind = crate::config::PropKind::ObjectArray;
17694
17695 fn display_secret_terminals() -> Vec<&'static str> {
17696 ObjectArraySecretEntry::secret_field_terminals()
17697 }
17698 }
17699
17700 #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
17701 #[prefix = "test.object_array"]
17702 struct ObjectArraySecretFixture {
17703 pub entries: Vec<ObjectArraySecretEntry>,
17704 }
17705
17706 #[test]
17709 async fn expand_tilde_path_handles_absolute_path() {
17710 let path = expand_tilde_path("/absolute/path");
17711 assert_eq!(path, PathBuf::from("/absolute/path"));
17712 }
17713
17714 #[test]
17715 async fn expand_tilde_path_handles_relative_path() {
17716 let path = expand_tilde_path("relative/path");
17717 assert_eq!(path, PathBuf::from("relative/path"));
17718 }
17719
17720 #[test]
17721 async fn expand_tilde_path_expands_tilde_when_home_set() {
17722 let path = expand_tilde_path("~/.zeroclaw");
17725 if std::env::var("HOME").is_ok() {
17728 assert!(
17729 !path.to_string_lossy().starts_with('~'),
17730 "Tilde should be expanded when HOME is set"
17731 );
17732 }
17733 }
17734
17735 fn has_test_table(raw: &str, table: &str) -> bool {
17738 let exact = format!("[{table}]");
17739 let nested = format!("[{table}.");
17740 raw.lines()
17741 .map(str::trim)
17742 .any(|line| line == exact || line.starts_with(&nested))
17743 }
17744
17745 fn parse_test_config(raw: &str) -> Config {
17746 let mut merged = raw.trim().to_string();
17747 for table in [
17748 "data_retention",
17749 "cloud_ops",
17750 "conversational_ai",
17751 "security",
17752 "security_ops",
17753 ] {
17754 if has_test_table(&merged, table) {
17755 continue;
17756 }
17757 if !merged.is_empty() {
17758 merged.push_str("\n\n");
17759 }
17760 merged.push('[');
17761 merged.push_str(table);
17762 merged.push(']');
17763 }
17764 merged.push('\n');
17765 let mut config: Config = toml::from_str(&merged).unwrap();
17772 config
17773 .risk_profiles
17774 .entry("default".to_string())
17775 .or_default()
17776 .ensure_default_auto_approve();
17777 config
17778 }
17779
17780 #[test]
17781 async fn http_request_config_default_has_correct_values() {
17782 let cfg = HttpRequestConfig::default();
17783 assert_eq!(cfg.timeout_secs, 30);
17784 assert_eq!(cfg.max_response_size, 1_000_000);
17785 assert!(cfg.enabled);
17786 assert_eq!(cfg.allowed_domains, vec!["*".to_string()]);
17787 assert!(!cfg.allow_private_hosts);
17788 assert!(cfg.allowed_private_hosts.is_empty());
17789 assert!(cfg.secrets.is_empty());
17790 }
17791
17792 #[test]
17793 async fn http_request_config_deserializes_allowed_private_hosts() {
17794 let c = parse_test_config(
17795 r#"
17796[http_request]
17797allowed_domains = ["example.com"]
17798allowed_private_hosts = ["localhost", "10.0.0.1"]
17799"#,
17800 );
17801
17802 assert_eq!(
17803 c.http_request.allowed_private_hosts,
17804 vec!["localhost".to_string(), "10.0.0.1".to_string()]
17805 );
17806 }
17807
17808 #[test]
17809 async fn http_request_config_deserializes_auth_secrets() {
17810 let c = parse_test_config(
17811 r#"
17812[http_request.secrets]
17813api_token = "Bearer test-token"
17814"#,
17815 );
17816
17817 assert_eq!(
17818 c.http_request.secrets.get("api_token").map(String::as_str),
17819 Some("Bearer test-token")
17820 );
17821 }
17822
17823 #[test]
17824 async fn http_request_auth_secret_names_are_validated() {
17825 let mut config = Config::default();
17826 config
17827 .http_request
17828 .secrets
17829 .insert("bad.name".to_string(), "Bearer test-token".to_string());
17830
17831 let err = config.validate().expect_err("invalid secret name");
17832 assert!(
17833 err.to_string().contains("http_request.secrets.bad.name"),
17834 "validation error must name the bad auth secret path: {err}"
17835 );
17836 }
17837
17838 #[test]
17839 async fn config_default_has_sane_values() {
17840 let c = Config::default();
17841 assert!(c.providers.models.is_empty());
17843 assert!(c.providers.models.iter_entries().next().is_none());
17844 assert!(!c.skills.open_skills_enabled);
17845 assert!(!c.skills.allow_scripts);
17846 assert!(!c.skills.install_suggestions.enabled);
17847 assert_eq!(
17848 c.skills.prompt_injection_mode,
17849 SkillsPromptInjectionMode::Full
17850 );
17851 assert!(c.data_dir.to_string_lossy().contains("data"));
17852 assert!(c.config_path.to_string_lossy().contains("config.toml"));
17853 }
17854
17855 #[test]
17856 async fn skills_install_suggestions_config_deserializes_enabled() {
17857 let c = parse_test_config(
17858 r#"
17859[skills.install_suggestions]
17860enabled = true
17861"#,
17862 );
17863
17864 assert!(c.skills.install_suggestions.enabled);
17865 }
17866
17867 #[test]
17868 async fn skills_install_suggestions_config_accepts_hyphen_alias() {
17869 let c = parse_test_config(
17870 r#"
17871[skills.install-suggestions]
17872enabled = true
17873"#,
17874 );
17875
17876 assert!(c.skills.install_suggestions.enabled);
17877 }
17878
17879 fn capture_log_events() -> tokio::sync::broadcast::Receiver<serde_json::Value> {
17880 ::zeroclaw_log::try_install_capture_subscriber();
17881 ::zeroclaw_log::subscribe_or_install()
17882 }
17883
17884 fn drain_captured(rx: &mut tokio::sync::broadcast::Receiver<serde_json::Value>) -> String {
17885 let mut buf = String::new();
17886 while let Ok(value) = rx.try_recv() {
17887 buf.push_str(&serde_json::to_string(&value).unwrap_or_default());
17888 buf.push('\n');
17889 }
17890 buf
17891 }
17892
17893 #[test]
17894 async fn config_dir_creation_error_mentions_openrc_and_path() {
17895 let msg = config_dir_creation_error(Path::new("/etc/zeroclaw"));
17896 assert!(msg.contains("/etc/zeroclaw"));
17897 assert!(msg.contains("OpenRC"));
17898 assert!(msg.contains("zeroclaw"));
17899 }
17900
17901 #[test]
17902 async fn config_schema_export_contains_expected_contract_shape() {
17903 #[cfg(feature = "schema-export")]
17904 let schema = schemars::schema_for!(Config);
17905 let schema_json = serde_json::to_value(&schema).expect("schema should serialize to json");
17906
17907 assert_eq!(
17908 schema_json
17909 .get("$schema")
17910 .and_then(serde_json::Value::as_str),
17911 Some("https://json-schema.org/draft/2020-12/schema")
17912 );
17913
17914 let properties = schema_json
17915 .get("properties")
17916 .and_then(serde_json::Value::as_object)
17917 .expect("schema should expose top-level properties");
17918
17919 assert!(properties.contains_key("providers"));
17920 assert!(properties.contains_key("skills"));
17921 assert!(properties.contains_key("gateway"));
17922 assert!(properties.contains_key("channels"));
17923 assert!(!properties.contains_key("workspace_dir"));
17924 assert!(!properties.contains_key("config_path"));
17925 assert!(!properties.contains_key("model_providers"));
17926 assert!(!properties.contains_key("tts_providers"));
17927 assert!(!properties.contains_key("transcription_providers"));
17928 assert!(!properties.contains_key("default_model_provider"));
17930 assert!(!properties.contains_key("api_key"));
17931 assert!(!properties.contains_key("default_model"));
17932
17933 assert!(
17934 schema_json
17935 .get("$defs")
17936 .and_then(serde_json::Value::as_object)
17937 .is_some(),
17938 "schema should include reusable type definitions"
17939 );
17940 }
17941
17942 #[cfg(unix)]
17943 #[test]
17944 async fn save_sets_config_permissions_on_new_file() {
17945 let temp = TempDir::new().expect("temp dir");
17946 let config_path = temp.path().join("config.toml");
17947 let workspace_dir = temp.path().join("workspace");
17948
17949 let config = Config {
17950 config_path: config_path.clone(),
17951 data_dir: workspace_dir,
17952 ..Default::default()
17953 };
17954
17955 config.save().await.expect("save config");
17956
17957 let mode = std::fs::metadata(&config_path)
17958 .expect("config metadata")
17959 .permissions()
17960 .mode()
17961 & 0o777;
17962 assert_eq!(mode, 0o600);
17963 }
17964
17965 #[test]
17966 async fn validate_rejects_reply_min_interval_above_upper_bound() {
17967 let mut config = Config::default();
17968 let mut tg = TelegramConfig {
17969 bot_token: "tok".into(),
17970 ..Default::default()
17971 };
17972 tg.reply_min_interval_secs = REPLY_MIN_INTERVAL_MAX_SECS + 1;
17973 config.channels.telegram.insert("default".to_string(), tg);
17974 let err = config.validate().expect_err("over-bound must be rejected");
17975 let msg = err.to_string();
17976 assert!(
17977 msg.contains("channels.telegram.default.reply_min_interval_secs"),
17978 "error must name the offending path; got: {msg}"
17979 );
17980 }
17981
17982 #[test]
17983 async fn validate_accepts_reply_min_interval_at_upper_bound() {
17984 let mut config = Config::default();
17985 let mut tg = TelegramConfig {
17986 bot_token: "tok".into(),
17987 ..Default::default()
17988 };
17989 tg.reply_min_interval_secs = REPLY_MIN_INTERVAL_MAX_SECS;
17990 config.channels.telegram.insert("default".to_string(), tg);
17991 config.validate().expect("documented upper bound must pass");
17992 }
17993
17994 #[test]
17995 async fn validate_rejects_reply_queue_depth_above_ceiling() {
17996 let mut config = Config::default();
17997 let mut tg = TelegramConfig {
17998 bot_token: "tok".into(),
17999 ..Default::default()
18000 };
18001 tg.reply_min_interval_secs = 1;
18002 tg.reply_queue_depth_max = REPLY_QUEUE_DEPTH_CEILING + 1;
18003 config.channels.telegram.insert("default".to_string(), tg);
18004 let err = config
18005 .validate()
18006 .expect_err("over-ceiling depth must be rejected");
18007 let msg = err.to_string();
18008 assert!(
18009 msg.contains("channels.telegram.default.reply_queue_depth_max"),
18010 "error must name the offending path; got: {msg}"
18011 );
18012 }
18013
18014 #[test]
18015 async fn validate_accepts_reply_queue_depth_at_ceiling() {
18016 let mut config = Config::default();
18017 let mut tg = TelegramConfig {
18018 bot_token: "tok".into(),
18019 ..Default::default()
18020 };
18021 tg.reply_min_interval_secs = 1;
18022 tg.reply_queue_depth_max = REPLY_QUEUE_DEPTH_CEILING;
18023 config.channels.telegram.insert("default".to_string(), tg);
18024 config.validate().expect("documented ceiling must pass");
18025 }
18026
18027 #[test]
18028 async fn validate_accepts_reply_queue_depth_zero_meaning_default() {
18029 let mut config = Config::default();
18032 let mut tg = TelegramConfig {
18033 bot_token: "tok".into(),
18034 ..Default::default()
18035 };
18036 tg.reply_min_interval_secs = 1;
18037 tg.reply_queue_depth_max = 0;
18038 config.channels.telegram.insert("default".to_string(), tg);
18039 config
18040 .validate()
18041 .expect("zero depth means default; must pass");
18042 }
18043
18044 #[test]
18045 async fn observability_config_default() {
18046 let o = ObservabilityConfig::default();
18047 assert_eq!(o.backend, "none");
18048 assert_eq!(o.log_persistence, "rolling");
18049 assert_eq!(o.log_persistence_path, "state/runtime-trace.jsonl");
18050 assert_eq!(o.log_persistence_max_entries, 200);
18051 assert_eq!(o.log_tool_io, "redacted");
18052 assert_eq!(o.log_tool_io_truncate_bytes, 40960);
18053 assert!(o.log_tool_io_denylist.is_empty());
18054 }
18055
18056 #[test]
18057 async fn risk_profile_default_mirrors_v2_autonomy_safety_defaults() {
18058 let a = RiskProfileConfig::default();
18059 assert_eq!(a.level, AutonomyLevel::Supervised);
18060 assert!(a.workspace_only);
18061 assert!(a.allowed_commands.contains(&"git".to_string()));
18062 assert!(a.allowed_commands.contains(&"cargo".to_string()));
18063 assert!(
18064 !a.forbidden_paths.is_empty(),
18065 "default forbidden_paths must not be empty"
18066 );
18067 #[cfg(not(target_os = "windows"))]
18068 assert!(
18069 a.forbidden_paths.iter().any(|p| p == "/etc"),
18070 "Default forbidden_paths must include /etc on Unix"
18071 );
18072 #[cfg(target_os = "windows")]
18073 assert!(
18074 a.forbidden_paths.iter().any(|p| p == "C:\\Windows"),
18075 "Default forbidden_paths must include C:\\Windows on Windows"
18076 );
18077 assert!(
18078 a.forbidden_paths.contains(&"~/.ssh".to_string()),
18079 "Default forbidden_paths must include ~/.ssh"
18080 );
18081 assert!(a.require_approval_for_medium_risk);
18082 assert!(a.block_high_risk_commands);
18083 assert!(a.shell_env_passthrough.is_empty());
18084 assert!(a.allowed_tools.is_empty());
18085 }
18086
18087 #[test]
18088 async fn runtime_config_default() {
18089 let r = RuntimeConfig::default();
18090 assert_eq!(r.kind, "native");
18091 assert_eq!(r.docker.image, "alpine:3.20");
18092 assert_eq!(r.docker.network, "none");
18093 assert_eq!(r.docker.memory_limit_mb, Some(512));
18094 assert_eq!(r.docker.cpu_limit, Some(1.0));
18095 assert!(r.docker.read_only_rootfs);
18096 assert!(r.docker.mount_workspace);
18097 }
18098
18099 #[test]
18100 async fn heartbeat_config_default() {
18101 let h = HeartbeatConfig::default();
18102 assert!(!h.enabled);
18106 assert!(h.agent.is_empty());
18107 assert_eq!(h.interval_minutes, 30);
18108 assert!(h.message.is_none());
18109 assert!(h.target.is_none());
18110 assert!(h.to.is_none());
18111 }
18112
18113 #[test]
18114 async fn heartbeat_config_parses_delivery_aliases() {
18115 let raw = r#"
18116enabled = true
18117interval_minutes = 10
18118message = "Ping"
18119channel = "telegram"
18120recipient = "42"
18121"#;
18122 let parsed: HeartbeatConfig = toml::from_str(raw).unwrap();
18123 assert!(parsed.enabled);
18124 assert_eq!(parsed.interval_minutes, 10);
18125 assert_eq!(parsed.message.as_deref(), Some("Ping"));
18126 assert_eq!(parsed.target.as_deref(), Some("telegram"));
18127 assert_eq!(parsed.to.as_deref(), Some("42"));
18128 }
18129
18130 #[test]
18131 async fn scheduler_config_default() {
18132 let s = SchedulerConfig::default();
18133 assert!(s.enabled);
18134 assert!(s.catch_up_on_startup);
18135 assert_eq!(s.max_run_history, 50);
18136 }
18137
18138 #[test]
18139 async fn scheduler_config_serde_roundtrip() {
18140 let s = SchedulerConfig {
18141 enabled: false,
18142 max_tasks: 16,
18143 max_concurrent: 2,
18144 catch_up_on_startup: false,
18145 max_run_history: 100,
18146 };
18147 let json = serde_json::to_string(&s).unwrap();
18148 let parsed: SchedulerConfig = serde_json::from_str(&json).unwrap();
18149 assert!(!parsed.enabled);
18150 assert!(!parsed.catch_up_on_startup);
18151 assert_eq!(parsed.max_run_history, 100);
18152 }
18153
18154 #[test]
18155 async fn config_defaults_scheduler_when_section_missing() {
18156 let toml_str = r#"
18157workspace_dir = "/tmp/workspace"
18158config_path = "/tmp/config.toml"
18159default_temperature = 0.7
18160"#;
18161
18162 let parsed = parse_test_config(toml_str);
18163 assert!(parsed.scheduler.enabled);
18164 assert!(parsed.scheduler.catch_up_on_startup);
18165 assert_eq!(parsed.scheduler.max_run_history, 50);
18166 assert!(parsed.cron.is_empty());
18167 }
18168
18169 #[test]
18170 async fn memory_config_default_hygiene_settings() {
18171 let m = MemoryConfig::default();
18172 assert_eq!(m.backend, "sqlite");
18173 assert!(m.auto_save);
18174 assert!(m.hygiene_enabled);
18175 assert_eq!(m.archive_after_days, 7);
18176 assert_eq!(m.purge_after_days, 30);
18177 assert_eq!(m.conversation_retention_days, 30);
18178 assert_eq!(m.search_mode, SearchMode::Hybrid);
18179 }
18180
18181 #[test]
18182 async fn search_mode_config_deserialization() {
18183 let toml_str = r#"
18184workspace_dir = "/tmp/workspace"
18185config_path = "/tmp/config.toml"
18186default_temperature = 0.7
18187
18188[memory]
18189backend = "sqlite"
18190auto_save = true
18191search_mode = "bm25"
18192"#;
18193 let parsed = parse_test_config(toml_str);
18194 assert_eq!(parsed.memory.search_mode, SearchMode::Bm25);
18195
18196 let toml_str_embedding = r#"
18197workspace_dir = "/tmp/workspace"
18198config_path = "/tmp/config.toml"
18199default_temperature = 0.7
18200
18201[memory]
18202backend = "sqlite"
18203auto_save = true
18204search_mode = "embedding"
18205"#;
18206 let parsed = parse_test_config(toml_str_embedding);
18207 assert_eq!(parsed.memory.search_mode, SearchMode::Embedding);
18208
18209 let toml_str_hybrid = r#"
18210workspace_dir = "/tmp/workspace"
18211config_path = "/tmp/config.toml"
18212default_temperature = 0.7
18213
18214[memory]
18215backend = "sqlite"
18216auto_save = true
18217search_mode = "hybrid"
18218"#;
18219 let parsed = parse_test_config(toml_str_hybrid);
18220 assert_eq!(parsed.memory.search_mode, SearchMode::Hybrid);
18221 }
18222
18223 #[test]
18224 async fn search_mode_defaults_to_hybrid_when_omitted() {
18225 let toml_str = r#"
18226workspace_dir = "/tmp/workspace"
18227config_path = "/tmp/config.toml"
18228default_temperature = 0.7
18229
18230[memory]
18231backend = "sqlite"
18232auto_save = true
18233"#;
18234 let parsed = parse_test_config(toml_str);
18235 assert_eq!(parsed.memory.search_mode, SearchMode::Hybrid);
18236 }
18237
18238 #[test]
18239 async fn search_mode_serde_roundtrip() {
18240 let json_bm25 = serde_json::to_string(&SearchMode::Bm25).unwrap();
18241 assert_eq!(json_bm25, "\"bm25\"");
18242 let parsed: SearchMode = serde_json::from_str(&json_bm25).unwrap();
18243 assert_eq!(parsed, SearchMode::Bm25);
18244
18245 let json_embedding = serde_json::to_string(&SearchMode::Embedding).unwrap();
18246 assert_eq!(json_embedding, "\"embedding\"");
18247 let parsed: SearchMode = serde_json::from_str(&json_embedding).unwrap();
18248 assert_eq!(parsed, SearchMode::Embedding);
18249
18250 let json_hybrid = serde_json::to_string(&SearchMode::Hybrid).unwrap();
18251 assert_eq!(json_hybrid, "\"hybrid\"");
18252 let parsed: SearchMode = serde_json::from_str(&json_hybrid).unwrap();
18253 assert_eq!(parsed, SearchMode::Hybrid);
18254 }
18255
18256 #[test]
18257 async fn storage_two_tier_defaults_empty() {
18258 let storage = StorageConfig::default();
18259 assert!(storage.sqlite.is_empty());
18260 assert!(storage.postgres.is_empty());
18261 assert!(storage.qdrant.is_empty());
18262 assert!(storage.markdown.is_empty());
18263 assert!(storage.lucid.is_empty());
18264 }
18265
18266 #[test]
18267 async fn storage_postgres_alias_pgvector_roundtrip() {
18268 let toml = r#"
18269 [postgres.default]
18270 db_url = "postgres://user:pw@host/db"
18271 vector_enabled = true
18272 vector_dimensions = 768
18273 "#;
18274 let parsed: StorageConfig = toml::from_str(toml).unwrap();
18275 let pg = parsed.postgres.get("default").expect("alias present");
18276 assert_eq!(pg.db_url.as_deref(), Some("postgres://user:pw@host/db"));
18277 assert!(pg.vector_enabled);
18278 assert_eq!(pg.vector_dimensions, 768);
18279 }
18280
18281 #[test]
18282 async fn storage_postgres_pgvector_defaults_when_omitted() {
18283 let toml = r#"
18284 [postgres.default]
18285 "#;
18286 let parsed: StorageConfig = toml::from_str(toml).unwrap();
18287 let pg = parsed.postgres.get("default").expect("alias present");
18288 assert!(!pg.vector_enabled);
18289 assert_eq!(pg.vector_dimensions, 1536);
18290 assert_eq!(pg.schema, "public");
18291 assert_eq!(pg.table, "memories");
18292 }
18293
18294 #[test]
18295 async fn ollama_alias_tuning_fields_roundtrip() {
18296 let toml = r#"
18302 num_ctx = 16384
18303 num_predict = 4096
18304 temperature_override = 0.5
18305 "#;
18306 let parsed: OllamaModelProviderConfig = toml::from_str(toml).unwrap();
18307 assert_eq!(parsed.num_ctx, Some(16384));
18308 assert_eq!(parsed.num_predict, Some(4096));
18309 assert_eq!(parsed.temperature_override, Some(0.5));
18310
18311 let serialized = toml::to_string(&parsed).unwrap();
18312 let reparsed: OllamaModelProviderConfig = toml::from_str(&serialized).unwrap();
18313 assert_eq!(reparsed.num_ctx, Some(16384));
18314 assert_eq!(reparsed.num_predict, Some(4096));
18315 assert_eq!(reparsed.temperature_override, Some(0.5));
18316 }
18317
18318 #[test]
18319 async fn ollama_alias_tuning_fields_default_to_none() {
18320 let toml = r#"
18321 api_key = "sk-test"
18322 "#;
18323 let parsed: OllamaModelProviderConfig = toml::from_str(toml).unwrap();
18324 assert!(parsed.num_ctx.is_none());
18325 assert!(parsed.num_predict.is_none());
18326 assert!(parsed.temperature_override.is_none());
18327 }
18328
18329 #[test]
18330 async fn channels_default() {
18331 let c = ChannelsConfig::default();
18332 assert!(c.cli);
18333 assert!(c.telegram.is_empty());
18334 assert!(c.discord.is_empty());
18335 assert!(c.wecom_ws.is_empty());
18336 assert!(!c.show_tool_calls);
18337 assert_eq!(
18338 c.max_concurrent_per_channel,
18339 default_channel_max_concurrent_per_channel()
18340 );
18341 }
18342
18343 #[test]
18344 async fn channels_max_concurrent_per_channel_defaults_and_round_trips() {
18345 let parsed: ChannelsConfig = toml::from_str("cli = true").unwrap();
18346 assert_eq!(
18347 parsed.max_concurrent_per_channel,
18348 default_channel_max_concurrent_per_channel()
18349 );
18350
18351 let parsed: ChannelsConfig =
18352 toml::from_str("cli = true\nmax_concurrent_per_channel = 2").unwrap();
18353 assert_eq!(parsed.max_concurrent_per_channel, 2);
18354
18355 let toml_str = toml::to_string_pretty(&parsed).unwrap();
18356 let reparsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
18357 assert_eq!(reparsed.max_concurrent_per_channel, 2);
18358 }
18359
18360 #[test]
18361 async fn validate_rejects_zero_channel_max_concurrent_per_channel() {
18362 let mut config = Config::default();
18363 config.channels.max_concurrent_per_channel = 0;
18364
18365 let err = config
18366 .validate()
18367 .expect_err("zero channel concurrency budget must fail validate");
18368 assert!(
18369 err.to_string()
18370 .contains("channels.max_concurrent_per_channel must be greater than 0"),
18371 "got: {err}"
18372 );
18373 }
18374
18375 #[test]
18376 async fn wecom_ws_config_serde_defaults_and_secret_metadata() {
18377 let toml = r#"
18378 enabled = true
18379 bot_id = "bot-123"
18380 secret = "sk-test"
18381 allowed_users = ["zeroclaw_user"]
18382 allowed_groups = ["zeroclaw_group"]
18383 bot_name = "danya"
18384 proxy_url = "http://127.0.0.1:7890"
18385 "#;
18386 let parsed: WeComWsConfig = toml::from_str(toml).unwrap();
18387
18388 assert!(parsed.enabled);
18389 assert_eq!(parsed.bot_id, "bot-123");
18390 assert_eq!(parsed.secret, "sk-test");
18391 assert_eq!(parsed.allowed_users, vec!["zeroclaw_user"]);
18392 assert_eq!(parsed.allowed_groups, vec!["zeroclaw_group"]);
18393 assert_eq!(parsed.bot_name.as_deref(), Some("danya"));
18394 assert_eq!(parsed.file_retention_days, 7);
18395 assert_eq!(parsed.max_file_size_mb, 20);
18396 assert_eq!(parsed.stream_mode, StreamMode::Partial);
18397 assert_eq!(parsed.proxy_url.as_deref(), Some("http://127.0.0.1:7890"));
18398 assert!(parsed.excluded_tools.is_empty());
18399 assert_eq!(WeComWsConfig::default().file_retention_days, 7);
18400 assert_eq!(WeComWsConfig::default().max_file_size_mb, 20);
18401 assert_eq!(WeComWsConfig::default().stream_mode, StreamMode::Partial);
18402 assert!(WeComWsConfig::default().bot_name.is_none());
18403 assert!(WeComWsConfig::default().proxy_url.is_none());
18404 assert!(WeComWsConfig::prop_is_secret("channels.wecom_ws.secret"));
18405 }
18406
18407 #[test]
18408 async fn config_parses_wecom_ws_separate_from_wecom_webhook() {
18409 let toml = r#"
18410 [channels.wecom.default]
18411 enabled = true
18412 webhook_key = "webhook-key"
18413
18414 [channels.wecom_ws.default]
18415 enabled = true
18416 bot_id = "bot-123"
18417 secret = "sk-test"
18418 allowed_users = ["zeroclaw_user"]
18419 "#;
18420 let parsed: Config = toml::from_str(toml).unwrap();
18421
18422 assert_eq!(
18423 parsed.channels.wecom.get("default").unwrap().webhook_key,
18424 "webhook-key"
18425 );
18426 let ws = parsed.channels.wecom_ws.get("default").unwrap();
18427 assert_eq!(ws.bot_id, "bot-123");
18428 assert_eq!(ws.allowed_users, vec!["zeroclaw_user"]);
18429 assert_eq!(ws.stream_mode, StreamMode::Partial);
18430 }
18431
18432 #[test]
18435 async fn config_toml_roundtrip() {
18436 let config = Config {
18437 degraded_security: Vec::new(),
18438 schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
18439 providers: {
18440 let mut p = crate::providers::Providers::default();
18441 p.models.openrouter.insert(
18442 "default".to_string(),
18443 OpenRouterModelProviderConfig {
18444 base: ModelProviderConfig {
18445 api_key: Some("sk-test-key".into()),
18446 model: Some("gpt-4o".into()),
18447 temperature: Some(0.5),
18448 timeout_secs: Some(120),
18449 ..Default::default()
18450 },
18451 },
18452 );
18453 p
18454 },
18455 model_routes: Vec::new(),
18456 embedding_routes: Vec::new(),
18457 data_dir: PathBuf::from("/tmp/test/workspace"),
18458 config_path: PathBuf::from("/tmp/test/config.toml"),
18459 observability: ObservabilityConfig {
18460 backend: "log".into(),
18461 ..ObservabilityConfig::default()
18462 },
18463 risk_profiles: {
18464 let mut m = HashMap::new();
18465 m.insert(
18466 "default".into(),
18467 RiskProfileConfig {
18468 level: AutonomyLevel::Full,
18469 workspace_only: false,
18470 allowed_commands: vec!["docker".into()],
18471 forbidden_paths: vec!["/secret".into()],
18472 require_approval_for_medium_risk: false,
18473 block_high_risk_commands: true,
18474 shell_env_passthrough: vec!["DATABASE_URL".into()],
18475 auto_approve: vec!["file_read".into()],
18476 always_ask: vec![],
18477 allowed_roots: vec![],
18478 allowed_tools: vec![],
18479 excluded_tools: vec![],
18480 ..RiskProfileConfig::default()
18481 },
18482 );
18483 m
18484 },
18485 trust: crate::scattered_types::TrustConfig::default(),
18486 backup: BackupConfig::default(),
18487 data_retention: DataRetentionConfig::default(),
18488 cloud_ops: CloudOpsConfig::default(),
18489 conversational_ai: ConversationalAiConfig::default(),
18490 security: SecurityConfig::default(),
18491 security_ops: SecurityOpsConfig::default(),
18492 runtime: RuntimeConfig {
18493 kind: "docker".into(),
18494 ..RuntimeConfig::default()
18495 },
18496 reliability: ReliabilityConfig::default(),
18497 scheduler: SchedulerConfig::default(),
18498 skills: SkillsConfig::default(),
18499 pipeline: PipelineConfig::default(),
18500 query_classification: QueryClassificationConfig::default(),
18501 heartbeat: HeartbeatConfig {
18502 enabled: true,
18503 interval_minutes: 15,
18504 two_phase: true,
18505 message: Some("Check London time".into()),
18506 target: Some("telegram".into()),
18507 to: Some("123456".into()),
18508 ..HeartbeatConfig::default()
18509 },
18510 cron: HashMap::new(),
18511 acp: AcpConfig::default(),
18512 channels: ChannelsConfig {
18513 cli: true,
18514 telegram: HashMap::from([(
18515 "default".to_string(),
18516 TelegramConfig {
18517 enabled: true,
18518 bot_token: "123:ABC".into(),
18519 stream_mode: StreamMode::default(),
18520 draft_update_interval_ms: default_draft_update_interval_ms(),
18521 interrupt_on_new_message: false,
18522 mention_only: false,
18523 ack_reactions: None,
18524 proxy_url: None,
18525 approval_timeout_secs: default_telegram_approval_timeout_secs(),
18526 excluded_tools: vec![],
18527 reply_min_interval_secs: 0,
18528 reply_queue_depth_max: 0,
18529 },
18530 )]),
18531 discord: HashMap::new(),
18532 slack: HashMap::new(),
18533 mattermost: HashMap::new(),
18534 webhook: HashMap::new(),
18535 imessage: HashMap::new(),
18536 matrix: HashMap::new(),
18537 signal: HashMap::new(),
18538 whatsapp: HashMap::new(),
18539 linq: HashMap::new(),
18540 wati: HashMap::new(),
18541 nextcloud_talk: HashMap::new(),
18542 email: HashMap::new(),
18543 gmail_push: HashMap::new(),
18544 irc: HashMap::new(),
18545 twitch: HashMap::new(),
18546 lark: HashMap::new(),
18547 line: HashMap::new(),
18548 dingtalk: HashMap::new(),
18549 wecom: HashMap::new(),
18550 wecom_ws: HashMap::new(),
18551 wechat: HashMap::new(),
18552 qq: HashMap::new(),
18553 twitter: HashMap::new(),
18554 mochat: HashMap::new(),
18555 nostr: HashMap::new(),
18556 clawdtalk: HashMap::new(),
18557 reddit: HashMap::new(),
18558 bluesky: HashMap::new(),
18559 voice_call: HashMap::new(),
18560 voice_duplex: HashMap::new(),
18561 voice_wake: HashMap::new(),
18562 mqtt: HashMap::new(),
18563 amqp: HashMap::new(),
18564 message_timeout_secs: 300,
18565 max_concurrent_per_channel: default_channel_max_concurrent_per_channel(),
18566 ack_reactions: true,
18567 show_tool_calls: true,
18568 session_persistence: true,
18569 session_backend: default_session_backend(),
18570 session_ttl_hours: 0,
18571 debounce_ms: 0,
18572 },
18573 memory: MemoryConfig::default(),
18574 storage: StorageConfig::default(),
18575 tunnel: TunnelConfig::default(),
18576 gateway: GatewayConfig::default(),
18577 wss: WssConfig::default(),
18578 composio: ComposioConfig::default(),
18579 microsoft365: Microsoft365Config::default(),
18580 secrets: SecretsConfig::default(),
18581 browser: BrowserConfig::default(),
18582 browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
18583 http_request: HttpRequestConfig::default(),
18584 multimodal: MultimodalConfig::default(),
18585 media_pipeline: MediaPipelineConfig::default(),
18586 web_fetch: WebFetchConfig::default(),
18587 link_enricher: LinkEnricherConfig::default(),
18588 text_browser: TextBrowserConfig::default(),
18589 web_search: WebSearchConfig::default(),
18590 project_intel: ProjectIntelConfig::default(),
18591 google_workspace: GoogleWorkspaceConfig::default(),
18592 proxy: ProxyConfig::default(),
18593 pacing: PacingConfig::default(),
18594 cost: CostConfig::default(),
18595 peripherals: PeripheralsConfig::default(),
18596 delegate: DelegateToolConfig::default(),
18597 agents: HashMap::new(),
18598 runtime_profiles: HashMap::new(),
18599 skill_bundles: HashMap::new(),
18600 knowledge_bundles: HashMap::new(),
18601 mcp_bundles: HashMap::new(),
18602 peer_groups: HashMap::new(),
18603 hooks: HooksConfig::default(),
18604 hardware: HardwareConfig::default(),
18605 transcription: TranscriptionConfig::default(),
18606 tts: TtsConfig::default(),
18607 mcp: McpConfig::default(),
18608 nodes: NodesConfig::default(),
18609 onboard_state: OnboardStateConfig::default(),
18610 notion: NotionConfig::default(),
18611 jira: JiraConfig::default(),
18612 node_transport: NodeTransportConfig::default(),
18613 knowledge: KnowledgeConfig::default(),
18614 linkedin: LinkedInConfig::default(),
18615 image_gen: ImageGenConfig::default(),
18616 file_upload: FileUploadConfig::default(),
18617 file_upload_bundle: FileUploadBundleConfig::default(),
18618 file_download: FileDownloadConfig::default(),
18619 plugins: PluginsConfig::default(),
18620 locale: None,
18621 verifiable_intent: VerifiableIntentConfig::default(),
18622 claude_code: ClaudeCodeConfig::default(),
18623 claude_code_runner: ClaudeCodeRunnerConfig::default(),
18624 codex_cli: CodexCliConfig::default(),
18625 gemini_cli: GeminiCliConfig::default(),
18626 opencode_cli: OpenCodeCliConfig::default(),
18627 sop: SopConfig::default(),
18628 shell_tool: ShellToolConfig::default(),
18629 escalation: EscalationConfig::default(),
18630 env_overridden_paths: std::collections::HashSet::new(),
18631 pre_override_snapshots: std::collections::HashMap::new(),
18632 onepassword_reference_snapshots: std::collections::HashMap::new(),
18633 dirty_paths: std::collections::HashSet::new(),
18634 };
18635 let toml_str = toml::to_string_pretty(&config).unwrap();
18638 let parsed = parse_test_config(&toml_str);
18639
18640 assert_eq!(parsed.providers.models.len(), config.providers.models.len());
18641 assert_eq!(parsed.observability.backend, "log");
18642 assert_eq!(parsed.observability.log_persistence, "rolling");
18643 let default_profile = parsed.risk_profiles.get("default").unwrap();
18644 assert_eq!(default_profile.level, AutonomyLevel::Full);
18645 assert!(!default_profile.workspace_only);
18646 assert_eq!(parsed.runtime.kind, "docker");
18647 assert!(parsed.heartbeat.enabled);
18648 assert_eq!(parsed.heartbeat.interval_minutes, 15);
18649 assert_eq!(
18650 parsed.heartbeat.message.as_deref(),
18651 Some("Check London time")
18652 );
18653 assert_eq!(parsed.heartbeat.target.as_deref(), Some("telegram"));
18654 assert_eq!(parsed.heartbeat.to.as_deref(), Some("123456"));
18655 assert!(!parsed.channels.telegram.is_empty());
18656 assert_eq!(
18657 parsed.channels.telegram.get("default").unwrap().bot_token,
18658 "123:ABC"
18659 );
18660 }
18661
18662 #[test]
18663 async fn config_minimal_toml_uses_defaults() {
18664 let minimal = r#"
18665workspace_dir = "/tmp/ws"
18666config_path = "/tmp/config.toml"
18667default_temperature = 0.7
18668"#;
18669 let parsed = parse_test_config(minimal);
18670 assert!(
18671 parsed
18672 .providers
18673 .models
18674 .iter_entries()
18675 .next()
18676 .map(|(_, _, e)| e)
18677 .and_then(|e| e.api_key.as_deref())
18678 .is_none()
18679 );
18680 assert_eq!(parsed.observability.backend, "none");
18681 assert_eq!(parsed.observability.log_persistence, "rolling");
18682 assert_eq!(
18686 parsed
18687 .risk_profiles
18688 .get("default")
18689 .expect("migration synthesized risk_profiles.default")
18690 .level,
18691 AutonomyLevel::Supervised
18692 );
18693 assert_eq!(parsed.runtime.kind, "native");
18694 assert!(!parsed.heartbeat.enabled);
18696 assert!(parsed.channels.cli);
18697 assert!(parsed.memory.hygiene_enabled);
18698 assert_eq!(parsed.memory.archive_after_days, 7);
18699 assert_eq!(parsed.memory.purge_after_days, 30);
18700 assert_eq!(parsed.memory.conversation_retention_days, 30);
18701 assert!(
18703 (parsed
18704 .providers
18705 .models
18706 .iter_entries()
18707 .next()
18708 .map(|(_, _, e)| e)
18709 .and_then(|e| e.temperature)
18710 .unwrap_or(0.7)
18711 - 0.7)
18712 .abs()
18713 < f64::EPSILON
18714 );
18715 assert_eq!(
18716 parsed
18717 .providers
18718 .models
18719 .iter_entries()
18720 .next()
18721 .map(|(_, _, e)| e)
18722 .and_then(|e| e.timeout_secs)
18723 .unwrap_or(120),
18724 DEFAULT_DELEGATE_TIMEOUT_SECS
18725 );
18726 }
18727
18728 #[test]
18731 async fn v2_autonomy_section_migrates_onto_risk_profiles_default() {
18732 let raw = r#"
18733schema_version = 2
18734default_temperature = 0.7
18735
18736[autonomy]
18737level = "full"
18738max_actions_per_hour = 99
18739auto_approve = ["file_read", "memory_recall", "http_request"]
18740"#;
18741 let parsed = crate::migration::migrate_to_current(raw).unwrap();
18742 let profile = parsed
18743 .risk_profiles
18744 .get("default")
18745 .expect("default profile");
18746 assert_eq!(profile.level, AutonomyLevel::Full);
18747 assert!(profile.auto_approve.contains(&"http_request".to_string()));
18748 let runtime = parsed
18749 .runtime_profiles
18750 .get("default")
18751 .expect("default runtime profile");
18752 assert_eq!(runtime.max_actions_per_hour, 99);
18753 }
18754
18755 #[test]
18758 async fn auto_approve_merges_user_entries_with_defaults() {
18759 let raw = r#"
18760default_temperature = 0.7
18761
18762[risk_profiles.default]
18763auto_approve = ["my_custom_tool", "another_tool"]
18764"#;
18765 let parsed = parse_test_config(raw);
18766 let profile = parsed.risk_profiles.get("default").unwrap();
18767 assert!(profile.auto_approve.contains(&"my_custom_tool".to_string()));
18768 assert!(profile.auto_approve.contains(&"another_tool".to_string()));
18769 for default_tool in &[
18770 "file_read",
18771 "memory_recall",
18772 "weather",
18773 "calculator",
18774 "web_fetch",
18775 ] {
18776 assert!(
18777 profile.auto_approve.contains(&String::from(*default_tool)),
18778 "default tool '{default_tool}' must be present"
18779 );
18780 }
18781 }
18782
18783 #[test]
18784 async fn default_auto_approve_includes_tool_search() {
18785 let defaults = default_auto_approve();
18786 assert!(defaults.contains(&"tool_search".to_string()));
18787 }
18788
18789 #[test]
18791 async fn auto_approve_empty_list_gets_defaults() {
18792 let raw = r#"
18793default_temperature = 0.7
18794
18795[risk_profiles.default]
18796auto_approve = []
18797"#;
18798 let parsed = parse_test_config(raw);
18799 let profile = parsed.risk_profiles.get("default").unwrap();
18800 for tool in &default_auto_approve() {
18801 assert!(
18802 profile.auto_approve.contains(tool),
18803 "default tool '{tool}' must be present"
18804 );
18805 }
18806 }
18807
18808 #[test]
18811 async fn auto_approve_defaults_when_no_risk_profile_section() {
18812 let raw = r#"
18813default_temperature = 0.7
18814"#;
18815 let parsed = parse_test_config(raw);
18816 let profile = parsed.risk_profiles.get("default").unwrap();
18817 for tool in &default_auto_approve() {
18818 assert!(
18819 profile.auto_approve.contains(tool),
18820 "default tool '{tool}' must be present"
18821 );
18822 }
18823 }
18824
18825 #[test]
18828 async fn auto_approve_no_duplicates() {
18829 let raw = r#"
18830default_temperature = 0.7
18831
18832[risk_profiles.default]
18833auto_approve = ["weather", "file_read"]
18834"#;
18835 let parsed = parse_test_config(raw);
18836 let profile = parsed.risk_profiles.get("default").unwrap();
18837 assert_eq!(
18838 profile
18839 .auto_approve
18840 .iter()
18841 .filter(|t| *t == "weather")
18842 .count(),
18843 1
18844 );
18845 assert_eq!(
18846 profile
18847 .auto_approve
18848 .iter()
18849 .filter(|t| *t == "file_read")
18850 .count(),
18851 1
18852 );
18853 }
18854
18855 #[test]
18856 async fn provider_timeout_secs_parses_from_toml() {
18857 let raw = r#"
18860default_temperature = 0.7
18861provider_timeout_secs = 300
18862"#;
18863 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
18864 assert_eq!(
18865 parsed
18866 .providers
18867 .models
18868 .find("openrouter", "default")
18869 .and_then(|e| e.timeout_secs)
18870 .unwrap_or(120),
18871 300
18872 );
18873 }
18874
18875 #[test]
18876 async fn extra_headers_parses_from_toml() {
18877 let raw = r#"
18880default_temperature = 0.7
18881
18882[extra_headers]
18883User-Agent = "MyApp/1.0"
18884X-Title = "zeroclaw"
18885"#;
18886 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
18887 let headers = &parsed
18888 .providers
18889 .models
18890 .find("openrouter", "default")
18891 .expect("synthesized openrouter.default model_provider")
18892 .extra_headers;
18893 assert_eq!(headers.len(), 2);
18894 assert_eq!(headers.get("User-Agent").unwrap(), "MyApp/1.0");
18895 assert_eq!(headers.get("X-Title").unwrap(), "zeroclaw");
18896 }
18897
18898 #[test]
18899 async fn extra_headers_defaults_to_empty() {
18900 let raw = r#"
18901default_temperature = 0.7
18902"#;
18903 let parsed = parse_test_config(raw);
18904 assert!(
18905 parsed
18906 .providers
18907 .models
18908 .iter_entries()
18909 .next()
18910 .map(|(_, _, e)| e.extra_headers.is_empty())
18911 .unwrap_or(true)
18912 );
18913 }
18914
18915 #[test]
18916 async fn storage_postgres_dburl_alias_deserializes() {
18917 let raw = r#"
18918default_temperature = 0.7
18919
18920[storage.postgres.default]
18921dbURL = "postgres://user:pw@host/db"
18922schema = "public"
18923table = "memories"
18924connect_timeout_secs = 12
18925"#;
18926
18927 let parsed = parse_test_config(raw);
18928 let pg = parsed
18929 .storage
18930 .postgres
18931 .get("default")
18932 .expect("postgres.default present");
18933 assert_eq!(pg.db_url.as_deref(), Some("postgres://user:pw@host/db"));
18934 assert_eq!(pg.schema, "public");
18935 assert_eq!(pg.table, "memories");
18936 assert_eq!(pg.connect_timeout_secs, Some(12));
18937 }
18938
18939 #[test]
18940 async fn runtime_reasoning_enabled_deserializes() {
18941 let raw = r#"
18942default_temperature = 0.7
18943
18944[runtime]
18945reasoning_enabled = false
18946"#;
18947
18948 let parsed = parse_test_config(raw);
18949 assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
18950 }
18951
18952 #[test]
18953 async fn runtime_reasoning_effort_deserializes() {
18954 let raw = r#"
18955default_temperature = 0.7
18956
18957[runtime]
18958reasoning_effort = "HIGH"
18959"#;
18960
18961 let parsed: Config = toml::from_str(raw).unwrap();
18962 assert_eq!(parsed.runtime.reasoning_effort.as_deref(), Some("high"));
18963 }
18964
18965 #[test]
18966 async fn runtime_reasoning_effort_rejects_invalid_values() {
18967 let raw = r#"
18968default_temperature = 0.7
18969
18970[runtime]
18971reasoning_effort = "turbo"
18972"#;
18973
18974 let error = toml::from_str::<Config>(raw).expect_err("invalid value should fail");
18975 assert!(error.to_string().contains("reasoning_effort"));
18976 }
18977
18978 #[test]
18979 async fn agent_config_defaults() {
18980 let cfg = AliasedAgentConfig::default();
18981 assert!(cfg.resolved.compact_context);
18982 assert_eq!(cfg.resolved.max_tool_iterations, 10);
18983 assert_eq!(cfg.resolved.max_history_messages, 50);
18984 assert!(!cfg.resolved.parallel_tools);
18985 assert_eq!(cfg.resolved.tool_dispatcher, "auto");
18986 assert!(!cfg.resolved.strict_tool_parsing);
18987 }
18988
18989 #[test]
18990 async fn agent_level_tunable_keys_are_inert() {
18991 let raw = r#"
18992default_temperature = 0.7
18993[agents.default]
18994compact_context = true
18995max_tool_iterations = 20
18996max_history_messages = 80
18997parallel_tools = true
18998tool_dispatcher = "xml"
18999strict_tool_parsing = true
19000"#;
19001 let parsed = parse_test_config(raw);
19002 let agent = parsed
19003 .agents
19004 .get("default")
19005 .expect("[agents.default] parses into agents map");
19006 assert_eq!(agent.resolved.max_tool_iterations, 10);
19007 assert_eq!(agent.resolved.tool_dispatcher, "auto");
19008 assert!(!agent.resolved.strict_tool_parsing);
19009 }
19010
19011 #[test]
19012 async fn runtime_profile_max_tool_iterations_is_honored() {
19013 let raw = r#"
19018[runtime_profiles.fast]
19019max_tool_iterations = 25
19020
19021[agents.default]
19022runtime_profile = "fast"
19023"#;
19024 let parsed = parse_test_config(raw);
19025 assert_eq!(parsed.effective_max_tool_iterations("default"), 25);
19026 }
19027
19028 #[test]
19029 async fn runtime_profile_unset_max_tool_iterations_uses_default() {
19030 let raw = r#"
19033[runtime_profiles.fast]
19034max_history_messages = 80
19035
19036[agents.default]
19037runtime_profile = "fast"
19038"#;
19039 let parsed = parse_test_config(raw);
19040 assert_eq!(parsed.effective_max_tool_iterations("default"), 10);
19041 }
19042
19043 #[test]
19044 async fn pacing_config_defaults_are_all_none_or_empty() {
19045 let cfg = PacingConfig::default();
19046 assert!(cfg.step_timeout_secs.is_none());
19047 assert!(cfg.loop_detection_min_elapsed_secs.is_none());
19048 assert!(cfg.loop_ignore_tools.is_empty());
19049 assert!(cfg.message_timeout_scale_max.is_none());
19050 }
19051
19052 #[test]
19053 async fn pacing_config_deserializes_from_toml() {
19054 let raw = r#"
19055default_temperature = 0.7
19056[pacing]
19057step_timeout_secs = 120
19058loop_detection_min_elapsed_secs = 60
19059loop_ignore_tools = ["browser_screenshot", "browser_navigate"]
19060message_timeout_scale_max = 8
19061"#;
19062 let parsed: Config = toml::from_str(raw).unwrap();
19063 assert_eq!(parsed.pacing.step_timeout_secs, Some(120));
19064 assert_eq!(parsed.pacing.loop_detection_min_elapsed_secs, Some(60));
19065 assert_eq!(
19066 parsed.pacing.loop_ignore_tools,
19067 vec!["browser_screenshot", "browser_navigate"]
19068 );
19069 assert_eq!(parsed.pacing.message_timeout_scale_max, Some(8));
19070 }
19071
19072 #[test]
19073 async fn pacing_config_absent_preserves_defaults() {
19074 let raw = r#"
19075default_temperature = 0.7
19076"#;
19077 let parsed: Config = toml::from_str(raw).unwrap();
19078 assert!(parsed.pacing.step_timeout_secs.is_none());
19079 assert!(parsed.pacing.loop_detection_min_elapsed_secs.is_none());
19080 assert!(parsed.pacing.loop_ignore_tools.is_empty());
19081 assert!(parsed.pacing.message_timeout_scale_max.is_none());
19082 }
19083
19084 #[tokio::test]
19085 async fn sync_directory_handles_existing_directory() {
19086 let dir = std::env::temp_dir().join(format!(
19087 "zeroclaw_test_sync_directory_{}",
19088 uuid::Uuid::new_v4()
19089 ));
19090 fs::create_dir_all(&dir).await.unwrap();
19091
19092 sync_directory(&dir).await.unwrap();
19093
19094 let _ = fs::remove_dir_all(&dir).await;
19095 }
19096
19097 #[tokio::test]
19098 async fn config_save_prunes_unchanged_default_blocks() {
19099 let dir =
19104 std::env::temp_dir().join(format!("zeroclaw_save_prune_test_{}", uuid::Uuid::new_v4()));
19105 fs::create_dir_all(&dir).await.unwrap();
19106 let config = Config {
19107 config_path: dir.join("config.toml"),
19108 data_dir: dir.join("data"),
19109 ..Default::default()
19110 };
19111 config.save().await.unwrap();
19112 let raw = fs::read_to_string(&config.config_path).await.unwrap();
19113
19114 assert!(
19117 raw.contains("schema_version"),
19118 "schema_version must survive pruning"
19119 );
19120
19121 for block in [
19124 "[memory]",
19125 "[linkedin",
19126 "[observability]",
19127 "[gateway]",
19128 "[cost]",
19129 ] {
19130 assert!(
19131 !raw.contains(block),
19132 "pruned config.toml must not emit defaulted block {block}; got:\n{raw}",
19133 );
19134 }
19135
19136 let _reloaded: Config = toml::from_str(&raw).expect("pruned config round-trips");
19139
19140 let _ = fs::remove_dir_all(&dir).await;
19141 }
19142
19143 #[tokio::test]
19144 async fn config_save_keeps_operator_set_non_default_fields() {
19145 let dir =
19146 std::env::temp_dir().join(format!("zeroclaw_save_keep_test_{}", uuid::Uuid::new_v4()));
19147 fs::create_dir_all(&dir).await.unwrap();
19148 let mut config = Config {
19149 config_path: dir.join("config.toml"),
19150 data_dir: dir.join("data"),
19151 ..Default::default()
19152 };
19153 config.locale = Some("ja-JP".into());
19155 config.providers.models.anthropic.insert(
19156 "claude_default".into(),
19157 AnthropicModelProviderConfig {
19158 base: ModelProviderConfig {
19159 model: Some("claude-sonnet-4".into()),
19160 ..Default::default()
19161 },
19162 },
19163 );
19164 config.save().await.unwrap();
19165 let raw = fs::read_to_string(&config.config_path).await.unwrap();
19166
19167 assert!(
19168 raw.contains("ja-JP"),
19169 "operator-set locale must survive pruning; got:\n{raw}",
19170 );
19171 assert!(
19172 raw.contains("claude_default"),
19173 "operator-added provider alias must survive pruning; got:\n{raw}",
19174 );
19175 assert!(
19176 raw.contains("claude-sonnet-4"),
19177 "operator-set model must survive pruning; got:\n{raw}",
19178 );
19179
19180 let _ = fs::remove_dir_all(&dir).await;
19181 }
19182
19183 #[tokio::test]
19184 async fn config_save_and_load_tmpdir() {
19185 let dir = std::env::temp_dir().join("zeroclaw_test_config");
19186 let _ = fs::remove_dir_all(&dir).await;
19187 fs::create_dir_all(&dir).await.unwrap();
19188
19189 let config_path = dir.join("config.toml");
19190 let mut providers = crate::providers::Providers::default();
19191 providers.models.openrouter.insert(
19192 "default".to_string(),
19193 OpenRouterModelProviderConfig {
19194 base: ModelProviderConfig {
19195 api_key: Some("sk-roundtrip".into()),
19196 model: Some("test-model".into()),
19197 temperature: Some(0.9),
19198 timeout_secs: Some(120),
19199 ..Default::default()
19200 },
19201 },
19202 );
19203 let config = Config {
19204 degraded_security: Vec::new(),
19205 schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
19206 providers,
19207 model_routes: Vec::new(),
19208 embedding_routes: Vec::new(),
19209 data_dir: dir.join("workspace"),
19210 config_path: config_path.clone(),
19211 observability: ObservabilityConfig::default(),
19212 trust: crate::scattered_types::TrustConfig::default(),
19213 backup: BackupConfig::default(),
19214 data_retention: DataRetentionConfig::default(),
19215 cloud_ops: CloudOpsConfig::default(),
19216 conversational_ai: ConversationalAiConfig::default(),
19217 security: SecurityConfig::default(),
19218 security_ops: SecurityOpsConfig::default(),
19219 runtime: RuntimeConfig::default(),
19220 reliability: ReliabilityConfig::default(),
19221 scheduler: SchedulerConfig::default(),
19222 skills: SkillsConfig::default(),
19223 pipeline: PipelineConfig::default(),
19224 query_classification: QueryClassificationConfig::default(),
19225 heartbeat: HeartbeatConfig::default(),
19226 cron: HashMap::new(),
19227 acp: AcpConfig::default(),
19228 channels: ChannelsConfig::default(),
19229 memory: MemoryConfig::default(),
19230 storage: StorageConfig::default(),
19231 tunnel: TunnelConfig::default(),
19232 gateway: GatewayConfig::default(),
19233 wss: WssConfig::default(),
19234 composio: ComposioConfig::default(),
19235 microsoft365: Microsoft365Config::default(),
19236 secrets: SecretsConfig::default(),
19237 browser: BrowserConfig::default(),
19238 browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
19239 http_request: HttpRequestConfig::default(),
19240 multimodal: MultimodalConfig::default(),
19241 media_pipeline: MediaPipelineConfig::default(),
19242 web_fetch: WebFetchConfig::default(),
19243 link_enricher: LinkEnricherConfig::default(),
19244 text_browser: TextBrowserConfig::default(),
19245 web_search: WebSearchConfig::default(),
19246 project_intel: ProjectIntelConfig::default(),
19247 google_workspace: GoogleWorkspaceConfig::default(),
19248 proxy: ProxyConfig::default(),
19249 pacing: PacingConfig::default(),
19250 cost: CostConfig::default(),
19251 peripherals: PeripheralsConfig::default(),
19252 delegate: DelegateToolConfig::default(),
19253 agents: HashMap::new(),
19254 risk_profiles: HashMap::new(),
19255 runtime_profiles: HashMap::new(),
19256 skill_bundles: HashMap::new(),
19257 knowledge_bundles: HashMap::new(),
19258 mcp_bundles: HashMap::new(),
19259 peer_groups: HashMap::new(),
19260 hooks: HooksConfig::default(),
19261 hardware: HardwareConfig::default(),
19262 transcription: TranscriptionConfig::default(),
19263 tts: TtsConfig::default(),
19264 mcp: McpConfig::default(),
19265 nodes: NodesConfig::default(),
19266 onboard_state: OnboardStateConfig::default(),
19267 notion: NotionConfig::default(),
19268 jira: JiraConfig::default(),
19269 node_transport: NodeTransportConfig::default(),
19270 knowledge: KnowledgeConfig::default(),
19271 linkedin: LinkedInConfig::default(),
19272 image_gen: ImageGenConfig::default(),
19273 file_upload: FileUploadConfig::default(),
19274 file_upload_bundle: FileUploadBundleConfig::default(),
19275 file_download: FileDownloadConfig::default(),
19276 plugins: PluginsConfig::default(),
19277 locale: None,
19278 verifiable_intent: VerifiableIntentConfig::default(),
19279 claude_code: ClaudeCodeConfig::default(),
19280 claude_code_runner: ClaudeCodeRunnerConfig::default(),
19281 codex_cli: CodexCliConfig::default(),
19282 gemini_cli: GeminiCliConfig::default(),
19283 opencode_cli: OpenCodeCliConfig::default(),
19284 sop: SopConfig::default(),
19285 shell_tool: ShellToolConfig::default(),
19286 escalation: EscalationConfig::default(),
19287 env_overridden_paths: std::collections::HashSet::new(),
19288 pre_override_snapshots: std::collections::HashMap::new(),
19289 onepassword_reference_snapshots: std::collections::HashMap::new(),
19290 dirty_paths: std::collections::HashSet::new(),
19291 };
19292
19293 config.save().await.unwrap();
19295 assert!(config_path.exists());
19296
19297 let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
19298 let loaded = crate::migration::migrate_to_current(&contents).unwrap();
19299 let entry = &loaded
19300 .providers
19301 .models
19302 .find("openrouter", "default")
19303 .expect("entry exists");
19304 assert!(
19305 entry
19306 .api_key
19307 .as_deref()
19308 .is_some_and(crate::secrets::SecretStore::is_encrypted)
19309 );
19310 let store = crate::secrets::SecretStore::new(&dir, true);
19311 let decrypted = store.decrypt(entry.api_key.as_deref().unwrap()).unwrap();
19312 assert_eq!(decrypted, "sk-roundtrip");
19313 assert_eq!(entry.model.as_deref(), Some("test-model"));
19314 assert!(
19315 entry
19316 .temperature
19317 .is_some_and(|t| (t - 0.9).abs() < f64::EPSILON)
19318 );
19319
19320 let _ = fs::remove_dir_all(&dir).await;
19321 }
19322
19323 #[tokio::test]
19324 async fn config_save_encrypts_nested_credentials() {
19325 let dir = std::env::temp_dir().join(format!(
19326 "zeroclaw_test_nested_credentials_{}",
19327 uuid::Uuid::new_v4()
19328 ));
19329 fs::create_dir_all(&dir).await.unwrap();
19330
19331 let mut config = Config {
19332 data_dir: dir.join("workspace"),
19333 config_path: dir.join("config.toml"),
19334 ..Default::default()
19335 };
19336 config.providers.models.anthropic.insert(
19337 "default".to_string(),
19338 AnthropicModelProviderConfig {
19339 base: ModelProviderConfig {
19340 api_key: Some("root-credential".into()),
19341 extra_headers: HashMap::from([(
19342 "Authorization".to_string(),
19343 "Bearer provider-header-credential".to_string(),
19344 )]),
19345 ..Default::default()
19346 },
19347 },
19348 );
19349 config.composio.api_key = Some("composio-credential".into());
19351 config.browser.computer_use.api_key = Some("browser-credential".into());
19352 config.web_search.brave_api_key = Some("brave-credential".into());
19353 config.web_search.tavily_api_key = Some("tavily-credential".into());
19354 config.storage.postgres.insert(
19355 "default".to_string(),
19356 PostgresStorageConfig {
19357 db_url: Some("postgres://user:pw@host/db".into()),
19358 ..PostgresStorageConfig::default()
19359 },
19360 );
19361 config.storage.qdrant.insert(
19362 "default".to_string(),
19363 QdrantStorageConfig {
19364 api_key: Some("qdrant-credential".into()),
19365 ..QdrantStorageConfig::default()
19366 },
19367 );
19368 config.reliability.api_keys = vec![
19369 "rotation-credential-a".into(),
19370 "rotation-credential-b".into(),
19371 ];
19372 config.node_transport.shared_secret = "node-shared-credential".into();
19373 config.nodes.auth_token = Some("nodes-auth-credential".into());
19374 config.observability.backend = "otel".into();
19375 config.observability.otel_headers = Some(HashMap::from([(
19376 "Authorization".to_string(),
19377 "Bearer otel-credential".to_string(),
19378 )]));
19379 config.file_upload.headers = HashMap::from([(
19380 "Authorization".to_string(),
19381 "Bearer upload-credential".to_string(),
19382 )]);
19383 config.http_request.secrets = HashMap::from([(
19384 "api_token".to_string(),
19385 "Bearer http-request-credential".to_string(),
19386 )]);
19387 config.channels.lark.insert(
19388 "feishu".to_string(),
19389 LarkConfig {
19390 enabled: true,
19391 app_id: "cli_feishu_123".into(),
19392 app_secret: "feishu-secret".into(),
19393 encrypt_key: Some("feishu-encrypt".into()),
19394 verification_token: Some("feishu-verify".into()),
19395 mention_only: false,
19396 use_feishu: true,
19397 receive_mode: LarkReceiveMode::Websocket,
19398 port: None,
19399 proxy_url: None,
19400 excluded_tools: vec![],
19401 approval_timeout_secs: 300,
19402 per_user_session: false,
19403 stream_mode: StreamMode::default(),
19404 draft_update_interval_ms: default_draft_update_interval_ms(),
19405 },
19406 );
19407
19408 config.providers.models.openrouter.insert(
19409 "worker".into(),
19410 crate::schema::OpenRouterModelProviderConfig {
19411 base: ModelProviderConfig {
19412 api_key: Some("agent-credential".into()),
19413 model: Some("model-test".into()),
19414 ..Default::default()
19415 },
19416 },
19417 );
19418 config.agents.insert(
19419 "worker".into(),
19420 AliasedAgentConfig {
19421 model_provider: "openrouter.worker".into(),
19422 ..Default::default()
19423 },
19424 );
19425
19426 config.channels.webhook.insert(
19429 "primary".into(),
19430 WebhookConfig {
19431 enabled: true,
19432 port: 8080,
19433 auth_header: Some("Bearer webhook-cred".into()),
19434 secret: Some("webhook-shared-secret".into()),
19435 ..Default::default()
19436 },
19437 );
19438
19439 config.mcp.servers.push(McpServerConfig {
19443 name: "primary".into(),
19444 transport: McpTransport::Sse,
19445 url: Some("https://mcp.example.invalid/sse".into()),
19446 env: HashMap::from([("MCP_API_KEY".to_string(), "mcp-env-credential".to_string())]),
19447 headers: HashMap::from([
19448 ("Authorization".to_string(), "Bearer mcp-cred".to_string()),
19449 ("X-Tenant".to_string(), "tenant-42".to_string()),
19450 ]),
19451 ..Default::default()
19452 });
19453
19454 config.save().await.unwrap();
19455
19456 let contents = tokio::fs::read_to_string(config.config_path.clone())
19457 .await
19458 .unwrap();
19459 for plaintext in [
19460 "root-credential",
19461 "Bearer provider-header-credential",
19462 "composio-credential",
19463 "browser-credential",
19464 "brave-credential",
19465 "tavily-credential",
19466 "postgres://user:pw@host/db",
19467 "qdrant-credential",
19468 "rotation-credential-a",
19469 "rotation-credential-b",
19470 "node-shared-credential",
19471 "nodes-auth-credential",
19472 "Bearer otel-credential",
19473 "Bearer upload-credential",
19474 "Bearer http-request-credential",
19475 "mcp-env-credential",
19476 "Bearer mcp-cred",
19477 "tenant-42",
19478 ] {
19479 assert!(
19480 !contents.contains(plaintext),
19481 "saved TOML must not contain plaintext credential `{plaintext}`"
19482 );
19483 }
19484 let stored: Config = crate::migration::migrate_to_current(&contents).unwrap();
19485 let store = crate::secrets::SecretStore::new(&dir, true);
19486
19487 let root_encrypted = stored
19488 .providers
19489 .models
19490 .find("anthropic", "default")
19491 .and_then(|e| e.api_key.as_deref())
19492 .unwrap();
19493 assert!(crate::secrets::SecretStore::is_encrypted(root_encrypted));
19494 assert_eq!(store.decrypt(root_encrypted).unwrap(), "root-credential");
19495
19496 let provider_header = stored
19497 .providers
19498 .models
19499 .find("anthropic", "default")
19500 .and_then(|e| e.extra_headers.get("Authorization"))
19501 .unwrap();
19502 assert!(crate::secrets::SecretStore::is_encrypted(provider_header));
19503 assert_eq!(
19504 store.decrypt(provider_header).unwrap(),
19505 "Bearer provider-header-credential"
19506 );
19507
19508 let composio_encrypted = stored.composio.api_key.as_deref().unwrap();
19509 assert!(crate::secrets::SecretStore::is_encrypted(
19510 composio_encrypted
19511 ));
19512 assert_eq!(
19513 store.decrypt(composio_encrypted).unwrap(),
19514 "composio-credential"
19515 );
19516
19517 let browser_encrypted = stored.browser.computer_use.api_key.as_deref().unwrap();
19518 assert!(crate::secrets::SecretStore::is_encrypted(browser_encrypted));
19519 assert_eq!(
19520 store.decrypt(browser_encrypted).unwrap(),
19521 "browser-credential"
19522 );
19523
19524 let web_search_encrypted = stored.web_search.brave_api_key.as_deref().unwrap();
19525 assert!(crate::secrets::SecretStore::is_encrypted(
19526 web_search_encrypted
19527 ));
19528 assert_eq!(
19529 store.decrypt(web_search_encrypted).unwrap(),
19530 "brave-credential"
19531 );
19532
19533 let tavily_encrypted = stored.web_search.tavily_api_key.as_deref().unwrap();
19534 assert!(crate::secrets::SecretStore::is_encrypted(tavily_encrypted));
19535 assert_eq!(
19536 store.decrypt(tavily_encrypted).unwrap(),
19537 "tavily-credential"
19538 );
19539
19540 let worker_provider = stored
19541 .providers
19542 .models
19543 .find("openrouter", "worker")
19544 .unwrap();
19545 let worker_encrypted = worker_provider.api_key.as_deref().unwrap();
19546 assert!(crate::secrets::SecretStore::is_encrypted(worker_encrypted));
19547 assert_eq!(store.decrypt(worker_encrypted).unwrap(), "agent-credential");
19548
19549 let storage_db_url = stored
19550 .storage
19551 .postgres
19552 .get("default")
19553 .and_then(|p| p.db_url.as_deref())
19554 .unwrap();
19555 assert!(crate::secrets::SecretStore::is_encrypted(storage_db_url));
19556 assert_eq!(
19557 store.decrypt(storage_db_url).unwrap(),
19558 "postgres://user:pw@host/db"
19559 );
19560
19561 let qdrant_key = stored
19562 .storage
19563 .qdrant
19564 .get("default")
19565 .and_then(|q| q.api_key.as_deref())
19566 .unwrap();
19567 assert!(crate::secrets::SecretStore::is_encrypted(qdrant_key));
19568 assert_eq!(store.decrypt(qdrant_key).unwrap(), "qdrant-credential");
19569
19570 for key in &stored.reliability.api_keys {
19571 assert!(crate::secrets::SecretStore::is_encrypted(key));
19572 }
19573 assert_eq!(
19574 store.decrypt(&stored.reliability.api_keys[0]).unwrap(),
19575 "rotation-credential-a"
19576 );
19577 assert_eq!(
19578 store.decrypt(&stored.reliability.api_keys[1]).unwrap(),
19579 "rotation-credential-b"
19580 );
19581
19582 assert!(crate::secrets::SecretStore::is_encrypted(
19583 &stored.node_transport.shared_secret
19584 ));
19585 assert_eq!(
19586 store.decrypt(&stored.node_transport.shared_secret).unwrap(),
19587 "node-shared-credential"
19588 );
19589
19590 let nodes_auth = stored.nodes.auth_token.as_deref().unwrap();
19591 assert!(crate::secrets::SecretStore::is_encrypted(nodes_auth));
19592 assert_eq!(store.decrypt(nodes_auth).unwrap(), "nodes-auth-credential");
19593
19594 let otel_auth = stored
19595 .observability
19596 .otel_headers
19597 .as_ref()
19598 .and_then(|h| h.get("Authorization"))
19599 .unwrap();
19600 assert!(crate::secrets::SecretStore::is_encrypted(otel_auth));
19601 assert_eq!(store.decrypt(otel_auth).unwrap(), "Bearer otel-credential");
19602
19603 let upload_auth = stored.file_upload.headers.get("Authorization").unwrap();
19604 assert!(crate::secrets::SecretStore::is_encrypted(upload_auth));
19605 assert_eq!(
19606 store.decrypt(upload_auth).unwrap(),
19607 "Bearer upload-credential"
19608 );
19609
19610 let http_request_auth = stored.http_request.secrets.get("api_token").unwrap();
19611 assert!(crate::secrets::SecretStore::is_encrypted(http_request_auth));
19612 assert_eq!(
19613 store.decrypt(http_request_auth).unwrap(),
19614 "Bearer http-request-credential"
19615 );
19616
19617 let feishu = stored.channels.lark.get("feishu").unwrap();
19618 assert!(crate::secrets::SecretStore::is_encrypted(
19619 &feishu.app_secret
19620 ));
19621 assert_eq!(store.decrypt(&feishu.app_secret).unwrap(), "feishu-secret");
19622 assert!(
19623 feishu
19624 .encrypt_key
19625 .as_deref()
19626 .is_some_and(crate::secrets::SecretStore::is_encrypted)
19627 );
19628 assert_eq!(
19629 store
19630 .decrypt(feishu.encrypt_key.as_deref().unwrap())
19631 .unwrap(),
19632 "feishu-encrypt"
19633 );
19634 assert!(
19635 feishu
19636 .verification_token
19637 .as_deref()
19638 .is_some_and(crate::secrets::SecretStore::is_encrypted)
19639 );
19640 assert_eq!(
19641 store
19642 .decrypt(feishu.verification_token.as_deref().unwrap())
19643 .unwrap(),
19644 "feishu-verify"
19645 );
19646
19647 let webhook = stored.channels.webhook.get("primary").unwrap();
19649 let webhook_auth = webhook.auth_header.as_deref().unwrap();
19650 assert!(
19651 crate::secrets::SecretStore::is_encrypted(webhook_auth),
19652 "webhook auth_header must be encrypted on save"
19653 );
19654 assert_eq!(store.decrypt(webhook_auth).unwrap(), "Bearer webhook-cred");
19655 let webhook_secret = webhook.secret.as_deref().unwrap();
19658 assert!(crate::secrets::SecretStore::is_encrypted(webhook_secret));
19659 assert_eq!(
19660 store.decrypt(webhook_secret).unwrap(),
19661 "webhook-shared-secret"
19662 );
19663
19664 let mcp_server = stored
19667 .mcp
19668 .servers
19669 .iter()
19670 .find(|s| s.name == "primary")
19671 .expect("mcp server `primary` round-trips through save");
19672 for (key, value) in &mcp_server.headers {
19673 assert!(
19674 crate::secrets::SecretStore::is_encrypted(value),
19675 "mcp.servers.primary.headers.{key} must be encrypted on save"
19676 );
19677 }
19678 let mcp_env = mcp_server.env.get("MCP_API_KEY").unwrap();
19679 assert!(
19680 crate::secrets::SecretStore::is_encrypted(mcp_env),
19681 "mcp.servers.primary.env.MCP_API_KEY must be encrypted on save"
19682 );
19683 let auth = mcp_server.headers.get("Authorization").unwrap();
19684 let tenant = mcp_server.headers.get("X-Tenant").unwrap();
19685 assert_eq!(store.decrypt(mcp_env).unwrap(), "mcp-env-credential");
19686 assert_eq!(store.decrypt(auth).unwrap(), "Bearer mcp-cred");
19687 assert_eq!(store.decrypt(tenant).unwrap(), "tenant-42");
19688
19689 let _ = fs::remove_dir_all(&dir).await;
19690 }
19691
19692 #[tokio::test]
19693 async fn config_save_atomic_cleanup() {
19694 let dir =
19695 std::env::temp_dir().join(format!("zeroclaw_test_config_{}", uuid::Uuid::new_v4()));
19696 fs::create_dir_all(&dir).await.unwrap();
19697
19698 let config_path = dir.join("config.toml");
19699 let mut config = Config {
19700 data_dir: dir.join("workspace"),
19701 config_path: config_path.clone(),
19702 ..Default::default()
19703 };
19704 config.providers.models.openrouter.insert(
19705 "default".to_string(),
19706 OpenRouterModelProviderConfig {
19707 base: ModelProviderConfig {
19708 model: Some("model-a".into()),
19709 ..Default::default()
19710 },
19711 },
19712 );
19713 config.save().await.unwrap();
19714 assert!(config_path.exists());
19715
19716 config
19717 .providers
19718 .models
19719 .ensure("openrouter", "default")
19720 .unwrap()
19721 .model = Some("model-b".into());
19722 config.save().await.unwrap();
19723
19724 let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
19725 assert!(contents.contains("model-b"));
19726
19727 let mut names: Vec<String> = Vec::new();
19728 let mut read_dir = fs::read_dir(&dir).await.unwrap();
19729 while let Some(entry) = read_dir.next_entry().await.unwrap() {
19730 names.push(entry.file_name().to_string_lossy().to_string());
19731 }
19732 assert!(!names.iter().any(|name| name.contains(".tmp-")));
19733 assert!(!names.iter().any(|name| name.ends_with(".bak")));
19734
19735 let _ = fs::remove_dir_all(&dir).await;
19736 }
19737
19738 #[test]
19741 async fn telegram_config_serde() {
19742 let tc = TelegramConfig {
19743 enabled: true,
19744 bot_token: "123:XYZ".into(),
19745 stream_mode: StreamMode::Partial,
19746 draft_update_interval_ms: 500,
19747 interrupt_on_new_message: true,
19748 mention_only: false,
19749 ack_reactions: None,
19750 proxy_url: None,
19751 approval_timeout_secs: 120,
19752 excluded_tools: vec![],
19753 reply_min_interval_secs: 0,
19754 reply_queue_depth_max: 0,
19755 };
19756 let json = serde_json::to_string(&tc).unwrap();
19757 let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
19758 assert_eq!(parsed.bot_token, "123:XYZ");
19759 assert_eq!(parsed.stream_mode, StreamMode::Partial);
19760 assert_eq!(parsed.draft_update_interval_ms, 500);
19761 assert!(parsed.interrupt_on_new_message);
19762 }
19763
19764 #[test]
19765 async fn telegram_config_defaults_stream_off() {
19766 let json = r#"{"bot_token":"tok","allowed_users":[]}"#;
19767 let parsed: TelegramConfig = serde_json::from_str(json).unwrap();
19768 assert_eq!(parsed.stream_mode, StreamMode::Off);
19769 assert_eq!(parsed.draft_update_interval_ms, 1000);
19770 assert!(!parsed.interrupt_on_new_message);
19771 }
19772
19773 #[test]
19774 async fn discord_config_serde() {
19775 let dc = DiscordConfig {
19776 enabled: true,
19777 bot_token: "discord-token".into(),
19778 guild_ids: vec!["12345".into()],
19779 channel_ids: vec![],
19780 archive: false,
19781 listen_to_bots: false,
19782 interrupt_on_new_message: false,
19783 mention_only: false,
19784 proxy_url: None,
19785 stream_mode: StreamMode::default(),
19786 draft_update_interval_ms: 1000,
19787 multi_message_delay_ms: 800,
19788 stall_timeout_secs: 0,
19789 approval_timeout_secs: 300,
19790 excluded_tools: vec![],
19791 reply_min_interval_secs: 0,
19792 reply_queue_depth_max: 0,
19793 };
19794 let json = serde_json::to_string(&dc).unwrap();
19795 let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
19796 assert_eq!(parsed.bot_token, "discord-token");
19797 assert_eq!(parsed.guild_ids, vec!["12345".to_string()]);
19798 }
19799
19800 #[test]
19801 async fn discord_config_empty_guild_ids() {
19802 let dc = DiscordConfig {
19803 enabled: true,
19804 bot_token: "tok".into(),
19805 guild_ids: Vec::new(),
19806 channel_ids: vec![],
19807 archive: false,
19808 listen_to_bots: false,
19809 interrupt_on_new_message: false,
19810 mention_only: false,
19811 proxy_url: None,
19812 stream_mode: StreamMode::default(),
19813 draft_update_interval_ms: 1000,
19814 multi_message_delay_ms: 800,
19815 stall_timeout_secs: 0,
19816 approval_timeout_secs: 300,
19817 excluded_tools: vec![],
19818 reply_min_interval_secs: 0,
19819 reply_queue_depth_max: 0,
19820 };
19821 let json = serde_json::to_string(&dc).unwrap();
19822 let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
19823 assert!(parsed.guild_ids.is_empty());
19824 }
19825
19826 #[test]
19835 async fn imessage_v2_allowed_contacts_fold_into_peer_groups() {
19836 let raw = r#"
19840schema_version = 2
19841
19842[channels.imessage]
19843enabled = true
19844allowed_contacts = ["+1234567890", "user@icloud.com"]
19845"#;
19846 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
19847 let group = parsed
19848 .peer_groups
19849 .get("imessage_default")
19850 .expect("V2 imessage.allowed_contacts must fold into peer_groups.imessage_default");
19851 assert_eq!(group.channel, "imessage");
19852 let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
19853 assert_eq!(usernames, vec!["+1234567890", "user@icloud.com"]);
19854 }
19855
19856 #[test]
19857 async fn matrix_config_serde() {
19858 let mc = MatrixConfig {
19859 enabled: true,
19860 homeserver: "https://matrix.org".into(),
19861 access_token: Some("syt_token_abc".into()),
19862 user_id: Some("@bot:matrix.org".into()),
19863 device_id: Some("DEVICE123".into()),
19864 allowed_rooms: vec!["!room123:matrix.org".into()],
19865 interrupt_on_new_message: false,
19866 stream_mode: StreamMode::default(),
19867 draft_update_interval_ms: 1500,
19868 multi_message_delay_ms: 800,
19869 recovery_key: None,
19870 mention_only: false,
19871 password: None,
19872 approval_timeout_secs: 300,
19873 reply_in_thread: true,
19874 ack_reactions: Some(true),
19875 excluded_tools: vec![],
19876 reply_min_interval_secs: 0,
19877 reply_queue_depth_max: 0,
19878 };
19879 let json = serde_json::to_string(&mc).unwrap();
19880 let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
19881 assert_eq!(parsed.homeserver, "https://matrix.org");
19882 assert_eq!(parsed.access_token.as_deref(), Some("syt_token_abc"));
19883 assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org"));
19884 assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123"));
19885 assert_eq!(
19886 parsed.allowed_rooms.first().map(|s| s.as_str()),
19887 Some("!room123:matrix.org")
19888 );
19889 }
19890
19891 #[test]
19892 async fn matrix_config_toml_roundtrip() {
19893 let mc = MatrixConfig {
19894 enabled: true,
19895 homeserver: "https://synapse.local:8448".into(),
19896 access_token: Some("tok".into()),
19897 user_id: None,
19898 device_id: None,
19899 allowed_rooms: vec!["!abc:synapse.local".into()],
19900 interrupt_on_new_message: false,
19901 stream_mode: StreamMode::default(),
19902 draft_update_interval_ms: 1500,
19903 multi_message_delay_ms: 800,
19904 recovery_key: None,
19905 mention_only: false,
19906 password: None,
19907 approval_timeout_secs: 300,
19908 reply_in_thread: true,
19909 ack_reactions: Some(true),
19910 excluded_tools: vec![],
19911 reply_min_interval_secs: 0,
19912 reply_queue_depth_max: 0,
19913 };
19914 let toml_str = toml::to_string(&mc).unwrap();
19915 let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
19916 assert_eq!(parsed.homeserver, "https://synapse.local:8448");
19917 assert_eq!(parsed.allowed_rooms.len(), 1);
19918 }
19919
19920 #[test]
19921 async fn matrix_config_backward_compatible_without_session_hints() {
19922 let toml = r#"
19925homeserver = "https://matrix.org"
19926access_token = "tok"
19927allowed_users = ["@ops:matrix.org"]
19928allowed_rooms = ["!ops:matrix.org"]
19929"#;
19930
19931 let parsed: MatrixConfig = toml::from_str(toml).unwrap();
19932 assert_eq!(parsed.homeserver, "https://matrix.org");
19933 assert!(parsed.user_id.is_none());
19934 assert!(parsed.device_id.is_none());
19935 assert_eq!(parsed.allowed_rooms, vec!["!ops:matrix.org"]);
19936 }
19937
19938 #[test]
19939 async fn matrix_config_reply_in_thread_defaults_to_true() {
19940 let toml = r#"
19941homeserver = "https://matrix.org"
19942access_token = "tok"
19943allowed_users = ["@u:matrix.org"]
19944"#;
19945 let parsed: MatrixConfig = toml::from_str(toml).unwrap();
19946 assert!(parsed.reply_in_thread);
19947 }
19948
19949 #[test]
19950 async fn signal_config_serde() {
19951 let sc = SignalConfig {
19952 enabled: true,
19953 http_url: "http://127.0.0.1:8686".into(),
19954 account: "+1234567890".into(),
19955 group_ids: vec!["group123".into()],
19956 dm_only: false,
19957 ignore_attachments: true,
19958 ignore_stories: false,
19959 proxy_url: None,
19960 approval_timeout_secs: 300,
19961 excluded_tools: vec![],
19962 reply_min_interval_secs: 0,
19963 reply_queue_depth_max: 0,
19964 };
19965 let json = serde_json::to_string(&sc).unwrap();
19966 let parsed: SignalConfig = serde_json::from_str(&json).unwrap();
19967 assert_eq!(parsed.http_url, "http://127.0.0.1:8686");
19968 assert_eq!(parsed.account, "+1234567890");
19969 assert_eq!(parsed.group_ids, vec!["group123".to_string()]);
19970 assert!(!parsed.dm_only);
19971 assert!(parsed.ignore_attachments);
19972 assert!(!parsed.ignore_stories);
19973 }
19974
19975 #[test]
19976 async fn signal_config_toml_roundtrip() {
19977 let sc = SignalConfig {
19978 enabled: true,
19979 http_url: "http://localhost:8080".into(),
19980 account: "+9876543210".into(),
19981 group_ids: Vec::new(),
19982 dm_only: true,
19983 ignore_attachments: false,
19984 ignore_stories: true,
19985 proxy_url: None,
19986 approval_timeout_secs: 300,
19987 excluded_tools: vec![],
19988 reply_min_interval_secs: 0,
19989 reply_queue_depth_max: 0,
19990 };
19991 let toml_str = toml::to_string(&sc).unwrap();
19992 let parsed: SignalConfig = toml::from_str(&toml_str).unwrap();
19993 assert_eq!(parsed.http_url, "http://localhost:8080");
19994 assert_eq!(parsed.account, "+9876543210");
19995 assert!(parsed.group_ids.is_empty());
19996 assert!(parsed.dm_only);
19997 assert!(parsed.ignore_stories);
19998 }
19999
20000 #[test]
20001 async fn signal_config_defaults() {
20002 let json = r#"{"http_url":"http://127.0.0.1:8686","account":"+1234567890"}"#;
20003 let parsed: SignalConfig = serde_json::from_str(json).unwrap();
20004 assert!(parsed.group_ids.is_empty());
20005 assert!(!parsed.dm_only);
20006 assert!(!parsed.ignore_attachments);
20007 assert!(!parsed.ignore_stories);
20008 }
20009
20010 #[test]
20011 async fn channels_with_imessage_and_matrix() {
20012 let c = ChannelsConfig {
20013 cli: true,
20014 telegram: HashMap::new(),
20015 discord: HashMap::new(),
20016 slack: HashMap::new(),
20017 mattermost: HashMap::new(),
20018 webhook: HashMap::new(),
20019 imessage: HashMap::from([(
20020 "default".to_string(),
20021 IMessageConfig {
20022 enabled: true,
20023 excluded_tools: vec![],
20024 reply_min_interval_secs: 0,
20025 reply_queue_depth_max: 0,
20026 },
20027 )]),
20028 matrix: HashMap::from([(
20029 "default".to_string(),
20030 MatrixConfig {
20031 enabled: true,
20032 homeserver: "https://m.org".into(),
20033 access_token: Some("tok".into()),
20034 user_id: None,
20035 device_id: None,
20036 allowed_rooms: vec!["!r:m".into()],
20037 interrupt_on_new_message: false,
20038 stream_mode: StreamMode::default(),
20039 draft_update_interval_ms: 1500,
20040 multi_message_delay_ms: 800,
20041 recovery_key: None,
20042 mention_only: false,
20043 password: None,
20044 approval_timeout_secs: 300,
20045 reply_in_thread: true,
20046 ack_reactions: Some(true),
20047 excluded_tools: vec![],
20048 reply_min_interval_secs: 0,
20049 reply_queue_depth_max: 0,
20050 },
20051 )]),
20052 signal: HashMap::new(),
20053 whatsapp: HashMap::new(),
20054 linq: HashMap::new(),
20055 wati: HashMap::new(),
20056 nextcloud_talk: HashMap::new(),
20057 email: HashMap::new(),
20058 gmail_push: HashMap::new(),
20059 irc: HashMap::new(),
20060 twitch: HashMap::new(),
20061 lark: HashMap::new(),
20062 line: HashMap::new(),
20063 dingtalk: HashMap::new(),
20064 wecom: HashMap::new(),
20065 wecom_ws: HashMap::new(),
20066 wechat: HashMap::new(),
20067 qq: HashMap::new(),
20068 twitter: HashMap::new(),
20069 mochat: HashMap::new(),
20070 nostr: HashMap::new(),
20071 clawdtalk: HashMap::new(),
20072 reddit: HashMap::new(),
20073 bluesky: HashMap::new(),
20074 voice_call: HashMap::new(),
20075 voice_duplex: HashMap::new(),
20076 voice_wake: HashMap::new(),
20077 mqtt: HashMap::new(),
20078 amqp: HashMap::new(),
20079 message_timeout_secs: 300,
20080 max_concurrent_per_channel: default_channel_max_concurrent_per_channel(),
20081 ack_reactions: true,
20082 show_tool_calls: true,
20083 session_persistence: true,
20084 session_backend: default_session_backend(),
20085 session_ttl_hours: 0,
20086 debounce_ms: 0,
20087 };
20088 let toml_str = toml::to_string_pretty(&c).unwrap();
20089 let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
20090 assert!(!parsed.imessage.is_empty());
20091 assert!(!parsed.matrix.is_empty());
20092 assert_eq!(
20093 parsed.matrix.get("default").unwrap().homeserver,
20094 "https://m.org"
20095 );
20096 }
20097
20098 #[test]
20099 async fn channels_default_has_no_imessage_matrix() {
20100 let c = ChannelsConfig::default();
20101 assert!(c.imessage.is_empty());
20102 assert!(c.matrix.is_empty());
20103 }
20104
20105 #[test]
20113 async fn discord_v2_allowed_users_fold_into_peer_groups() {
20114 let raw = r#"
20115schema_version = 2
20116
20117[channels.discord]
20118enabled = true
20119bot_token = "tok"
20120guild_id = "123"
20121allowed_users = ["111", "222"]
20122"#;
20123 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
20124 let group = parsed
20125 .peer_groups
20126 .get("discord_default")
20127 .expect("V2 discord.allowed_users must fold into peer_groups.discord_default");
20128 assert_eq!(group.channel, "discord");
20129 let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
20130 assert_eq!(usernames, vec!["111", "222"]);
20131 }
20132
20133 #[test]
20134 async fn slack_v2_allowed_users_fold_into_peer_groups() {
20135 let raw = r#"
20136schema_version = 2
20137
20138[channels.slack]
20139enabled = true
20140bot_token = "xoxb-tok"
20141allowed_users = ["U111"]
20142"#;
20143 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
20144 let group = parsed
20145 .peer_groups
20146 .get("slack_default")
20147 .expect("V2 slack.allowed_users must fold into peer_groups.slack_default");
20148 assert_eq!(group.channel, "slack");
20149 let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
20150 assert_eq!(usernames, vec!["U111"]);
20151 }
20152
20153 #[test]
20154 async fn slack_config_deserializes_with_channel_ids() {
20155 let json = r#"{"bot_token":"xoxb-tok","channel_ids":["C111","D222"]}"#;
20156 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
20157 assert_eq!(parsed.channel_ids, vec!["C111", "D222"]);
20158 assert!(!parsed.interrupt_on_new_message);
20159 assert_eq!(parsed.thread_replies, None);
20160 assert!(!parsed.mention_only);
20161 }
20162
20163 #[test]
20164 async fn slack_config_deserializes_with_mention_only() {
20165 let json = r#"{"bot_token":"xoxb-tok","mention_only":true}"#;
20166 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
20167 assert!(parsed.mention_only);
20168 assert!(!parsed.interrupt_on_new_message);
20169 assert_eq!(parsed.thread_replies, None);
20170 }
20171
20172 #[test]
20173 async fn slack_config_deserializes_interrupt_on_new_message() {
20174 let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
20175 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
20176 assert!(parsed.interrupt_on_new_message);
20177 assert_eq!(parsed.thread_replies, None);
20178 assert!(!parsed.mention_only);
20179 }
20180
20181 #[test]
20182 async fn slack_config_deserializes_thread_replies() {
20183 let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#;
20184 let parsed: SlackConfig = serde_json::from_str(json).unwrap();
20185 assert_eq!(parsed.thread_replies, Some(false));
20186 assert!(!parsed.interrupt_on_new_message);
20187 assert!(!parsed.mention_only);
20188 }
20189
20190 #[test]
20191 async fn discord_config_default_interrupt_on_new_message_is_false() {
20192 let json = r#"{"bot_token":"tok"}"#;
20193 let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
20194 assert!(!parsed.interrupt_on_new_message);
20195 }
20196
20197 #[test]
20198 async fn discord_config_deserializes_interrupt_on_new_message_true() {
20199 let json = r#"{"bot_token":"tok","interrupt_on_new_message":true}"#;
20200 let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
20201 assert!(parsed.interrupt_on_new_message);
20202 }
20203
20204 #[test]
20205 async fn discord_config_toml_backward_compat() {
20206 let toml_str = r#"
20207bot_token = "tok"
20208guild_id = "123"
20209"#;
20210 let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
20211 assert_eq!(parsed.bot_token, "tok");
20212 }
20213
20214 #[test]
20215 async fn slack_config_toml_with_channel_ids() {
20216 let toml_str = r#"
20217bot_token = "xoxb-tok"
20218channel_ids = ["C123", "D456"]
20219"#;
20220 let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
20221 assert_eq!(parsed.channel_ids, vec!["C123", "D456"]);
20222 assert!(!parsed.interrupt_on_new_message);
20223 assert_eq!(parsed.thread_replies, None);
20224 assert!(!parsed.mention_only);
20225 }
20226
20227 #[test]
20228 async fn slack_config_toml_without_channel_ids_defaults_empty() {
20229 let toml_str = r#"
20230bot_token = "xoxb-tok"
20231"#;
20232 let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
20233 assert!(parsed.channel_ids.is_empty());
20234 }
20235
20236 #[test]
20237 async fn mattermost_config_default_interrupt_on_new_message_is_false() {
20238 let json = r#"{"url":"https://mm.example.com","bot_token":"tok"}"#;
20239 let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
20240 assert!(!parsed.interrupt_on_new_message);
20241 }
20242
20243 #[test]
20244 async fn mattermost_config_deserializes_interrupt_on_new_message_true() {
20245 let json =
20246 r#"{"url":"https://mm.example.com","bot_token":"tok","interrupt_on_new_message":true}"#;
20247 let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
20248 assert!(parsed.interrupt_on_new_message);
20249 }
20250
20251 #[test]
20252 async fn whatsapp_config_default_interrupt_on_new_message_is_false() {
20253 let json = r#"{"session_path":"/tmp/zeroclaw-whatsapp-session.db"}"#;
20254 let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
20255 assert!(!parsed.interrupt_on_new_message);
20256 }
20257
20258 #[test]
20259 async fn whatsapp_config_deserializes_interrupt_on_new_message_true() {
20260 let json = r#"{"session_path":"/tmp/zeroclaw-whatsapp-session.db","interrupt_on_new_message":true}"#;
20261 let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
20262 assert!(parsed.interrupt_on_new_message);
20263 }
20264
20265 #[test]
20266 async fn webhook_config_with_secret() {
20267 let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
20268 let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
20269 assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
20270 }
20271
20272 #[test]
20273 async fn webhook_config_without_secret() {
20274 let json = r#"{"port":8080}"#;
20275 let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
20276 assert!(parsed.secret.is_none());
20277 assert_eq!(parsed.port, 8080);
20278 }
20279
20280 #[test]
20281 async fn webhook_config_port_defaults_when_omitted() {
20282 let p: WebhookConfig = serde_json::from_str("{}").unwrap();
20283 assert_eq!(p.port, 8090);
20284 }
20285
20286 #[test]
20287 async fn webhook_config_retry_fields_default_to_none() {
20288 let json = r#"{"port":8080}"#;
20289 let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
20290 assert!(parsed.max_retries.is_none());
20291 assert!(parsed.retry_base_delay_ms.is_none());
20292 assert!(parsed.retry_max_delay_ms.is_none());
20293 }
20294
20295 #[test]
20296 async fn webhook_config_retry_fields_roundtrip() {
20297 let wc = WebhookConfig {
20298 enabled: true,
20299 port: 8080,
20300 listen_path: None,
20301 send_url: Some("https://example.com/cb".into()),
20302 send_method: None,
20303 auth_header: None,
20304 secret: None,
20305 excluded_tools: vec![],
20306 reply_min_interval_secs: 0,
20307 reply_queue_depth_max: 0,
20308 max_retries: Some(5),
20309 retry_base_delay_ms: Some(250),
20310 retry_max_delay_ms: Some(10_000),
20311 };
20312
20313 let json = serde_json::to_string(&wc).unwrap();
20314 let parsed: WebhookConfig = serde_json::from_str(&json).unwrap();
20315 assert_eq!(parsed.max_retries, Some(5));
20316 assert_eq!(parsed.retry_base_delay_ms, Some(250));
20317 assert_eq!(parsed.retry_max_delay_ms, Some(10_000));
20318
20319 let toml_str = toml::to_string(&wc).unwrap();
20320 let parsed: WebhookConfig = toml::from_str(&toml_str).unwrap();
20321 assert_eq!(parsed.max_retries, Some(5));
20322 assert_eq!(parsed.retry_base_delay_ms, Some(250));
20323 assert_eq!(parsed.retry_max_delay_ms, Some(10_000));
20324 }
20325
20326 #[test]
20329 async fn whatsapp_config_serde() {
20330 let wc = WhatsAppConfig {
20331 enabled: true,
20332 access_token: Some("EAABx...".into()),
20333 phone_number_id: Some("123456789".into()),
20334 verify_token: Some("my-verify-token".into()),
20335 app_secret: None,
20336 session_path: None,
20337 pair_phone: None,
20338 pair_code: None,
20339 ws_url: None,
20340 mention_only: false,
20341 interrupt_on_new_message: false,
20342 mode: WhatsAppWebMode::default(),
20343 dm_policy: WhatsAppChatPolicy::default(),
20344 group_policy: WhatsAppChatPolicy::default(),
20345 self_chat_mode: false,
20346 dm_mention_patterns: vec![],
20347 group_mention_patterns: vec![],
20348 proxy_url: None,
20349 approval_timeout_secs: 300,
20350 excluded_tools: vec![],
20351 reply_min_interval_secs: 0,
20352 reply_queue_depth_max: 0,
20353 };
20354 let json = serde_json::to_string(&wc).unwrap();
20355 let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
20356 assert_eq!(parsed.access_token, Some("EAABx...".into()));
20357 assert_eq!(parsed.phone_number_id, Some("123456789".into()));
20358 assert_eq!(parsed.verify_token, Some("my-verify-token".into()));
20359 }
20360
20361 #[test]
20362 async fn whatsapp_config_toml_roundtrip() {
20363 let wc = WhatsAppConfig {
20364 enabled: true,
20365 access_token: Some("tok".into()),
20366 phone_number_id: Some("12345".into()),
20367 verify_token: Some("verify".into()),
20368 app_secret: Some("secret123".into()),
20369 session_path: None,
20370 pair_phone: None,
20371 pair_code: None,
20372 ws_url: None,
20373 mention_only: false,
20374 interrupt_on_new_message: false,
20375 mode: WhatsAppWebMode::default(),
20376 dm_policy: WhatsAppChatPolicy::default(),
20377 group_policy: WhatsAppChatPolicy::default(),
20378 self_chat_mode: false,
20379 dm_mention_patterns: vec![],
20380 group_mention_patterns: vec![],
20381 proxy_url: None,
20382 approval_timeout_secs: 300,
20383 excluded_tools: vec![],
20384 reply_min_interval_secs: 0,
20385 reply_queue_depth_max: 0,
20386 };
20387 let toml_str = toml::to_string(&wc).unwrap();
20388 let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
20389 assert_eq!(parsed.phone_number_id, Some("12345".into()));
20390 }
20391
20392 #[test]
20393 async fn whatsapp_v2_allowed_numbers_fold_into_peer_groups() {
20394 let raw = r#"
20398schema_version = 2
20399
20400[channels.whatsapp]
20401enabled = true
20402access_token = "tok"
20403phone_number_id = "123"
20404verify_token = "ver"
20405allowed_numbers = ["+1", "+2"]
20406"#;
20407 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
20408 let group = parsed
20409 .peer_groups
20410 .get("whatsapp_default")
20411 .expect("V2 whatsapp.allowed_numbers must fold into peer_groups.whatsapp_default");
20412 assert_eq!(group.channel, "whatsapp");
20413 let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
20414 assert_eq!(usernames, vec!["+1", "+2"]);
20415 }
20416
20417 #[test]
20418 async fn whatsapp_config_backend_type_cloud_precedence_when_ambiguous() {
20419 let wc = WhatsAppConfig {
20420 enabled: true,
20421 access_token: Some("tok".into()),
20422 phone_number_id: Some("123".into()),
20423 verify_token: Some("ver".into()),
20424 app_secret: None,
20425 session_path: Some("~/.zeroclaw/state/whatsapp-web/session.db".into()),
20426 pair_phone: None,
20427 pair_code: None,
20428 ws_url: None,
20429 mention_only: false,
20430 interrupt_on_new_message: false,
20431 mode: WhatsAppWebMode::default(),
20432 dm_policy: WhatsAppChatPolicy::default(),
20433 group_policy: WhatsAppChatPolicy::default(),
20434 self_chat_mode: false,
20435 dm_mention_patterns: vec![],
20436 group_mention_patterns: vec![],
20437 proxy_url: None,
20438 approval_timeout_secs: 300,
20439 excluded_tools: vec![],
20440 reply_min_interval_secs: 0,
20441 reply_queue_depth_max: 0,
20442 };
20443 assert!(wc.is_ambiguous_config());
20444 assert_eq!(wc.backend_type(), "cloud");
20445 }
20446
20447 #[test]
20448 async fn whatsapp_config_backend_type_web() {
20449 let wc = WhatsAppConfig {
20450 enabled: true,
20451 access_token: None,
20452 phone_number_id: None,
20453 verify_token: None,
20454 app_secret: None,
20455 session_path: Some("~/.zeroclaw/state/whatsapp-web/session.db".into()),
20456 pair_phone: None,
20457 pair_code: None,
20458 ws_url: None,
20459 mention_only: false,
20460 interrupt_on_new_message: false,
20461 mode: WhatsAppWebMode::default(),
20462 dm_policy: WhatsAppChatPolicy::default(),
20463 group_policy: WhatsAppChatPolicy::default(),
20464 self_chat_mode: false,
20465 dm_mention_patterns: vec![],
20466 group_mention_patterns: vec![],
20467 proxy_url: None,
20468 approval_timeout_secs: 300,
20469 excluded_tools: vec![],
20470 reply_min_interval_secs: 0,
20471 reply_queue_depth_max: 0,
20472 };
20473 assert!(!wc.is_ambiguous_config());
20474 assert_eq!(wc.backend_type(), "web");
20475 }
20476
20477 #[test]
20478 async fn channels_with_whatsapp() {
20479 let c = ChannelsConfig {
20480 cli: true,
20481 telegram: HashMap::new(),
20482 discord: HashMap::new(),
20483 slack: HashMap::new(),
20484 mattermost: HashMap::new(),
20485 webhook: HashMap::new(),
20486 imessage: HashMap::new(),
20487 matrix: HashMap::new(),
20488 signal: HashMap::new(),
20489 whatsapp: HashMap::from([(
20490 "default".to_string(),
20491 WhatsAppConfig {
20492 enabled: true,
20493 access_token: Some("tok".into()),
20494 phone_number_id: Some("123".into()),
20495 verify_token: Some("ver".into()),
20496 app_secret: None,
20497 session_path: None,
20498 pair_phone: None,
20499 pair_code: None,
20500 ws_url: None,
20501 mention_only: false,
20502 interrupt_on_new_message: false,
20503 mode: WhatsAppWebMode::default(),
20504 dm_policy: WhatsAppChatPolicy::default(),
20505 group_policy: WhatsAppChatPolicy::default(),
20506 self_chat_mode: false,
20507 dm_mention_patterns: vec![],
20508 group_mention_patterns: vec![],
20509 proxy_url: None,
20510 approval_timeout_secs: 300,
20511 excluded_tools: vec![],
20512 reply_min_interval_secs: 0,
20513 reply_queue_depth_max: 0,
20514 },
20515 )]),
20516 linq: HashMap::new(),
20517 wati: HashMap::new(),
20518 nextcloud_talk: HashMap::new(),
20519 email: HashMap::new(),
20520 gmail_push: HashMap::new(),
20521 irc: HashMap::new(),
20522 twitch: HashMap::new(),
20523 lark: HashMap::new(),
20524 line: HashMap::new(),
20525 dingtalk: HashMap::new(),
20526 wecom: HashMap::new(),
20527 wecom_ws: HashMap::new(),
20528 wechat: HashMap::new(),
20529 qq: HashMap::new(),
20530 twitter: HashMap::new(),
20531 mochat: HashMap::new(),
20532 nostr: HashMap::new(),
20533 clawdtalk: HashMap::new(),
20534 reddit: HashMap::new(),
20535 bluesky: HashMap::new(),
20536 voice_call: HashMap::new(),
20537 voice_duplex: HashMap::new(),
20538 voice_wake: HashMap::new(),
20539 mqtt: HashMap::new(),
20540 amqp: HashMap::new(),
20541 message_timeout_secs: 300,
20542 max_concurrent_per_channel: default_channel_max_concurrent_per_channel(),
20543 ack_reactions: true,
20544 show_tool_calls: true,
20545 session_persistence: true,
20546 session_backend: default_session_backend(),
20547 session_ttl_hours: 0,
20548 debounce_ms: 0,
20549 };
20550 let toml_str = toml::to_string_pretty(&c).unwrap();
20551 let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
20552 assert!(!parsed.whatsapp.is_empty());
20553 let wa = parsed.whatsapp.get("default").unwrap();
20554 assert_eq!(wa.phone_number_id, Some("123".into()));
20555 }
20556
20557 #[test]
20558 async fn channels_default_has_no_whatsapp() {
20559 let c = ChannelsConfig::default();
20560 assert!(c.whatsapp.is_empty());
20561 }
20562
20563 #[test]
20564 async fn channels_default_has_no_nextcloud_talk() {
20565 let c = ChannelsConfig::default();
20566 assert!(c.nextcloud_talk.is_empty());
20567 }
20568
20569 #[test]
20574 async fn checklist_gateway_default_requires_pairing() {
20575 let g = GatewayConfig::default();
20576 assert!(g.require_pairing, "Pairing must be required by default");
20577 }
20578
20579 #[test]
20580 async fn checklist_gateway_default_blocks_public_bind() {
20581 let g = GatewayConfig::default();
20582 assert!(
20583 !g.allow_public_bind,
20584 "Public bind must be blocked by default"
20585 );
20586 }
20587
20588 #[test]
20589 async fn checklist_gateway_default_no_tokens() {
20590 let g = GatewayConfig::default();
20591 assert!(
20592 g.paired_tokens.is_empty(),
20593 "No pre-paired tokens by default"
20594 );
20595 assert_eq!(g.pair_rate_limit_per_minute, 10);
20596 assert_eq!(g.webhook_rate_limit_per_minute, 60);
20597 assert!(!g.trust_forwarded_headers);
20598 assert_eq!(g.rate_limit_max_keys, 10_000);
20599 assert_eq!(g.idempotency_ttl_secs, 300);
20600 assert_eq!(g.idempotency_max_keys, 10_000);
20601 }
20602
20603 #[test]
20604 async fn checklist_gateway_cli_default_host_is_localhost() {
20605 let c = Config::default();
20608 assert!(
20609 c.gateway.require_pairing,
20610 "Config default must require pairing"
20611 );
20612 assert!(
20613 !c.gateway.allow_public_bind,
20614 "Config default must block public bind"
20615 );
20616 }
20617
20618 #[test]
20619 async fn checklist_gateway_serde_roundtrip() {
20620 let g = GatewayConfig {
20621 port: 42617,
20622 host: "127.0.0.1".into(),
20623 require_pairing: true,
20624 allow_public_bind: false,
20625 allow_remote_admin: false,
20626 paired_tokens: vec!["zc_test_token".into()],
20627 pair_rate_limit_per_minute: 12,
20628 webhook_rate_limit_per_minute: 80,
20629 trust_forwarded_headers: true,
20630 path_prefix: Some("/zeroclaw".into()),
20631 rate_limit_max_keys: 2048,
20632 idempotency_ttl_secs: 600,
20633 idempotency_max_keys: 4096,
20634 session_persistence: true,
20635 session_ttl_hours: 0,
20636 pairing_dashboard: PairingDashboardConfig::default(),
20637 web_dist_dir: None,
20638 tls: None,
20639 request_timeout_secs: 30,
20640 long_running_request_timeout_secs: 600,
20641 };
20642 let toml_str = toml::to_string(&g).unwrap();
20643 let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
20644 assert!(parsed.require_pairing);
20645 assert!(parsed.session_persistence);
20646 assert_eq!(parsed.session_ttl_hours, 0);
20647 assert!(!parsed.allow_public_bind);
20648 assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
20649 assert_eq!(parsed.pair_rate_limit_per_minute, 12);
20650 assert_eq!(parsed.webhook_rate_limit_per_minute, 80);
20651 assert!(parsed.trust_forwarded_headers);
20652 assert_eq!(parsed.path_prefix.as_deref(), Some("/zeroclaw"));
20653 assert_eq!(parsed.rate_limit_max_keys, 2048);
20654 assert_eq!(parsed.idempotency_ttl_secs, 600);
20655 assert_eq!(parsed.idempotency_max_keys, 4096);
20656 }
20657
20658 #[test]
20659 async fn checklist_gateway_backward_compat_no_gateway_section() {
20660 let minimal = r#"
20662workspace_dir = "/tmp/ws"
20663config_path = "/tmp/config.toml"
20664default_temperature = 0.7
20665"#;
20666 let parsed = parse_test_config(minimal);
20667 assert!(
20668 parsed.gateway.require_pairing,
20669 "Missing [gateway] must default to require_pairing=true"
20670 );
20671 assert!(
20672 !parsed.gateway.allow_public_bind,
20673 "Missing [gateway] must default to allow_public_bind=false"
20674 );
20675 }
20676
20677 #[test]
20678 async fn checklist_risk_profile_default_is_workspace_scoped() {
20679 let a = RiskProfileConfig::default();
20680 assert!(a.workspace_only, "Default profile must be workspace_only");
20681 assert!(
20682 !a.forbidden_paths.is_empty(),
20683 "Default forbidden_paths must not be empty"
20684 );
20685 #[cfg(not(target_os = "windows"))]
20686 {
20687 assert!(
20688 a.forbidden_paths.iter().any(|p| p == "/etc"),
20689 "Must block /etc on Unix"
20690 );
20691 assert!(
20692 a.forbidden_paths.iter().any(|p| p == "/proc"),
20693 "Must block /proc on Unix"
20694 );
20695 }
20696 #[cfg(target_os = "windows")]
20697 {
20698 assert!(
20699 a.forbidden_paths.iter().any(|p| p == "C:\\Windows"),
20700 "Must block C:\\Windows on Windows"
20701 );
20702 assert!(
20703 a.forbidden_paths.iter().any(|p| p == "C:\\Program Files"),
20704 "Must block C:\\Program Files on Windows"
20705 );
20706 }
20707 assert!(
20708 a.forbidden_paths.contains(&"~/.ssh".to_string()),
20709 "Must block ~/.ssh"
20710 );
20711 }
20712
20713 #[test]
20718 async fn composio_config_default_disabled() {
20719 let c = ComposioConfig::default();
20720 assert!(!c.enabled, "Composio must be disabled by default");
20721 assert!(c.api_key.is_none(), "No API key by default");
20722 assert_eq!(c.entity_id, "default");
20723 }
20724
20725 #[test]
20726 async fn composio_config_serde_roundtrip() {
20727 let c = ComposioConfig {
20728 enabled: true,
20729 api_key: Some("comp-key-123".into()),
20730 entity_id: "user42".into(),
20731 };
20732 let toml_str = toml::to_string(&c).unwrap();
20733 let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
20734 assert!(parsed.enabled);
20735 assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
20736 assert_eq!(parsed.entity_id, "user42");
20737 }
20738
20739 #[test]
20740 async fn composio_config_backward_compat_missing_section() {
20741 let minimal = r#"
20742workspace_dir = "/tmp/ws"
20743config_path = "/tmp/config.toml"
20744default_temperature = 0.7
20745"#;
20746 let parsed = parse_test_config(minimal);
20747 assert!(
20748 !parsed.composio.enabled,
20749 "Missing [composio] must default to disabled"
20750 );
20751 assert!(parsed.composio.api_key.is_none());
20752 }
20753
20754 #[test]
20755 async fn composio_config_partial_toml() {
20756 let toml_str = r"
20757enabled = true
20758";
20759 let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
20760 assert!(parsed.enabled);
20761 assert!(parsed.api_key.is_none());
20762 assert_eq!(parsed.entity_id, "default");
20763 }
20764
20765 #[test]
20766 async fn composio_config_enable_alias_supported() {
20767 let toml_str = r"
20768enable = true
20769";
20770 let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
20771 assert!(parsed.enabled);
20772 assert!(parsed.api_key.is_none());
20773 assert_eq!(parsed.entity_id, "default");
20774 }
20775
20776 #[test]
20781 async fn secrets_config_default_encrypts() {
20782 let s = SecretsConfig::default();
20783 assert!(s.encrypt, "Encryption must be enabled by default");
20784 }
20785
20786 #[test]
20787 async fn secrets_config_serde_roundtrip() {
20788 let s = SecretsConfig { encrypt: false };
20789 let toml_str = toml::to_string(&s).unwrap();
20790 let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
20791 assert!(!parsed.encrypt);
20792 }
20793
20794 #[test]
20795 async fn secrets_config_backward_compat_missing_section() {
20796 let minimal = r#"
20797workspace_dir = "/tmp/ws"
20798config_path = "/tmp/config.toml"
20799default_temperature = 0.7
20800"#;
20801 let parsed = parse_test_config(minimal);
20802 assert!(
20803 parsed.secrets.encrypt,
20804 "Missing [secrets] must default to encrypt=true"
20805 );
20806 }
20807
20808 #[test]
20809 async fn config_default_has_composio_and_secrets() {
20810 let c = Config::default();
20811 assert!(!c.composio.enabled);
20812 assert!(c.composio.api_key.is_none());
20813 assert!(c.secrets.encrypt);
20814 assert!(c.browser.enabled);
20815 assert_eq!(c.browser.allowed_domains, vec!["*".to_string()]);
20816 }
20817
20818 #[test]
20819 async fn browser_config_default_enabled() {
20820 let b = BrowserConfig::default();
20821 assert!(b.enabled);
20822 assert_eq!(b.allowed_domains, vec!["*".to_string()]);
20823 assert_eq!(b.backend, "agent_browser");
20824 assert_eq!(b.headed, None);
20825 assert!(b.native_headless);
20826 assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
20827 assert!(b.native_chrome_path.is_none());
20828 assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions");
20829 assert_eq!(b.computer_use.timeout_ms, 15_000);
20830 assert!(!b.computer_use.allow_remote_endpoint);
20831 assert!(b.computer_use.window_allowlist.is_empty());
20832 assert!(b.computer_use.max_coordinate_x.is_none());
20833 assert!(b.computer_use.max_coordinate_y.is_none());
20834 }
20835
20836 #[test]
20837 async fn browser_config_serde_roundtrip() {
20838 let b = BrowserConfig {
20839 enabled: true,
20840 allowed_domains: vec!["example.com".into(), "docs.example.com".into()],
20841 session_name: None,
20842 backend: "auto".into(),
20843 headed: Some(true),
20844 native_headless: false,
20845 native_webdriver_url: "http://localhost:4444".into(),
20846 native_chrome_path: Some("/usr/bin/chromium".into()),
20847 computer_use: BrowserComputerUseConfig {
20848 endpoint: "https://computer-use.example.com/v1/actions".into(),
20849 api_key: Some("test-token".into()),
20850 timeout_ms: 8_000,
20851 allow_remote_endpoint: true,
20852 window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()],
20853 max_coordinate_x: Some(3840),
20854 max_coordinate_y: Some(2160),
20855 },
20856 };
20857 let toml_str = toml::to_string(&b).unwrap();
20858 let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap();
20859 assert!(parsed.enabled);
20860 assert_eq!(parsed.allowed_domains.len(), 2);
20861 assert_eq!(parsed.allowed_domains[0], "example.com");
20862 assert_eq!(parsed.backend, "auto");
20863 assert_eq!(parsed.headed, Some(true));
20864 assert!(!parsed.native_headless);
20865 assert_eq!(parsed.native_webdriver_url, "http://localhost:4444");
20866 assert_eq!(
20867 parsed.native_chrome_path.as_deref(),
20868 Some("/usr/bin/chromium")
20869 );
20870 assert_eq!(
20871 parsed.computer_use.endpoint,
20872 "https://computer-use.example.com/v1/actions"
20873 );
20874 assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token"));
20875 assert_eq!(parsed.computer_use.timeout_ms, 8_000);
20876 assert!(parsed.computer_use.allow_remote_endpoint);
20877 assert_eq!(parsed.computer_use.window_allowlist.len(), 2);
20878 assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840));
20879 assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160));
20880 }
20881
20882 #[test]
20883 async fn browser_config_parses_headed_true() {
20884 let parsed: BrowserConfig = toml::from_str(
20885 r#"
20886backend = "agent_browser"
20887headed = true
20888"#,
20889 )
20890 .unwrap();
20891
20892 assert_eq!(parsed.backend, "agent_browser");
20893 assert_eq!(parsed.headed, Some(true));
20894 assert!(parsed.native_headless);
20895 }
20896
20897 #[test]
20898 async fn browser_config_backward_compat_missing_section() {
20899 let minimal = r#"
20900workspace_dir = "/tmp/ws"
20901config_path = "/tmp/config.toml"
20902default_temperature = 0.7
20903"#;
20904 let parsed = parse_test_config(minimal);
20905 assert!(parsed.browser.enabled);
20906 assert_eq!(parsed.browser.allowed_domains, vec!["*".to_string()]);
20907 }
20908
20909 async fn env_override_lock() -> MutexGuard<'static, ()> {
20910 crate::env_overrides::env_test_lock().await
20914 }
20915
20916 #[test]
20917 async fn v1_known_provider_migrates_with_globals_folded_onto_typed_slot() {
20918 let raw = r#"
20927default_temperature = 0.7
20928model_provider = "openai"
20929model = "gpt-5.3-codex"
20930
20931[model_providers.openai]
20932api_key = "sk-test"
20933uri = "https://api.openai.com/v1"
20934wire_api = "responses"
20935requires_openai_auth = true
20936"#;
20937
20938 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
20939 assert!(
20940 parsed
20941 .providers
20942 .models
20943 .contains_model_provider_type("openai"),
20944 "vendor-canonical V1 provider should land in its typed slot",
20945 );
20946 let profile = parsed
20947 .providers
20948 .models
20949 .find("openai", "default")
20950 .expect("openai.default entry");
20951 assert_eq!(profile.api_key.as_deref(), Some("sk-test"));
20952 assert_eq!(profile.uri.as_deref(), Some("https://api.openai.com/v1"));
20953 assert_eq!(profile.model.as_deref(), Some("gpt-5.3-codex"));
20954 assert_eq!(profile.wire_api, Some(WireApi::Responses));
20955 assert!(profile.requires_openai_auth);
20956 }
20957
20958 #[test]
20959 async fn typed_custom_slot_routes_uri_through_find() {
20960 let _env_guard = env_override_lock().await;
20961 let mut config = Config::default();
20962 config.providers.models.custom.insert(
20963 "default".to_string(),
20964 CustomModelProviderConfig {
20965 base: ModelProviderConfig {
20966 uri: Some("https://api.tonsof.blue/v1".to_string()),
20967 ..Default::default()
20968 },
20969 },
20970 );
20971
20972 assert_eq!(
20973 config
20974 .providers
20975 .models
20976 .find("custom", "default")
20977 .and_then(|e| e.uri.as_deref()),
20978 Some("https://api.tonsof.blue/v1")
20979 );
20980 assert!(config.providers.models.find("custom", "default").is_some());
20981 }
20982
20983 #[test]
20984 async fn openai_codex_alias_carries_responses_wire_api_and_requires_openai_auth() {
20985 let _env_guard = env_override_lock().await;
20986 let mut config = Config::default();
20987 config.providers.models.openai.insert(
20988 "codex".to_string(),
20989 OpenAIModelProviderConfig {
20990 base: ModelProviderConfig {
20991 uri: Some("https://api.tonsof.blue".to_string()),
20992 wire_api: Some(WireApi::Responses),
20993 requires_openai_auth: true,
20994 ..Default::default()
20995 },
20996 },
20997 );
20998
20999 let entry = config
21000 .providers
21001 .models
21002 .find("openai", "codex")
21003 .expect("openai.codex entry");
21004 assert_eq!(entry.uri.as_deref(), Some("https://api.tonsof.blue"));
21005 assert_eq!(entry.wire_api, Some(WireApi::Responses));
21006 assert!(entry.requires_openai_auth);
21007 }
21008
21009 #[test]
21013 async fn provider_models_round_trips_through_load_apply_serialize() {
21014 let _env_guard = env_override_lock().await;
21015 let toml_in = r#"
21016schema_version = 3
21017
21018[providers.models.openrouter.default]
21019uri = "https://example.invalid/v1"
21020model = "primary-model"
21021"#;
21022
21023 let config: Config = toml::from_str(toml_in).expect("parse toml");
21024
21025 assert_eq!(
21026 config
21027 .providers
21028 .models
21029 .find("openrouter", "default")
21030 .and_then(|e| e.model.as_deref()),
21031 Some("primary-model"),
21032 );
21033
21034 let toml_out = toml::to_string(&config).expect("serialize toml");
21036 assert!(
21037 toml_out.contains("primary-model"),
21038 "serialized config must keep model value; got:\n{toml_out}",
21039 );
21040 }
21041
21042 #[test]
21047 async fn resolve_default_model_picks_first_available() {
21048 let _env_guard = env_override_lock().await;
21049 let mut config = Config::default();
21050 assert_eq!(config.resolve_default_model(), None);
21052
21053 config
21055 .providers
21056 .models
21057 .anthropic
21058 .insert("default".into(), AnthropicModelProviderConfig::default());
21059 assert_eq!(config.resolve_default_model(), None);
21060
21061 config.providers.models.together.insert(
21063 "default".to_string(),
21064 TogetherModelProviderConfig {
21065 base: ModelProviderConfig {
21066 model: Some("tertiary-model".to_string()),
21067 ..Default::default()
21068 },
21069 },
21070 );
21071 assert_eq!(
21072 config.resolve_default_model().as_deref(),
21073 Some("tertiary-model"),
21074 );
21075
21076 config.providers.models.openrouter.insert(
21078 "default".to_string(),
21079 OpenRouterModelProviderConfig {
21080 base: ModelProviderConfig {
21081 model: Some("primary-model".to_string()),
21082 ..Default::default()
21083 },
21084 },
21085 );
21086 assert!(config.resolve_default_model().is_some());
21088 }
21089
21090 #[test]
21091 async fn save_repairs_bare_config_filename_using_runtime_resolution() {
21092 let _env_guard = env_override_lock().await;
21093 let temp_home =
21094 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21095 let workspace_dir = temp_home.join("workspace");
21096 let resolved_config_path = temp_home.join(".zeroclaw").join("config.toml");
21097
21098 let original_home = std::env::var("HOME").ok();
21099 unsafe { std::env::set_var("HOME", &temp_home) };
21101 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21103
21104 let mut config = Config {
21105 data_dir: workspace_dir,
21106 config_path: PathBuf::from("config.toml"),
21107 ..Default::default()
21108 };
21109 config.providers.models.anthropic.insert(
21110 "default".to_string(),
21111 AnthropicModelProviderConfig {
21112 base: ModelProviderConfig {
21113 temperature: Some(0.5),
21114 ..Default::default()
21115 },
21116 },
21117 );
21118 config.save().await.unwrap();
21120
21121 assert!(resolved_config_path.exists());
21122 let saved = tokio::fs::read_to_string(&resolved_config_path)
21123 .await
21124 .unwrap();
21125 let parsed = parse_test_config(&saved);
21126 assert!(
21127 (parsed
21128 .providers
21129 .models
21130 .find("anthropic", "default")
21131 .and_then(|e| e.temperature)
21132 .unwrap_or(0.7)
21133 - 0.5)
21134 .abs()
21135 < f64::EPSILON
21136 );
21137
21138 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21140 if let Some(home) = original_home {
21141 unsafe { std::env::set_var("HOME", home) };
21143 } else {
21144 unsafe { std::env::remove_var("HOME") };
21146 }
21147 let _ = tokio::fs::remove_dir_all(temp_home).await;
21148 }
21149
21150 #[test]
21151 async fn validate_ollama_cloud_model_requires_remote_api_url() {
21152 let _env_guard = env_override_lock().await;
21153 let mut config = Config::default();
21154 config.providers.models.ollama.insert(
21155 "default".to_string(),
21156 OllamaModelProviderConfig {
21157 base: ModelProviderConfig {
21158 model: Some("glm-5:cloud".to_string()),
21159 uri: None,
21160 api_key: Some("ollama-key".to_string()),
21161 ..Default::default()
21162 },
21163 ..OllamaModelProviderConfig::default()
21164 },
21165 );
21166
21167 let error = config.validate().expect_err("expected validation to fail");
21168 assert!(error.to_string().contains(
21169 "providers.models.ollama.default.model uses ':cloud', but uri is local or unset"
21170 ));
21171 }
21172
21173 #[test]
21174 async fn validate_ollama_cloud_model_accepts_private_remote_without_api_key() {
21175 let _env_guard = env_override_lock().await;
21176 let mut config = Config::default();
21177 config.providers.models.ollama.insert(
21178 "default".to_string(),
21179 OllamaModelProviderConfig {
21180 base: ModelProviderConfig {
21181 model: Some("glm-5:cloud".to_string()),
21182 uri: Some("http://192.168.1.100:11434".to_string()),
21183 api_key: None,
21184 ..Default::default()
21185 },
21186 ..OllamaModelProviderConfig::default()
21187 },
21188 );
21189
21190 let result = config.validate();
21191 assert!(result.is_ok(), "expected validation to pass: {result:?}");
21192 }
21193
21194 #[test]
21195 async fn validate_ollama_cloud_model_requires_api_key_for_official_endpoint() {
21196 let _env_guard = env_override_lock().await;
21197 let mut config = Config::default();
21198 config.providers.models.ollama.insert(
21199 "default".to_string(),
21200 OllamaModelProviderConfig {
21201 base: ModelProviderConfig {
21202 model: Some("glm-5:cloud".to_string()),
21203 uri: Some("https://ollama.com/api".to_string()),
21204 api_key: None,
21205 ..Default::default()
21206 },
21207 ..OllamaModelProviderConfig::default()
21208 },
21209 );
21210
21211 let error = config.validate().expect_err("expected validation to fail");
21212 assert!(error.to_string().contains(
21213 "providers.models.ollama.default.model uses ':cloud', but no API key is configured"
21214 ));
21215 }
21216
21217 #[test]
21218 async fn validate_ollama_cloud_model_accepts_remote_endpoint_with_typed_api_key() {
21219 let _env_guard = env_override_lock().await;
21222 let mut config = Config::default();
21223 config.providers.models.ollama.insert(
21224 "default".to_string(),
21225 OllamaModelProviderConfig {
21226 base: ModelProviderConfig {
21227 model: Some("glm-5:cloud".to_string()),
21228 uri: Some("https://ollama.com/api".to_string()),
21229 api_key: Some("ollama-typed-key".to_string()),
21230 ..Default::default()
21231 },
21232 ..OllamaModelProviderConfig::default()
21233 },
21234 );
21235
21236 let result = config.validate();
21237 assert!(result.is_ok(), "expected validation to pass: {result:?}");
21238 }
21239
21240 #[test]
21241 async fn validate_ollama_cloud_model_checks_each_alias_for_official_key() {
21242 let _env_guard = env_override_lock().await;
21243 let mut config = Config::default();
21244 config.providers.models.ollama.insert(
21245 "local".to_string(),
21246 OllamaModelProviderConfig {
21247 base: ModelProviderConfig {
21248 model: Some("llama3".to_string()),
21249 uri: Some("http://192.168.1.100:11434".to_string()),
21250 ..Default::default()
21251 },
21252 ..OllamaModelProviderConfig::default()
21253 },
21254 );
21255 config.providers.models.ollama.insert(
21256 "cloud".to_string(),
21257 OllamaModelProviderConfig {
21258 base: ModelProviderConfig {
21259 model: Some("glm-5:cloud".to_string()),
21260 uri: Some("https://ollama.com/api".to_string()),
21261 api_key: None,
21262 ..Default::default()
21263 },
21264 ..OllamaModelProviderConfig::default()
21265 },
21266 );
21267
21268 let error = config.validate().expect_err("expected validation to fail");
21269 assert!(error.to_string().contains(
21270 "providers.models.ollama.cloud.model uses ':cloud', but no API key is configured"
21271 ));
21272 }
21273
21274 #[test]
21275 async fn deserialize_rejects_unknown_model_provider_wire_api() {
21276 let toml = r#"
21277schema_version = 3
21278
21279[providers.models.openrouter.default]
21280uri = "https://api.tonsof.blue/v1"
21281wire_api = "ws"
21282"#;
21283 let err = toml::from_str::<Config>(toml).expect_err("expected deserialize failure");
21284 let msg = err.to_string();
21285 assert!(
21286 msg.contains("wire_api") || msg.contains("ws"),
21287 "error should reference the invalid wire_api value, got: {msg}"
21288 );
21289 }
21290
21291 #[test]
21292 async fn resolve_runtime_config_dirs_accepts_legacy_zeroclaw_workspace() {
21293 let _env_guard = env_override_lock().await;
21294 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
21295 let default_workspace_dir = default_config_dir.join("workspace");
21296 let workspace_dir = default_config_dir.join("profile-a");
21297
21298 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21300 let (config_dir, resolved_workspace_dir, source) =
21301 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
21302 .await
21303 .unwrap();
21304
21305 assert_eq!(source, ConfigResolutionSource::EnvWorkspaceLegacy);
21309 assert_eq!(config_dir, workspace_dir);
21310 assert_eq!(resolved_workspace_dir, workspace_dir.join("data"));
21311
21312 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21314 let _ = fs::remove_dir_all(default_config_dir).await;
21315 }
21316
21317 #[test]
21318 async fn resolve_runtime_config_dirs_uses_env_config_dir_first() {
21319 let _env_guard = env_override_lock().await;
21320 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
21321 let default_workspace_dir = default_config_dir.join("workspace");
21322 let explicit_config_dir = default_config_dir.join("explicit-config");
21323
21324 fs::create_dir_all(&default_config_dir).await.unwrap();
21325
21326 unsafe { std::env::set_var("ZEROCLAW_CONFIG_DIR", &explicit_config_dir) };
21328 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21330
21331 let (config_dir, resolved_workspace_dir, source) =
21332 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
21333 .await
21334 .unwrap();
21335
21336 assert_eq!(source, ConfigResolutionSource::EnvConfigDir);
21337 assert_eq!(config_dir, explicit_config_dir);
21338 assert_eq!(resolved_workspace_dir, explicit_config_dir.join("data"));
21339
21340 unsafe { std::env::remove_var("ZEROCLAW_CONFIG_DIR") };
21342 let _ = fs::remove_dir_all(default_config_dir).await;
21343 }
21344
21345 #[test]
21346 async fn resolve_runtime_config_dirs_falls_back_to_default_layout() {
21347 let _env_guard = env_override_lock().await;
21348 let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
21349 let default_workspace_dir = default_config_dir.join("workspace");
21350
21351 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21353 let (config_dir, resolved_workspace_dir, source) =
21354 resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
21355 .await
21356 .unwrap();
21357
21358 assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
21359 assert_eq!(config_dir, default_config_dir);
21360 assert_eq!(resolved_workspace_dir, default_workspace_dir);
21361
21362 let _ = fs::remove_dir_all(default_config_dir).await;
21363 }
21364
21365 async fn create_homebrew_prefix() -> TempDir {
21366 let prefix = TempDir::new().expect("homebrew prefix temp dir");
21367 fs::create_dir_all(prefix.path().join("Cellar"))
21368 .await
21369 .expect("create Cellar marker");
21370 prefix
21371 }
21372
21373 #[test]
21374 async fn try_resolve_macos_homebrew_config_dir_detects_cellar_layout() {
21375 let prefix = create_homebrew_prefix().await;
21376 let exe = prefix
21377 .path()
21378 .join("Cellar")
21379 .join("zeroclaw")
21380 .join("0.7.0")
21381 .join("bin")
21382 .join("zeroclaw");
21383
21384 let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
21385 .await
21386 .expect("expected Homebrew layout");
21387
21388 assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
21389 }
21390
21391 #[test]
21392 async fn try_resolve_macos_homebrew_config_dir_detects_prefix_bin_layout() {
21393 let prefix = create_homebrew_prefix().await;
21394 let exe = prefix.path().join("bin").join("zeroclaw");
21395
21396 let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
21397 .await
21398 .expect("expected Homebrew layout");
21399
21400 assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
21401 }
21402
21403 #[test]
21404 async fn try_resolve_macos_homebrew_config_dir_detects_opt_bin_layout() {
21405 let prefix = create_homebrew_prefix().await;
21406 let exe = prefix
21407 .path()
21408 .join("opt")
21409 .join("zeroclaw")
21410 .join("bin")
21411 .join("zeroclaw");
21412
21413 let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
21414 .await
21415 .expect("expected Homebrew layout");
21416
21417 assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
21418 }
21419
21420 #[test]
21421 async fn try_resolve_macos_homebrew_config_dir_rejects_non_homebrew_layout() {
21422 let prefix = TempDir::new().expect("non-homebrew temp dir");
21423 let exe = prefix.path().join("bin").join("zeroclaw");
21424
21425 assert!(try_resolve_macos_homebrew_config_dir(&exe).await.is_none());
21426 }
21427
21428 #[test]
21429 async fn default_path_under_config_dir_respects_zeroclaw_config_dir() {
21430 let _env_guard = env_override_lock().await;
21431 let custom_dir = std::env::temp_dir().join("zeroclaw-test-profile");
21432 unsafe { std::env::set_var("ZEROCLAW_CONFIG_DIR", &custom_dir) };
21434
21435 let result = default_path_under_config_dir("knowledge.db");
21436
21437 unsafe { std::env::remove_var("ZEROCLAW_CONFIG_DIR") };
21439
21440 assert_eq!(
21441 result,
21442 custom_dir.join("knowledge.db").to_string_lossy().as_ref(),
21443 "expected path under ZEROCLAW_CONFIG_DIR, got: {result}"
21444 );
21445 }
21446
21447 #[test]
21448 async fn load_or_init_workspace_override_uses_workspace_root_for_config() {
21449 let _env_guard = env_override_lock().await;
21450 let temp_home =
21451 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21452 let workspace_dir = temp_home.join("profile-a");
21453
21454 let original_home = std::env::var("HOME").ok();
21455 unsafe { std::env::set_var("HOME", &temp_home) };
21457 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21459
21460 let config = Box::pin(Config::load_or_init()).await.unwrap();
21461
21462 assert_eq!(config.data_dir, workspace_dir.join("data"));
21468 assert_eq!(config.config_path, workspace_dir.join("config.toml"));
21469 assert!(workspace_dir.join("config.toml").exists());
21470 assert!(
21471 !workspace_dir.join("agents").exists(),
21472 "fresh init must not create agents/ tree"
21473 );
21474
21475 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21477 if let Some(home) = original_home {
21478 unsafe { std::env::set_var("HOME", home) };
21480 } else {
21481 unsafe { std::env::remove_var("HOME") };
21483 }
21484 let _ = fs::remove_dir_all(temp_home).await;
21485 }
21486
21487 #[test]
21488 async fn load_or_init_workspace_suffix_uses_legacy_config_layout() {
21489 let _env_guard = env_override_lock().await;
21490 let temp_home =
21491 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21492 let workspace_dir = temp_home.join("workspace");
21493 let legacy_config_dir = temp_home.join(".zeroclaw");
21494 let legacy_config_path = legacy_config_dir.join("config.toml");
21495
21496 let original_home = std::env::var("HOME").ok();
21497 unsafe { std::env::set_var("HOME", &temp_home) };
21499 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21501
21502 let config = Box::pin(Config::load_or_init()).await.unwrap();
21503
21504 assert_eq!(config.data_dir, legacy_config_dir.join("data"));
21509 assert_eq!(config.config_path, legacy_config_path);
21510 assert!(config.config_path.exists());
21511
21512 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21514 if let Some(home) = original_home {
21515 unsafe { std::env::set_var("HOME", home) };
21517 } else {
21518 unsafe { std::env::remove_var("HOME") };
21520 }
21521 let _ = fs::remove_dir_all(temp_home).await;
21522 }
21523
21524 #[test]
21525 async fn load_or_init_workspace_override_keeps_existing_legacy_config() {
21526 let _env_guard = env_override_lock().await;
21527 let temp_home =
21528 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21529 let workspace_dir = temp_home.join("custom-workspace");
21530 let legacy_config_dir = temp_home.join(".zeroclaw");
21531 let legacy_config_path = legacy_config_dir.join("config.toml");
21532
21533 fs::create_dir_all(&legacy_config_dir).await.unwrap();
21534 fs::write(
21535 &legacy_config_path,
21536 r#"default_temperature = 0.7
21537default_model = "legacy-model"
21538"#,
21539 )
21540 .await
21541 .unwrap();
21542
21543 let original_home = std::env::var("HOME").ok();
21544 unsafe { std::env::set_var("HOME", &temp_home) };
21546 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21548
21549 let config = Box::pin(Config::load_or_init()).await.unwrap();
21550
21551 assert_eq!(config.data_dir, legacy_config_dir.join("data"));
21556 assert_eq!(config.config_path, legacy_config_path);
21557 assert_eq!(
21558 config
21559 .providers
21560 .models
21561 .find("openrouter", "default")
21562 .and_then(|e| e.model.as_deref()),
21563 Some("legacy-model")
21564 );
21565
21566 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21568 if let Some(home) = original_home {
21569 unsafe { std::env::set_var("HOME", home) };
21571 } else {
21572 unsafe { std::env::remove_var("HOME") };
21574 }
21575 let _ = fs::remove_dir_all(temp_home).await;
21576 }
21577
21578 #[test]
21579 async fn load_or_init_decrypts_feishu_channel_secrets() {
21580 let _env_guard = env_override_lock().await;
21581 let temp_home =
21582 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21583 let config_dir = temp_home.join(".zeroclaw");
21584 let config_path = config_dir.join("config.toml");
21585
21586 fs::create_dir_all(&config_dir).await.unwrap();
21587
21588 let original_home = std::env::var("HOME").ok();
21589 unsafe { std::env::set_var("HOME", &temp_home) };
21591 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21593
21594 let mut config = Config {
21595 config_path: config_path.clone(),
21596 data_dir: config_dir.join("workspace"),
21597 ..Default::default()
21598 };
21599 config.secrets.encrypt = true;
21600 config.channels.lark.insert(
21601 "feishu".to_string(),
21602 LarkConfig {
21603 enabled: true,
21604 app_id: "cli_feishu_123".into(),
21605 app_secret: "feishu-secret".into(),
21606 encrypt_key: Some("feishu-encrypt".into()),
21607 verification_token: Some("feishu-verify".into()),
21608 mention_only: false,
21609 use_feishu: true,
21610 receive_mode: LarkReceiveMode::Websocket,
21611 port: None,
21612 proxy_url: None,
21613 excluded_tools: vec![],
21614 approval_timeout_secs: 300,
21615 per_user_session: false,
21616 stream_mode: StreamMode::default(),
21617 draft_update_interval_ms: default_draft_update_interval_ms(),
21618 },
21619 );
21620 config.save().await.unwrap();
21621
21622 let loaded = Box::pin(Config::load_or_init()).await.unwrap();
21623 let feishu = loaded.channels.lark.get("feishu").unwrap();
21624 assert_eq!(feishu.app_secret, "feishu-secret");
21625 assert_eq!(feishu.encrypt_key.as_deref(), Some("feishu-encrypt"));
21626 assert_eq!(feishu.verification_token.as_deref(), Some("feishu-verify"));
21627
21628 if let Some(home) = original_home {
21629 unsafe { std::env::set_var("HOME", home) };
21631 } else {
21632 unsafe { std::env::remove_var("HOME") };
21634 }
21635 let _ = fs::remove_dir_all(temp_home).await;
21636 }
21637
21638 #[test]
21639 #[allow(clippy::large_futures)]
21640 async fn load_or_init_logs_existing_config_as_initialized() {
21641 let _env_guard = env_override_lock().await;
21642 let temp_home =
21643 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21644 let workspace_dir = temp_home.join("profile-a");
21645 let config_path = workspace_dir.join("config.toml");
21646
21647 fs::create_dir_all(&workspace_dir).await.unwrap();
21648 fs::write(
21649 &config_path,
21650 r#"default_temperature = 0.7
21651default_model = "persisted-profile"
21652"#,
21653 )
21654 .await
21655 .unwrap();
21656
21657 let original_home = std::env::var("HOME").ok();
21658 unsafe { std::env::set_var("HOME", &temp_home) };
21660 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21662
21663 let mut rx = capture_log_events();
21664
21665 let config = Box::pin(Config::load_or_init()).await.unwrap();
21666
21667 let logs = drain_captured(&mut rx);
21668
21669 assert_eq!(config.data_dir, workspace_dir.join("data"));
21675 assert_eq!(config.config_path, config_path);
21676 assert_eq!(
21677 config
21678 .providers
21679 .models
21680 .find("openrouter", "default")
21681 .and_then(|e| e.model.as_deref()),
21682 Some("persisted-profile")
21683 );
21684 assert!(logs.contains("Config loaded"), "{logs}");
21685 assert!(logs.contains("\"initialized\":true"), "{logs}");
21686 assert!(!logs.contains("\"initialized\":false"), "{logs}");
21687
21688 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21690 if let Some(home) = original_home {
21691 unsafe { std::env::set_var("HOME", home) };
21693 } else {
21694 unsafe { std::env::remove_var("HOME") };
21696 }
21697 let _ = fs::remove_dir_all(temp_home).await;
21698 }
21699
21700 #[test]
21701 #[allow(clippy::large_futures)]
21702 async fn load_or_init_assigns_degraded_security_for_malformed_section() {
21703 let _env_guard = env_override_lock().await;
21704 let temp_home =
21705 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21706 let workspace_dir = temp_home.join("profile-a");
21707 let config_path = workspace_dir.join("config.toml");
21708
21709 fs::create_dir_all(&workspace_dir).await.unwrap();
21710 fs::write(
21713 &config_path,
21714 r#"schema_version = 3
21715audit = "should-be-a-table-not-a-string"
21716
21717[security]
21718audit = "should-be-a-table-not-a-string"
21719"#,
21720 )
21721 .await
21722 .unwrap();
21723
21724 let original_home = std::env::var("HOME").ok();
21725 unsafe { std::env::set_var("HOME", &temp_home) };
21727 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21729
21730 let config = Box::pin(Config::load_or_init()).await.unwrap();
21731
21732 assert!(
21733 config.degraded_security.iter().any(|s| s == "security"),
21734 "load_or_init must surface a dropped [security] section on degraded_security, got {:?}",
21735 config.degraded_security
21736 );
21737
21738 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21740 if let Some(home) = original_home {
21741 unsafe { std::env::set_var("HOME", home) };
21743 } else {
21744 unsafe { std::env::remove_var("HOME") };
21746 }
21747 let _ = fs::remove_dir_all(temp_home).await;
21748 }
21749
21750 #[test]
21751 #[allow(clippy::large_futures)]
21752 async fn load_or_init_marks_whole_config_degraded_for_unparseable_file() {
21753 let _env_guard = env_override_lock().await;
21754 let temp_home =
21755 std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21756 let workspace_dir = temp_home.join("profile-a");
21757 let config_path = workspace_dir.join("config.toml");
21758
21759 fs::create_dir_all(&workspace_dir).await.unwrap();
21760 fs::write(&config_path, "this is not valid TOML {{{")
21764 .await
21765 .unwrap();
21766
21767 let original_home = std::env::var("HOME").ok();
21768 unsafe { std::env::set_var("HOME", &temp_home) };
21770 unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21772
21773 let config = Box::pin(Config::load_or_init()).await.unwrap();
21774
21775 assert!(
21776 !config.degraded_security.is_empty(),
21777 "load_or_init must surface a whole-config loss on degraded_security, got {:?}",
21778 config.degraded_security
21779 );
21780
21781 unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21783 if let Some(home) = original_home {
21784 unsafe { std::env::set_var("HOME", home) };
21786 } else {
21787 unsafe { std::env::remove_var("HOME") };
21789 }
21790 let _ = fs::remove_dir_all(temp_home).await;
21791 }
21792
21793 #[test]
21794 async fn validate_rejects_out_of_range_temperature() {
21795 let mut config = Config::default();
21796 config.providers.models.openrouter.insert(
21797 "default".to_string(),
21798 OpenRouterModelProviderConfig {
21799 base: ModelProviderConfig {
21800 api_key: Some("sk-test".into()),
21801 temperature: Some(99.0),
21802 ..Default::default()
21803 },
21804 },
21805 );
21806 let err = config.validate().unwrap_err();
21807 assert!(
21808 err.to_string().contains("temperature"),
21809 "expected temperature validation error, got: {err}"
21810 );
21811 }
21812
21813 #[test]
21814 async fn validate_rejects_negative_temperature() {
21815 let mut config = Config::default();
21816 config.providers.models.openrouter.insert(
21817 "default".to_string(),
21818 OpenRouterModelProviderConfig {
21819 base: ModelProviderConfig {
21820 api_key: Some("sk-test".into()),
21821 temperature: Some(-0.5),
21822 ..Default::default()
21823 },
21824 },
21825 );
21826 let err = config.validate().unwrap_err();
21827 assert!(
21828 err.to_string().contains("temperature"),
21829 "expected temperature validation error, got: {err}"
21830 );
21831 }
21832
21833 #[test]
21834 async fn validate_accepts_valid_temperature() {
21835 let mut config = Config::default();
21836 config.providers.models.openrouter.insert(
21837 "default".to_string(),
21838 OpenRouterModelProviderConfig {
21839 base: ModelProviderConfig {
21840 temperature: Some(0.7),
21841 ..Default::default()
21842 },
21843 },
21844 );
21845 assert!(config.validate().is_ok());
21846 }
21847
21848 #[test]
21849 async fn validate_rejects_unknown_jira_actions() {
21850 for action in ["delete_ticket", "drop_database", ""] {
21851 let mut config = Config::default();
21852 config.jira.enabled = true;
21853 config.jira.base_url = "https://jira.example.test".into();
21854 config.jira.api_token = "token".into();
21855 config.jira.allowed_actions = vec![action.into()];
21856
21857 let err = config
21858 .validate()
21859 .expect_err("unknown Jira action should be rejected")
21860 .to_string();
21861 assert!(
21862 err.contains("jira.allowed_actions contains unknown action"),
21863 "expected Jira allowed action error for {action:?}, got: {err}"
21864 );
21865 }
21866 }
21867
21868 #[test]
21869 async fn validate_accepts_all_published_jira_actions() {
21870 for action in [
21871 "get_ticket",
21872 "search_tickets",
21873 "comment_ticket",
21874 "list_projects",
21875 "myself",
21876 "list_transitions",
21877 "transition_ticket",
21878 "create_ticket",
21879 ] {
21880 let mut config = Config::default();
21881 config.jira.enabled = true;
21882 config.jira.base_url = "https://jira.example.test".into();
21883 config.jira.api_token = "token".into();
21884 config.jira.allowed_actions = vec![action.into()];
21885
21886 assert!(
21887 config.validate().is_ok(),
21888 "published Jira action {action:?} should validate"
21889 );
21890 }
21891 }
21892
21893 #[test]
21894 async fn jira_email_empty_string_deserializes_as_none() {
21895 let toml_input = r#"
21903enabled = true
21904base_url = "https://jira.example.test"
21905email = ""
21906api_token = "tok"
21907"#;
21908 let cfg: JiraConfig = toml::from_str(toml_input).expect("parses with empty email");
21909 assert!(
21910 cfg.email.is_none(),
21911 "empty `email = \"\"` must deserialize as None, got {:?}",
21912 cfg.email
21913 );
21914 let toml_input_ws = r#"
21916enabled = true
21917base_url = "https://jira.example.test"
21918email = " "
21919api_token = "tok"
21920"#;
21921 let cfg_ws: JiraConfig =
21922 toml::from_str(toml_input_ws).expect("parses with whitespace email");
21923 assert!(
21924 cfg_ws.email.is_none(),
21925 "whitespace-only email must deserialize as None, got {:?}",
21926 cfg_ws.email
21927 );
21928 let toml_input_real = r#"
21930enabled = true
21931base_url = "https://jira.example.test"
21932email = "ops@example.com"
21933api_token = "tok"
21934"#;
21935 let cfg_real: JiraConfig = toml::from_str(toml_input_real).expect("parses with real email");
21936 assert_eq!(
21937 cfg_real.email.as_deref(),
21938 Some("ops@example.com"),
21939 "non-empty email must round-trip unchanged"
21940 );
21941 }
21942
21943 #[test]
21944 async fn proxy_config_scope_services_requires_entries_when_enabled() {
21945 let proxy = ProxyConfig {
21946 enabled: true,
21947 http_proxy: Some("http://127.0.0.1:7890".into()),
21948 https_proxy: None,
21949 all_proxy: None,
21950 no_proxy: Vec::new(),
21951 scope: ProxyScope::Services,
21952 services: Vec::new(),
21953 };
21954
21955 let error = proxy.validate().unwrap_err().to_string();
21956 assert!(error.contains("proxy.scope='services'"));
21957 }
21958
21959 #[test]
21960 async fn google_workspace_allowed_operations_require_methods() {
21961 let mut config = Config::default();
21962 config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
21963 service: "gmail".into(),
21964 resource: "users".into(),
21965 sub_resource: Some("drafts".into()),
21966 methods: Vec::new(),
21967 }];
21968
21969 let err = config.validate().unwrap_err().to_string();
21970 assert!(err.contains("google_workspace.allowed_operations[0].methods"));
21971 }
21972
21973 #[test]
21974 async fn google_workspace_allowed_operations_reject_duplicate_service_resource_sub_resource_entries()
21975 {
21976 let mut config = Config::default();
21977 config.google_workspace.allowed_operations = vec![
21978 GoogleWorkspaceAllowedOperation {
21979 service: "gmail".into(),
21980 resource: "users".into(),
21981 sub_resource: Some("drafts".into()),
21982 methods: vec!["create".into()],
21983 },
21984 GoogleWorkspaceAllowedOperation {
21985 service: "gmail".into(),
21986 resource: "users".into(),
21987 sub_resource: Some("drafts".into()),
21988 methods: vec!["update".into()],
21989 },
21990 ];
21991
21992 let err = config.validate().unwrap_err().to_string();
21993 assert!(err.contains("duplicate service/resource/sub_resource entry"));
21994 }
21995
21996 #[test]
21997 async fn google_workspace_allowed_operations_allow_same_resource_different_sub_resource() {
21998 let mut config = Config::default();
21999 config.google_workspace.allowed_operations = vec![
22000 GoogleWorkspaceAllowedOperation {
22001 service: "gmail".into(),
22002 resource: "users".into(),
22003 sub_resource: Some("messages".into()),
22004 methods: vec!["list".into(), "get".into()],
22005 },
22006 GoogleWorkspaceAllowedOperation {
22007 service: "gmail".into(),
22008 resource: "users".into(),
22009 sub_resource: Some("drafts".into()),
22010 methods: vec!["create".into(), "update".into()],
22011 },
22012 ];
22013
22014 assert!(config.validate().is_ok());
22015 }
22016
22017 #[test]
22018 async fn google_workspace_allowed_operations_reject_duplicate_methods_within_entry() {
22019 let mut config = Config::default();
22020 config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
22021 service: "gmail".into(),
22022 resource: "users".into(),
22023 sub_resource: Some("drafts".into()),
22024 methods: vec!["create".into(), "create".into()],
22025 }];
22026
22027 let err = config.validate().unwrap_err().to_string();
22028 assert!(
22029 err.contains("duplicate entry"),
22030 "expected duplicate entry error, got: {err}"
22031 );
22032 }
22033
22034 #[test]
22035 async fn google_workspace_allowed_operations_accept_valid_entries() {
22036 let mut config = Config::default();
22037 config.google_workspace.allowed_operations = vec![
22038 GoogleWorkspaceAllowedOperation {
22039 service: "gmail".into(),
22040 resource: "users".into(),
22041 sub_resource: Some("messages".into()),
22042 methods: vec!["list".into(), "get".into()],
22043 },
22044 GoogleWorkspaceAllowedOperation {
22045 service: "drive".into(),
22046 resource: "files".into(),
22047 sub_resource: None,
22048 methods: vec!["list".into(), "get".into()],
22049 },
22050 ];
22051
22052 assert!(config.validate().is_ok());
22053 }
22054
22055 #[test]
22056 async fn google_workspace_allowed_operations_reject_invalid_sub_resource_characters() {
22057 let mut config = Config::default();
22058 config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
22059 service: "gmail".into(),
22060 resource: "users".into(),
22061 sub_resource: Some("bad resource!".into()),
22062 methods: vec!["list".into()],
22063 }];
22064
22065 let err = config.validate().unwrap_err().to_string();
22066 assert!(err.contains("sub_resource contains invalid characters"));
22067 }
22068
22069 fn runtime_proxy_cache_contains(cache_key: &str) -> bool {
22070 match runtime_proxy_client_cache().read() {
22071 Ok(guard) => guard.contains_key(cache_key),
22072 Err(poisoned) => poisoned.into_inner().contains_key(cache_key),
22073 }
22074 }
22075
22076 #[test]
22077 async fn runtime_proxy_client_cache_reuses_default_profile_key() {
22078 let service_key = format!(
22079 "model_provider.cache_test.{}",
22080 std::time::SystemTime::now()
22081 .duration_since(std::time::UNIX_EPOCH)
22082 .expect("system clock should be after unix epoch")
22083 .as_nanos()
22084 );
22085 let cache_key = runtime_proxy_cache_key(&service_key, None, None);
22086
22087 clear_runtime_proxy_client_cache();
22088 assert!(!runtime_proxy_cache_contains(&cache_key));
22089
22090 let _ = build_runtime_proxy_client(&service_key);
22091 assert!(runtime_proxy_cache_contains(&cache_key));
22092
22093 let _ = build_runtime_proxy_client(&service_key);
22094 assert!(runtime_proxy_cache_contains(&cache_key));
22095 }
22096
22097 #[test]
22098 async fn set_runtime_proxy_config_clears_runtime_proxy_client_cache() {
22099 let service_key = format!(
22100 "model_provider.cache_timeout_test.{}",
22101 std::time::SystemTime::now()
22102 .duration_since(std::time::UNIX_EPOCH)
22103 .expect("system clock should be after unix epoch")
22104 .as_nanos()
22105 );
22106 let cache_key = runtime_proxy_cache_key(&service_key, Some(30), Some(5));
22107
22108 clear_runtime_proxy_client_cache();
22109 let _ = build_runtime_proxy_client_with_timeouts(&service_key, 30, 5);
22110 assert!(runtime_proxy_cache_contains(&cache_key));
22111
22112 set_runtime_proxy_config(ProxyConfig::default());
22113 assert!(!runtime_proxy_cache_contains(&cache_key));
22114 }
22115
22116 #[test]
22117 async fn gateway_config_default_values() {
22118 let g = GatewayConfig::default();
22119 assert_eq!(g.port, 42617);
22120 assert_eq!(g.host, "127.0.0.1");
22121 assert!(g.require_pairing);
22122 assert!(!g.allow_public_bind);
22123 assert!(g.paired_tokens.is_empty());
22124 assert!(!g.trust_forwarded_headers);
22125 assert_eq!(g.rate_limit_max_keys, 10_000);
22126 assert_eq!(g.idempotency_max_keys, 10_000);
22127 }
22128
22129 #[test]
22132 async fn peripherals_config_default_disabled() {
22133 let p = PeripheralsConfig::default();
22134 assert!(!p.enabled);
22135 assert!(p.boards.is_empty());
22136 }
22137
22138 #[test]
22139 async fn peripheral_board_config_defaults() {
22140 let b = PeripheralBoardConfig::default();
22141 assert!(b.board.is_empty());
22142 assert_eq!(b.transport, "serial");
22143 assert!(b.path.is_none());
22144 assert_eq!(b.baud, 115_200);
22145 }
22146
22147 #[test]
22148 async fn peripherals_config_toml_roundtrip() {
22149 let p = PeripheralsConfig {
22150 enabled: true,
22151 boards: vec![PeripheralBoardConfig {
22152 board: "nucleo-f401re".into(),
22153 transport: "serial".into(),
22154 path: Some("/dev/ttyACM0".into()),
22155 baud: 115_200,
22156 }],
22157 datasheet_dir: None,
22158 };
22159 let toml_str = toml::to_string(&p).unwrap();
22160 let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
22161 assert!(parsed.enabled);
22162 assert_eq!(parsed.boards.len(), 1);
22163 assert_eq!(parsed.boards[0].board, "nucleo-f401re");
22164 assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
22165 }
22166
22167 #[test]
22168 async fn lark_config_serde() {
22169 let lc = LarkConfig {
22170 enabled: true,
22171 app_id: "cli_123456".into(),
22172 app_secret: "secret_abc".into(),
22173 encrypt_key: Some("encrypt_key".into()),
22174 verification_token: Some("verify_token".into()),
22175 mention_only: false,
22176 use_feishu: true,
22177 receive_mode: LarkReceiveMode::Websocket,
22178 port: None,
22179 proxy_url: None,
22180 excluded_tools: vec![],
22181 approval_timeout_secs: 300,
22182 per_user_session: false,
22183 stream_mode: StreamMode::default(),
22184 draft_update_interval_ms: default_draft_update_interval_ms(),
22185 };
22186 let json = serde_json::to_string(&lc).unwrap();
22187 let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
22188 assert_eq!(parsed.app_id, "cli_123456");
22189 assert_eq!(parsed.app_secret, "secret_abc");
22190 assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
22191 assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
22192 assert!(parsed.use_feishu);
22193 }
22194
22195 #[test]
22196 async fn lark_config_toml_roundtrip() {
22197 let lc = LarkConfig {
22198 enabled: true,
22199 app_id: "cli_123456".into(),
22200 app_secret: "secret_abc".into(),
22201 encrypt_key: Some("encrypt_key".into()),
22202 verification_token: Some("verify_token".into()),
22203 mention_only: false,
22204 use_feishu: false,
22205 receive_mode: LarkReceiveMode::Webhook,
22206 port: Some(9898),
22207 proxy_url: None,
22208 excluded_tools: vec![],
22209 approval_timeout_secs: 300,
22210 per_user_session: false,
22211 stream_mode: StreamMode::default(),
22212 draft_update_interval_ms: default_draft_update_interval_ms(),
22213 };
22214 let toml_str = toml::to_string(&lc).unwrap();
22215 let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
22216 assert_eq!(parsed.app_id, "cli_123456");
22217 assert_eq!(parsed.app_secret, "secret_abc");
22218 assert!(!parsed.use_feishu);
22219 }
22220
22221 #[test]
22222 async fn lark_config_deserializes_without_optional_fields() {
22223 let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
22224 let parsed: LarkConfig = serde_json::from_str(json).unwrap();
22225 assert!(parsed.encrypt_key.is_none());
22226 assert!(parsed.verification_token.is_none());
22227 assert!(!parsed.mention_only);
22228 assert!(!parsed.use_feishu);
22229 }
22230
22231 #[test]
22232 async fn lark_config_defaults_to_lark_endpoint() {
22233 let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
22234 let parsed: LarkConfig = serde_json::from_str(json).unwrap();
22235 assert!(
22236 !parsed.use_feishu,
22237 "use_feishu should default to false (Lark)"
22238 );
22239 }
22240
22241 #[test]
22242 async fn lark_v2_allowed_users_fold_into_peer_groups() {
22243 let raw = r#"
22248schema_version = 2
22249
22250[channels.lark]
22251enabled = true
22252app_id = "cli_123"
22253app_secret = "secret"
22254allowed_users = ["user_alpha", "user_beta"]
22255"#;
22256 let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
22257 let group = parsed
22258 .peer_groups
22259 .get("lark_default")
22260 .expect("V2 lark.allowed_users must fold into peer_groups.lark_default");
22261 assert_eq!(group.channel, "lark");
22262 let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
22263 assert_eq!(usernames, vec!["user_alpha", "user_beta"]);
22264 }
22265
22266 #[test]
22269 async fn line_config_toml_roundtrip() {
22270 let toml = r#"
22276[channels_config.line.default]
22277enabled = true
22278channel_access_token = "ChannelAccessToken=="
22279channel_secret = "abc123secret"
22280dm_policy = "pairing"
22281group_policy = "mention"
22282allowed_users = []
22283webhook_port = 8443
22284"#;
22285 let config: Config = toml::from_str(toml).unwrap();
22286 let ln = config.channels.line.get("default").unwrap();
22287 assert_eq!(ln.channel_access_token, "ChannelAccessToken==");
22288 assert_eq!(ln.channel_secret, "abc123secret");
22289 assert_eq!(ln.dm_policy, LineDmPolicy::Pairing);
22290 assert_eq!(ln.group_policy, LineGroupPolicy::Mention);
22291 assert_eq!(ln.webhook_port, 8443);
22292 assert!(ln.proxy_url.is_none());
22293 }
22294
22295 #[test]
22296 async fn line_config_defaults() {
22297 let toml = r#"
22300[channels_config.line.default]
22301channel_access_token = "tok"
22302channel_secret = "sec"
22303"#;
22304 let config: Config = toml::from_str(toml).unwrap();
22305 let ln = config.channels.line.get("default").unwrap();
22306 assert_eq!(
22307 ln.dm_policy,
22308 LineDmPolicy::Pairing,
22309 "dm_policy default is pairing"
22310 );
22311 assert_eq!(
22312 ln.group_policy,
22313 LineGroupPolicy::Mention,
22314 "group_policy default is mention"
22315 );
22316 assert_eq!(ln.webhook_port, 8443, "webhook_port default is 8443");
22317 assert!(ln.proxy_url.is_none());
22318 }
22319
22320 #[test]
22321 async fn line_config_allowlist_policy() {
22322 let toml = r#"
22326schema_version = 2
22327
22328[channels.line]
22329enabled = true
22330channel_access_token = "tok"
22331channel_secret = "sec"
22332dm_policy = "allowlist"
22333allowed_users = ["Uabc123", "Udef456"]
22334"#;
22335 let config = crate::migration::migrate_to_current(toml).expect("migration succeeds");
22336 let ln = config.channels.line.get("default").unwrap();
22337 assert_eq!(ln.dm_policy, LineDmPolicy::Allowlist);
22338 let group = config
22339 .peer_groups
22340 .get("line_default")
22341 .expect("V2 line.allowed_users must fold into peer_groups.line_default");
22342 let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
22343 assert_eq!(usernames, vec!["Uabc123", "Udef456"]);
22344 }
22345
22346 #[test]
22347 async fn line_config_open_policies() {
22348 let toml = r#"
22350[channels_config.line.default]
22351channel_access_token = "tok"
22352channel_secret = "sec"
22353dm_policy = "open"
22354group_policy = "open"
22355"#;
22356 let config: Config = toml::from_str(toml).unwrap();
22357 let ln = config.channels.line.get("default").unwrap();
22358 assert_eq!(ln.dm_policy, LineDmPolicy::Open);
22359 assert_eq!(ln.group_policy, LineGroupPolicy::Open);
22360 }
22361
22362 #[test]
22363 async fn line_config_group_disabled() {
22364 let toml = r#"
22366[channels_config.line.default]
22367channel_access_token = "tok"
22368channel_secret = "sec"
22369group_policy = "disabled"
22370"#;
22371 let config: Config = toml::from_str(toml).unwrap();
22372 let ln = config.channels.line.get("default").unwrap();
22373 assert_eq!(ln.group_policy, LineGroupPolicy::Disabled);
22374 }
22375
22376 #[test]
22377 async fn nextcloud_talk_config_serde() {
22378 let nc = NextcloudTalkConfig {
22379 enabled: true,
22380 base_url: "https://cloud.example.com".into(),
22381 app_token: "app-token".into(),
22382 webhook_secret: Some("webhook-secret".into()),
22383 proxy_url: None,
22384 bot_name: None,
22385 excluded_tools: vec![],
22386 stream_mode: StreamMode::default(),
22387 draft_update_interval_ms: 1000,
22388 };
22389
22390 let json = serde_json::to_string(&nc).unwrap();
22391 let parsed: NextcloudTalkConfig = serde_json::from_str(&json).unwrap();
22392 assert_eq!(parsed.base_url, "https://cloud.example.com");
22393 assert_eq!(parsed.app_token, "app-token");
22394 assert_eq!(parsed.webhook_secret.as_deref(), Some("webhook-secret"));
22395 }
22396
22397 #[test]
22398 async fn nextcloud_talk_config_defaults_optional_fields() {
22399 let json = r#"{"base_url":"https://cloud.example.com","app_token":"app-token"}"#;
22400 let parsed: NextcloudTalkConfig = serde_json::from_str(json).unwrap();
22401 assert!(parsed.webhook_secret.is_none());
22402 }
22403
22404 #[cfg(unix)]
22407 #[test]
22408 async fn new_config_file_has_restricted_permissions() {
22409 let tmp = tempfile::TempDir::new().unwrap();
22410 let config_path = tmp.path().join("config.toml");
22411
22412 let config = Config {
22414 config_path: config_path.clone(),
22415 ..Default::default()
22416 };
22417 config.save().await.unwrap();
22418
22419 let meta = fs::metadata(&config_path).await.unwrap();
22420 let mode = meta.permissions().mode() & 0o777;
22421 assert_eq!(
22422 mode, 0o600,
22423 "New config file should be owner-only (0600), got {mode:o}"
22424 );
22425 }
22426
22427 #[cfg(unix)]
22428 #[test]
22429 async fn save_restricts_existing_world_readable_config_to_owner_only() {
22430 let tmp = tempfile::TempDir::new().unwrap();
22431 let config_path = tmp.path().join("config.toml");
22432
22433 let mut config = Config {
22434 config_path: config_path.clone(),
22435 ..Default::default()
22436 };
22437 config.save().await.unwrap();
22438
22439 std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
22441 let loose_mode = std::fs::metadata(&config_path)
22442 .unwrap()
22443 .permissions()
22444 .mode()
22445 & 0o777;
22446 assert_eq!(
22447 loose_mode, 0o644,
22448 "test setup requires world-readable config"
22449 );
22450
22451 if let Some(entry) = config.providers.models.ensure("openrouter", "default") {
22452 entry.temperature = Some(0.6);
22453 }
22454 config.save().await.unwrap();
22455
22456 let hardened_mode = std::fs::metadata(&config_path)
22457 .unwrap()
22458 .permissions()
22459 .mode()
22460 & 0o777;
22461 assert_eq!(
22462 hardened_mode, 0o600,
22463 "Saving config should restore owner-only permissions (0600)"
22464 );
22465 }
22466
22467 #[test]
22468 async fn save_dirty_stamps_current_schema_version_on_stale_label() {
22469 let tmp = tempfile::TempDir::new().unwrap();
22475 let config_path = tmp.path().join("config.toml");
22476
22477 std::fs::write(
22480 &config_path,
22481 "schema_version = 2\n\n[observability]\nbackend = \"none\"\n",
22482 )
22483 .unwrap();
22484
22485 let mut config = Config {
22486 config_path: config_path.clone(),
22487 ..Default::default()
22488 };
22489 config.observability.backend = "otel".to_string();
22490 config.mark_dirty("observability.backend");
22491 config.save_dirty().await.unwrap();
22492
22493 let written = std::fs::read_to_string(&config_path).unwrap();
22494 assert!(
22495 written.contains(&format!(
22496 "schema_version = {}",
22497 crate::migration::CURRENT_SCHEMA_VERSION
22498 )),
22499 "save_dirty must stamp the current schema_version; got:\n{written}"
22500 );
22501 assert!(
22502 !written.contains("schema_version = 2"),
22503 "stale schema_version label must be overwritten; got:\n{written}"
22504 );
22505 assert!(
22507 written.contains("backend = \"otel\""),
22508 "dirty value must still be written; got:\n{written}"
22509 );
22510 assert!(
22511 written.trim_start().starts_with("schema_version ="),
22512 "schema_version should remain the first key; got:\n{written}"
22513 );
22514 }
22515
22516 #[test]
22517 async fn collect_warnings_flags_wire_api_on_fixed_protocol_family() {
22518 let mut config = Config::default();
22519 config
22521 .providers
22522 .models
22523 .ensure("mistral", "primary")
22524 .unwrap()
22525 .wire_api = Some(WireApi::Responses);
22526 config
22528 .providers
22529 .models
22530 .ensure("custom", "vllm")
22531 .unwrap()
22532 .wire_api = Some(WireApi::Responses);
22533
22534 let warnings = config.collect_warnings();
22535 assert_eq!(warnings.len(), 1, "exactly the mistral entry should warn");
22536 let w = &warnings[0];
22537 assert_eq!(w.code, "wire_api_not_supported_for_family");
22538 assert_eq!(w.path, "providers.models.mistral.primary.wire_api");
22539 assert!(
22540 !warnings.iter().any(|w| w.path.contains("custom.vllm")),
22541 "custom honors wire_api and must not warn",
22542 );
22543 }
22544
22545 #[cfg(unix)]
22546 #[test]
22547 async fn world_readable_config_is_detectable() {
22548 use std::os::unix::fs::PermissionsExt;
22549
22550 let tmp = tempfile::TempDir::new().unwrap();
22551 let config_path = tmp.path().join("config.toml");
22552
22553 std::fs::write(&config_path, "# test config").unwrap();
22555 std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
22556
22557 let meta = std::fs::metadata(&config_path).unwrap();
22558 let mode = meta.permissions().mode();
22559 assert!(
22560 mode & 0o004 != 0,
22561 "Test setup: file should be world-readable (mode {mode:o})"
22562 );
22563 }
22564
22565 #[test]
22566 async fn transcription_config_defaults() {
22567 let tc = TranscriptionConfig::default();
22568 assert!(!tc.enabled);
22569 assert!(tc.api_url.contains("groq.com"));
22570 assert_eq!(tc.model, "whisper-large-v3-turbo");
22571 assert!(tc.language.is_none());
22572 assert!(tc.max_audio_bytes.is_none());
22573 assert_eq!(tc.max_duration_secs, 120);
22574 assert!(!tc.transcribe_non_ptt_audio);
22575 }
22576
22577 #[test]
22578 async fn config_roundtrip_with_transcription() {
22579 let mut config = Config::default();
22580 config.transcription.enabled = true;
22581 config.transcription.language = Some("en".into());
22582
22583 let toml_str = toml::to_string_pretty(&config).unwrap();
22584 let parsed = parse_test_config(&toml_str);
22585
22586 assert!(parsed.transcription.enabled);
22587 assert_eq!(parsed.transcription.language.as_deref(), Some("en"));
22588 assert_eq!(parsed.transcription.model, "whisper-large-v3-turbo");
22589 }
22590
22591 #[test]
22592 async fn config_roundtrip_with_transcription_max_audio_bytes() {
22593 let mut config = Config::default();
22594 config.transcription.max_audio_bytes = Some(65_536);
22595
22596 let toml_str = toml::to_string_pretty(&config).unwrap();
22597 let parsed = parse_test_config(&toml_str);
22598
22599 assert_eq!(parsed.transcription.max_audio_bytes, Some(65_536));
22600 }
22601
22602 #[test]
22603 async fn transcription_max_audio_bytes_round_trips_through_prop_path() {
22604 let mut config = Config::default();
22605
22606 assert_eq!(
22607 config
22608 .get_prop("transcription.max_audio_bytes")
22609 .unwrap()
22610 .as_str(),
22611 "<unset>"
22612 );
22613
22614 config
22615 .set_prop("transcription.max_audio_bytes", "65536")
22616 .unwrap();
22617 assert_eq!(config.transcription.max_audio_bytes, Some(65_536));
22618 assert_eq!(
22619 config.get_prop("transcription.max_audio_bytes").unwrap(),
22620 "65536"
22621 );
22622
22623 config
22624 .set_prop("transcription.max_audio_bytes", "")
22625 .unwrap();
22626 assert!(config.transcription.max_audio_bytes.is_none());
22627 assert_eq!(
22628 config.get_prop("transcription.max_audio_bytes").unwrap(),
22629 "<unset>"
22630 );
22631 }
22632
22633 #[test]
22634 async fn config_validate_rejects_zero_transcription_max_audio_bytes() {
22635 let mut config = Config::default();
22636 config.transcription.max_audio_bytes = Some(0);
22637
22638 let err = config.validate().unwrap_err();
22639 assert!(
22640 err.to_string()
22641 .contains("transcription.max_audio_bytes must be greater than zero"),
22642 "got: {err}"
22643 );
22644 }
22645
22646 #[test]
22647 async fn config_without_transcription_uses_defaults() {
22648 let toml_str = r#"
22649 default_model_provider = "openrouter"
22650 default_model = "test-model"
22651 default_temperature = 0.7
22652 "#;
22653 let parsed = parse_test_config(toml_str);
22654 assert!(!parsed.transcription.enabled);
22655 assert_eq!(parsed.transcription.max_duration_secs, 120);
22656 }
22657
22658 #[test]
22659 async fn security_defaults_are_backward_compatible() {
22660 let parsed = parse_test_config(
22661 r#"
22662default_model_provider = "openrouter"
22663default_model = "anthropic/claude-sonnet-4.6"
22664default_temperature = 0.7
22665"#,
22666 );
22667
22668 assert!(!parsed.security.otp.enabled);
22669 assert_eq!(parsed.security.otp.method, OtpMethod::Totp);
22670 assert!(!parsed.security.estop.enabled);
22671 assert!(parsed.security.estop.require_otp_to_resume);
22672 }
22673
22674 #[test]
22675 async fn security_toml_parses_otp_and_estop_sections() {
22676 let parsed = parse_test_config(
22677 r#"
22678default_model_provider = "openrouter"
22679default_model = "anthropic/claude-sonnet-4.6"
22680default_temperature = 0.7
22681
22682[security.otp]
22683enabled = true
22684method = "totp"
22685token_ttl_secs = 30
22686cache_valid_secs = 120
22687gated_actions = ["shell", "browser_open"]
22688gated_domains = ["*.chase.com", "accounts.google.com"]
22689gated_domain_categories = ["banking"]
22690
22691[security.estop]
22692enabled = true
22693state_file = "~/.zeroclaw/estop-state.json"
22694require_otp_to_resume = true
22695"#,
22696 );
22697
22698 assert!(parsed.security.otp.enabled);
22699 assert!(parsed.security.estop.enabled);
22700 assert_eq!(parsed.security.otp.gated_actions.len(), 2);
22701 assert_eq!(parsed.security.otp.gated_domains.len(), 2);
22702 parsed.validate().unwrap();
22703 }
22704
22705 #[test]
22706 async fn security_validation_rejects_invalid_domain_glob() {
22707 let mut config = Config::default();
22708 config.security.otp.gated_domains = vec!["bad domain.com".into()];
22709
22710 let err = config.validate().expect_err("expected invalid domain glob");
22711 assert!(err.to_string().contains("gated_domains"));
22712 }
22713
22714 #[test]
22715 async fn security_validation_accepts_all_default_gated_actions_without_warning() {
22716 let mut config = Config::default();
22717 config.security.otp.gated_actions = default_otp_gated_actions();
22718
22719 config
22720 .validate()
22721 .expect("the canonical default gated actions must validate clean");
22722 }
22723
22724 #[test]
22725 async fn security_validation_accepts_unknown_gated_action_but_does_not_bail() {
22726 let mut config = Config::default();
22732 config.security.otp.gated_actions = vec!["kubectl_write".into()];
22733
22734 config
22735 .validate()
22736 .expect("an unknown gated action must warn, not reject the config");
22737 }
22738
22739 #[test]
22740 async fn security_validation_still_rejects_malformed_gated_action() {
22741 let mut config = Config::default();
22745 config.security.otp.gated_actions = vec!["kubectl write".into()];
22746
22747 let err = config
22748 .validate()
22749 .expect_err("malformed gated action must still be rejected");
22750 assert!(err.to_string().contains("gated_actions"));
22751 }
22752
22753 #[tokio::test]
22761 async fn channel_secret_telegram_bot_token_roundtrip() {
22762 let dir = std::env::temp_dir().join(format!(
22763 "zeroclaw_test_tg_bot_token_{}",
22764 uuid::Uuid::new_v4()
22765 ));
22766 fs::create_dir_all(&dir).await.unwrap();
22767
22768 let plaintext_token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";
22769
22770 let mut config = Config {
22771 data_dir: dir.join("workspace"),
22772 config_path: dir.join("config.toml"),
22773 ..Default::default()
22774 };
22775 config.channels.telegram.insert(
22776 "default".to_string(),
22777 TelegramConfig {
22778 enabled: true,
22779 bot_token: plaintext_token.into(),
22780 stream_mode: StreamMode::default(),
22781 draft_update_interval_ms: default_draft_update_interval_ms(),
22782 interrupt_on_new_message: false,
22783 mention_only: false,
22784 ack_reactions: None,
22785 proxy_url: None,
22786 approval_timeout_secs: default_telegram_approval_timeout_secs(),
22787 excluded_tools: vec![],
22788 reply_min_interval_secs: 0,
22789 reply_queue_depth_max: 0,
22790 },
22791 );
22792
22793 config.save().await.unwrap();
22795
22796 let raw_toml = tokio::fs::read_to_string(&config.config_path)
22798 .await
22799 .unwrap();
22800 assert!(
22801 !raw_toml.contains(plaintext_token),
22802 "Saved TOML must not contain the plaintext bot_token"
22803 );
22804
22805 let stored: Config = toml::from_str(&raw_toml).unwrap();
22807 let stored_token = &stored.channels.telegram.get("default").unwrap().bot_token;
22808 assert!(
22809 crate::secrets::SecretStore::is_encrypted(stored_token),
22810 "Stored bot_token must be marked as encrypted"
22811 );
22812
22813 let store = crate::secrets::SecretStore::new(&dir, true);
22815 assert_eq!(store.decrypt(stored_token).unwrap(), plaintext_token);
22816
22817 let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
22819 loaded.config_path = dir.join("config.toml");
22820 let load_store = crate::secrets::SecretStore::new(&dir, loaded.secrets.encrypt);
22821 loaded.decrypt_secrets(&load_store).unwrap();
22822 assert_eq!(
22823 loaded.channels.telegram.get("default").unwrap().bot_token,
22824 plaintext_token,
22825 "Loaded bot_token must match the original plaintext after decryption"
22826 );
22827
22828 let _ = fs::remove_dir_all(&dir).await;
22829 }
22830
22831 #[test]
22832 async fn security_validation_rejects_unknown_domain_category() {
22833 let mut config = Config::default();
22834 config.security.otp.gated_domain_categories = vec!["not_real".into()];
22835
22836 let err = config
22837 .validate()
22838 .expect_err("expected unknown domain category");
22839 assert!(err.to_string().contains("gated_domain_categories"));
22840 }
22841
22842 #[test]
22843 async fn security_validation_rejects_zero_token_ttl() {
22844 let mut config = Config::default();
22845 config.security.otp.token_ttl_secs = 0;
22846
22847 let err = config
22848 .validate()
22849 .expect_err("expected ttl validation failure");
22850 assert!(err.to_string().contains("token_ttl_secs"));
22851 }
22852
22853 fn stdio_server(name: &str, command: &str) -> McpServerConfig {
22856 McpServerConfig {
22857 name: name.to_string(),
22858 transport: McpTransport::Stdio,
22859 command: command.to_string(),
22860 ..Default::default()
22861 }
22862 }
22863
22864 fn http_server(name: &str, url: &str) -> McpServerConfig {
22865 McpServerConfig {
22866 name: name.to_string(),
22867 transport: McpTransport::Http,
22868 url: Some(url.to_string()),
22869 ..Default::default()
22870 }
22871 }
22872
22873 fn sse_server(name: &str, url: &str) -> McpServerConfig {
22874 McpServerConfig {
22875 name: name.to_string(),
22876 transport: McpTransport::Sse,
22877 url: Some(url.to_string()),
22878 ..Default::default()
22879 }
22880 }
22881
22882 #[test]
22883 async fn validate_mcp_config_empty_servers_ok() {
22884 let cfg = McpConfig::default();
22885 assert!(validate_mcp_config(&cfg).is_ok());
22886 }
22887
22888 #[test]
22889 async fn validate_mcp_config_valid_stdio_ok() {
22890 let cfg = McpConfig {
22891 enabled: true,
22892 servers: vec![stdio_server("fs", "/usr/bin/mcp-fs")],
22893 ..Default::default()
22894 };
22895 assert!(validate_mcp_config(&cfg).is_ok());
22896 }
22897
22898 #[test]
22899 async fn validate_mcp_config_valid_http_ok() {
22900 let cfg = McpConfig {
22901 enabled: true,
22902 servers: vec![http_server("svc", "http://localhost:8080/mcp")],
22903 ..Default::default()
22904 };
22905 assert!(validate_mcp_config(&cfg).is_ok());
22906 }
22907
22908 #[test]
22909 async fn validate_mcp_config_valid_sse_ok() {
22910 let cfg = McpConfig {
22911 enabled: true,
22912 servers: vec![sse_server("svc", "https://example.com/events")],
22913 ..Default::default()
22914 };
22915 assert!(validate_mcp_config(&cfg).is_ok());
22916 }
22917
22918 #[test]
22919 async fn validate_mcp_config_rejects_empty_name() {
22920 let cfg = McpConfig {
22921 enabled: true,
22922 servers: vec![stdio_server("", "/usr/bin/tool")],
22923 ..Default::default()
22924 };
22925 let err = validate_mcp_config(&cfg).expect_err("empty name should fail");
22926 assert!(
22927 err.to_string().contains("name must not be empty"),
22928 "got: {err}"
22929 );
22930 }
22931
22932 #[test]
22933 async fn validate_mcp_config_rejects_whitespace_name() {
22934 let cfg = McpConfig {
22935 enabled: true,
22936 servers: vec![stdio_server(" ", "/usr/bin/tool")],
22937 ..Default::default()
22938 };
22939 let err = validate_mcp_config(&cfg).expect_err("whitespace name should fail");
22940 assert!(
22941 err.to_string().contains("name must not be empty"),
22942 "got: {err}"
22943 );
22944 }
22945
22946 #[test]
22947 async fn validate_mcp_config_rejects_duplicate_names() {
22948 let cfg = McpConfig {
22949 enabled: true,
22950 servers: vec![
22951 stdio_server("fs", "/usr/bin/mcp-a"),
22952 stdio_server("fs", "/usr/bin/mcp-b"),
22953 ],
22954 ..Default::default()
22955 };
22956 let err = validate_mcp_config(&cfg).expect_err("duplicate name should fail");
22957 assert!(err.to_string().contains("duplicate name"), "got: {err}");
22958 }
22959
22960 #[test]
22961 async fn validate_mcp_config_rejects_zero_timeout() {
22962 let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
22963 server.tool_timeout_secs = Some(0);
22964 let cfg = McpConfig {
22965 enabled: true,
22966 servers: vec![server],
22967 ..Default::default()
22968 };
22969 let err = validate_mcp_config(&cfg).expect_err("zero timeout should fail");
22970 assert!(err.to_string().contains("greater than 0"), "got: {err}");
22971 }
22972
22973 #[test]
22974 async fn validate_mcp_config_rejects_timeout_exceeding_max() {
22975 let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
22976 server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS + 1);
22977 let cfg = McpConfig {
22978 enabled: true,
22979 servers: vec![server],
22980 ..Default::default()
22981 };
22982 let err = validate_mcp_config(&cfg).expect_err("oversized timeout should fail");
22983 assert!(err.to_string().contains("exceeds max"), "got: {err}");
22984 }
22985
22986 #[test]
22987 async fn validate_mcp_config_allows_max_timeout_exactly() {
22988 let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
22989 server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS);
22990 let cfg = McpConfig {
22991 enabled: true,
22992 servers: vec![server],
22993 ..Default::default()
22994 };
22995 assert!(validate_mcp_config(&cfg).is_ok());
22996 }
22997
22998 #[test]
22999 async fn validate_mcp_config_rejects_stdio_with_empty_command() {
23000 let cfg = McpConfig {
23001 enabled: true,
23002 servers: vec![stdio_server("fs", "")],
23003 ..Default::default()
23004 };
23005 let err = validate_mcp_config(&cfg).expect_err("empty command should fail");
23006 assert!(
23007 err.to_string().contains("requires non-empty command"),
23008 "got: {err}"
23009 );
23010 }
23011
23012 #[test]
23013 async fn validate_mcp_config_rejects_http_without_url() {
23014 let cfg = McpConfig {
23015 enabled: true,
23016 servers: vec![McpServerConfig {
23017 name: "svc".to_string(),
23018 transport: McpTransport::Http,
23019 url: None,
23020 ..Default::default()
23021 }],
23022 ..Default::default()
23023 };
23024 let err = validate_mcp_config(&cfg).expect_err("http without url should fail");
23025 assert!(err.to_string().contains("requires url"), "got: {err}");
23026 }
23027
23028 #[test]
23029 async fn validate_mcp_config_rejects_sse_without_url() {
23030 let cfg = McpConfig {
23031 enabled: true,
23032 servers: vec![McpServerConfig {
23033 name: "svc".to_string(),
23034 transport: McpTransport::Sse,
23035 url: None,
23036 ..Default::default()
23037 }],
23038 ..Default::default()
23039 };
23040 let err = validate_mcp_config(&cfg).expect_err("sse without url should fail");
23041 assert!(err.to_string().contains("requires url"), "got: {err}");
23042 }
23043
23044 #[test]
23045 async fn validate_mcp_config_rejects_non_http_scheme() {
23046 let cfg = McpConfig {
23047 enabled: true,
23048 servers: vec![http_server("svc", "ftp://example.com/mcp")],
23049 ..Default::default()
23050 };
23051 let err = validate_mcp_config(&cfg).expect_err("non-http scheme should fail");
23052 assert!(err.to_string().contains("http/https"), "got: {err}");
23053 }
23054
23055 #[test]
23056 async fn validate_mcp_config_rejects_invalid_url() {
23057 let cfg = McpConfig {
23058 enabled: true,
23059 servers: vec![http_server("svc", "not a url at all !!!")],
23060 ..Default::default()
23061 };
23062 let err = validate_mcp_config(&cfg).expect_err("invalid url should fail");
23063 assert!(err.to_string().contains("valid URL"), "got: {err}");
23064 }
23065
23066 #[test]
23067 async fn mcp_config_defaults_enabled_eager_loading_with_empty_servers() {
23068 let cfg = McpConfig::default();
23069 assert!(cfg.enabled);
23070 assert!(!cfg.deferred_loading);
23071 assert!(cfg.servers.is_empty());
23072 }
23073
23074 #[test]
23075 async fn mcp_config_parsed_missing_flags_uses_enabled_eager_defaults() {
23076 let raw = r#"
23077[mcp]
23078
23079[[mcp.servers]]
23080name = "svc"
23081transport = "http"
23082url = "http://localhost:8080/mcp"
23083"#;
23084 let parsed = parse_test_config(raw);
23085 assert!(parsed.mcp.enabled);
23086 assert!(!parsed.mcp.deferred_loading);
23087 assert_eq!(parsed.mcp.servers.len(), 1);
23088 }
23089
23090 #[test]
23091 async fn mcp_config_explicit_disable_and_deferred_loading_are_respected() {
23092 let raw = r#"
23093[mcp]
23094enabled = false
23095deferred_loading = true
23096
23097[[mcp.servers]]
23098name = "svc"
23099transport = "http"
23100url = "http://localhost:8080/mcp"
23101"#;
23102 let parsed = parse_test_config(raw);
23103 assert!(!parsed.mcp.enabled);
23104 assert!(parsed.mcp.deferred_loading);
23105 assert_eq!(parsed.mcp.servers.len(), 1);
23106 }
23107
23108 #[test]
23109 async fn mcp_transport_serde_roundtrip_lowercase() {
23110 let cases = [
23111 (McpTransport::Stdio, "\"stdio\""),
23112 (McpTransport::Http, "\"http\""),
23113 (McpTransport::Sse, "\"sse\""),
23114 ];
23115 for (variant, expected_json) in &cases {
23116 let serialized = serde_json::to_string(variant).expect("serialize");
23117 assert_eq!(&serialized, expected_json, "variant: {variant:?}");
23118 let deserialized: McpTransport =
23119 serde_json::from_str(expected_json).expect("deserialize");
23120 assert_eq!(&deserialized, variant);
23121 }
23122 }
23123
23124 #[tokio::test]
23125 async fn nevis_client_secret_encrypt_decrypt_roundtrip() {
23126 let dir = std::env::temp_dir().join(format!(
23127 "zeroclaw_test_nevis_secret_{}",
23128 uuid::Uuid::new_v4()
23129 ));
23130 fs::create_dir_all(&dir).await.unwrap();
23131
23132 let plaintext_secret = "nevis-test-client-secret-value";
23133
23134 let mut config = Config {
23135 data_dir: dir.join("workspace"),
23136 config_path: dir.join("config.toml"),
23137 ..Default::default()
23138 };
23139 config.security.nevis.client_secret = Some(plaintext_secret.into());
23140
23141 config.save().await.unwrap();
23143
23144 let raw_toml = tokio::fs::read_to_string(&config.config_path)
23146 .await
23147 .unwrap();
23148 assert!(
23149 !raw_toml.contains(plaintext_secret),
23150 "Saved TOML must not contain the plaintext client_secret"
23151 );
23152
23153 let stored: Config = toml::from_str(&raw_toml).unwrap();
23155 let stored_secret = stored.security.nevis.client_secret.as_ref().unwrap();
23156 assert!(
23157 crate::secrets::SecretStore::is_encrypted(stored_secret),
23158 "Stored client_secret must be marked as encrypted"
23159 );
23160
23161 let store = crate::secrets::SecretStore::new(&dir, true);
23163 assert_eq!(store.decrypt(stored_secret).unwrap(), plaintext_secret);
23164
23165 let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
23167 loaded.config_path = dir.join("config.toml");
23168 let load_store = crate::secrets::SecretStore::new(&dir, loaded.secrets.encrypt);
23169 loaded.decrypt_secrets(&load_store).unwrap();
23170 assert_eq!(
23171 loaded.security.nevis.client_secret.as_deref().unwrap(),
23172 plaintext_secret,
23173 "Loaded client_secret must match the original plaintext after decryption"
23174 );
23175
23176 let _ = fs::remove_dir_all(&dir).await;
23177 }
23178
23179 #[test]
23184 async fn nevis_config_validate_disabled_accepts_empty_fields() {
23185 let cfg = NevisConfig::default();
23186 assert!(!cfg.enabled);
23187 assert!(cfg.validate().is_ok());
23188 }
23189
23190 #[test]
23191 async fn nevis_config_validate_rejects_empty_instance_url() {
23192 let cfg = NevisConfig {
23193 enabled: true,
23194 instance_url: String::new(),
23195 client_id: "test-client".into(),
23196 ..NevisConfig::default()
23197 };
23198 let err = cfg.validate().unwrap_err();
23199 assert!(err.contains("instance_url"));
23200 }
23201
23202 #[test]
23203 async fn nevis_config_validate_rejects_empty_client_id() {
23204 let cfg = NevisConfig {
23205 enabled: true,
23206 instance_url: "https://nevis.example.com".into(),
23207 client_id: String::new(),
23208 ..NevisConfig::default()
23209 };
23210 let err = cfg.validate().unwrap_err();
23211 assert!(err.contains("client_id"));
23212 }
23213
23214 #[test]
23215 async fn nevis_config_validate_rejects_empty_realm() {
23216 let cfg = NevisConfig {
23217 enabled: true,
23218 instance_url: "https://nevis.example.com".into(),
23219 client_id: "test-client".into(),
23220 realm: String::new(),
23221 ..NevisConfig::default()
23222 };
23223 let err = cfg.validate().unwrap_err();
23224 assert!(err.contains("realm"));
23225 }
23226
23227 #[test]
23228 async fn nevis_config_validate_rejects_local_without_jwks() {
23229 let cfg = NevisConfig {
23230 enabled: true,
23231 instance_url: "https://nevis.example.com".into(),
23232 client_id: "test-client".into(),
23233 token_validation: "local".into(),
23234 jwks_url: None,
23235 ..NevisConfig::default()
23236 };
23237 let err = cfg.validate().unwrap_err();
23238 assert!(err.contains("jwks_url"));
23239 }
23240
23241 #[test]
23242 async fn nevis_config_validate_rejects_zero_session_timeout() {
23243 let cfg = NevisConfig {
23244 enabled: true,
23245 instance_url: "https://nevis.example.com".into(),
23246 client_id: "test-client".into(),
23247 token_validation: "remote".into(),
23248 session_timeout_secs: 0,
23249 ..NevisConfig::default()
23250 };
23251 let err = cfg.validate().unwrap_err();
23252 assert!(err.contains("session_timeout_secs"));
23253 }
23254
23255 #[test]
23256 async fn nevis_config_validate_accepts_valid_enabled_config() {
23257 let cfg = NevisConfig {
23258 enabled: true,
23259 instance_url: "https://nevis.example.com".into(),
23260 realm: "master".into(),
23261 client_id: "test-client".into(),
23262 token_validation: "remote".into(),
23263 session_timeout_secs: 3600,
23264 ..NevisConfig::default()
23265 };
23266 assert!(cfg.validate().is_ok());
23267 }
23268
23269 #[test]
23270 async fn nevis_config_validate_rejects_invalid_token_validation() {
23271 let cfg = NevisConfig {
23272 enabled: true,
23273 instance_url: "https://nevis.example.com".into(),
23274 realm: "master".into(),
23275 client_id: "test-client".into(),
23276 token_validation: "invalid_mode".into(),
23277 session_timeout_secs: 3600,
23278 ..NevisConfig::default()
23279 };
23280 let err = cfg.validate().unwrap_err();
23281 assert!(
23282 err.contains("invalid value 'invalid_mode'"),
23283 "Expected invalid token_validation error, got: {err}"
23284 );
23285 }
23286
23287 #[test]
23288 async fn nevis_config_debug_redacts_client_secret() {
23289 let cfg = NevisConfig {
23290 client_secret: Some("super-secret".into()),
23291 ..NevisConfig::default()
23292 };
23293 let debug_output = format!("{:?}", cfg);
23294 assert!(
23295 !debug_output.contains("super-secret"),
23296 "Debug output must not contain the raw client_secret"
23297 );
23298 assert!(
23299 debug_output.contains("[REDACTED]"),
23300 "Debug output must show [REDACTED] for client_secret"
23301 );
23302 }
23303
23304 #[test]
23305 async fn telegram_config_ack_reactions_false_deserializes() {
23306 let toml_str = r#"
23307 bot_token = "123:ABC"
23308 allowed_users = ["alice"]
23309 ack_reactions = false
23310 "#;
23311 let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
23312 assert_eq!(cfg.ack_reactions, Some(false));
23313 }
23314
23315 #[test]
23316 async fn telegram_config_ack_reactions_true_deserializes() {
23317 let toml_str = r#"
23318 bot_token = "123:ABC"
23319 allowed_users = ["alice"]
23320 ack_reactions = true
23321 "#;
23322 let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
23323 assert_eq!(cfg.ack_reactions, Some(true));
23324 }
23325
23326 #[test]
23327 async fn telegram_config_ack_reactions_missing_defaults_to_none() {
23328 let toml_str = r#"
23329 bot_token = "123:ABC"
23330 allowed_users = ["alice"]
23331 "#;
23332 let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
23333 assert_eq!(cfg.ack_reactions, None);
23334 }
23335
23336 #[test]
23337 async fn telegram_config_ack_reactions_channel_overrides_top_level() {
23338 let tg_toml = r#"
23339 bot_token = "123:ABC"
23340 allowed_users = ["alice"]
23341 ack_reactions = false
23342 "#;
23343 let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
23344 let top_level_ack = true;
23345 let effective = tg.ack_reactions.unwrap_or(top_level_ack);
23346 assert!(
23347 !effective,
23348 "channel-level false must override top-level true"
23349 );
23350 }
23351
23352 #[test]
23353 async fn telegram_config_ack_reactions_falls_back_to_top_level() {
23354 let tg_toml = r#"
23355 bot_token = "123:ABC"
23356 allowed_users = ["alice"]
23357 "#;
23358 let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
23359 let top_level_ack = false;
23360 let effective = tg.ack_reactions.unwrap_or(top_level_ack);
23361 assert!(
23362 !effective,
23363 "must fall back to top-level false when channel omits field"
23364 );
23365 }
23366
23367 #[test]
23368 async fn google_workspace_allowed_operations_deserialize_from_toml() {
23369 let toml_str = r#"
23370 enabled = true
23371
23372 [[allowed_operations]]
23373 service = "gmail"
23374 resource = "users"
23375 sub_resource = "drafts"
23376 methods = ["create", "update"]
23377 "#;
23378
23379 let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
23380 assert_eq!(cfg.allowed_operations.len(), 1);
23381 assert_eq!(cfg.allowed_operations[0].service, "gmail");
23382 assert_eq!(cfg.allowed_operations[0].resource, "users");
23383 assert_eq!(
23384 cfg.allowed_operations[0].sub_resource.as_deref(),
23385 Some("drafts")
23386 );
23387 assert_eq!(
23388 cfg.allowed_operations[0].methods,
23389 vec!["create".to_string(), "update".to_string()]
23390 );
23391 }
23392
23393 #[test]
23394 async fn google_workspace_allowed_operations_deserialize_without_sub_resource() {
23395 let toml_str = r#"
23396 enabled = true
23397
23398 [[allowed_operations]]
23399 service = "drive"
23400 resource = "files"
23401 methods = ["list", "get"]
23402 "#;
23403
23404 let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
23405 assert_eq!(cfg.allowed_operations[0].sub_resource, None);
23406 }
23407
23408 #[test]
23409 async fn config_validate_accepts_google_workspace_allowed_operations() {
23410 let mut cfg = Config::default();
23411 cfg.google_workspace.enabled = true;
23412 cfg.google_workspace.allowed_services = vec!["gmail".into()];
23413 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
23414 service: "gmail".into(),
23415 resource: "users".into(),
23416 sub_resource: Some("drafts".into()),
23417 methods: vec!["create".into(), "update".into()],
23418 }];
23419
23420 cfg.validate().unwrap();
23421 }
23422
23423 #[test]
23424 async fn config_validate_rejects_duplicate_google_workspace_allowed_operations() {
23425 let mut cfg = Config::default();
23426 cfg.google_workspace.enabled = true;
23427 cfg.google_workspace.allowed_services = vec!["gmail".into()];
23428 cfg.google_workspace.allowed_operations = vec![
23429 GoogleWorkspaceAllowedOperation {
23430 service: "gmail".into(),
23431 resource: "users".into(),
23432 sub_resource: Some("drafts".into()),
23433 methods: vec!["create".into()],
23434 },
23435 GoogleWorkspaceAllowedOperation {
23436 service: "gmail".into(),
23437 resource: "users".into(),
23438 sub_resource: Some("drafts".into()),
23439 methods: vec!["update".into()],
23440 },
23441 ];
23442
23443 let err = cfg.validate().unwrap_err().to_string();
23444 assert!(err.contains("duplicate service/resource/sub_resource entry"));
23445 }
23446
23447 #[test]
23448 async fn config_validate_rejects_operation_service_not_in_allowed_services() {
23449 let mut cfg = Config::default();
23450 cfg.google_workspace.enabled = true;
23451 cfg.google_workspace.allowed_services = vec!["gmail".into()];
23452 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
23453 service: "drive".into(), resource: "files".into(),
23455 sub_resource: None,
23456 methods: vec!["list".into()],
23457 }];
23458
23459 let err = cfg.validate().unwrap_err().to_string();
23460 assert!(
23461 err.contains("not in the effective allowed_services"),
23462 "expected not-in-allowed_services error, got: {err}"
23463 );
23464 }
23465
23466 #[test]
23467 async fn config_validate_accepts_default_service_when_allowed_services_empty() {
23468 let mut cfg = Config::default();
23471 cfg.google_workspace.enabled = true;
23472 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
23474 service: "drive".into(),
23475 resource: "files".into(),
23476 sub_resource: None,
23477 methods: vec!["list".into()],
23478 }];
23479
23480 assert!(cfg.validate().is_ok());
23481 }
23482
23483 #[test]
23484 async fn config_validate_rejects_unknown_service_when_allowed_services_empty() {
23485 let mut cfg = Config::default();
23489 cfg.google_workspace.enabled = true;
23490 cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
23492 service: "not_a_real_service".into(),
23493 resource: "files".into(),
23494 sub_resource: None,
23495 methods: vec!["list".into()],
23496 }];
23497
23498 let err = cfg.validate().unwrap_err().to_string();
23499 assert!(
23500 err.contains("not in the effective allowed_services"),
23501 "expected effective-allowed_services error, got: {err}"
23502 );
23503 }
23504
23505 #[tokio::test]
23508 async fn ensure_bootstrap_files_creates_missing_files() {
23509 let tmp = tempfile::TempDir::new().unwrap();
23510 let ws = tmp.path().join("workspace");
23511 let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
23512
23513 ensure_bootstrap_files(&ws).await.unwrap();
23514
23515 let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
23516 let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
23517 .await
23518 .unwrap();
23519 assert!(soul.contains("SOUL.md"));
23520 assert!(identity.contains("IDENTITY.md"));
23521 }
23522
23523 #[tokio::test]
23524 async fn ensure_bootstrap_files_does_not_overwrite_existing() {
23525 let tmp = tempfile::TempDir::new().unwrap();
23526 let ws = tmp.path().join("workspace");
23527 let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
23528
23529 let custom = "# My custom SOUL";
23530 let _: () = tokio::fs::write(ws.join("SOUL.md"), custom).await.unwrap();
23531
23532 ensure_bootstrap_files(&ws).await.unwrap();
23533
23534 let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
23535 assert_eq!(
23536 soul, custom,
23537 "ensure_bootstrap_files must not overwrite existing files"
23538 );
23539
23540 let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
23542 .await
23543 .unwrap();
23544 assert!(identity.contains("IDENTITY.md"));
23545 }
23546
23547 #[test]
23550 async fn pacing_config_serde_defaults_match_manual_default() {
23551 let from_toml: PacingConfig = toml::from_str("").unwrap();
23554 let manual = PacingConfig::default();
23555
23556 assert_eq!(
23557 from_toml.loop_detection_enabled,
23558 manual.loop_detection_enabled
23559 );
23560 assert_eq!(
23561 from_toml.loop_detection_window_size,
23562 manual.loop_detection_window_size
23563 );
23564 assert_eq!(
23565 from_toml.loop_detection_max_repeats,
23566 manual.loop_detection_max_repeats
23567 );
23568
23569 assert!(from_toml.loop_detection_enabled, "default should be true");
23571 assert_eq!(from_toml.loop_detection_window_size, 20);
23572 assert_eq!(from_toml.loop_detection_max_repeats, 3);
23573 }
23574
23575 const DOCKER_CONFIG_TEMPLATE: &str = r#"
23580schema_version = 3
23581workspace_dir = "/zeroclaw-data/workspace"
23582config_path = "/zeroclaw-data/.zeroclaw/config.toml"
23583api_key = ""
23584default_model_provider = "openrouter"
23585default_model = "anthropic/claude-sonnet-4-20250514"
23586default_temperature = 0.7
23587
23588[gateway]
23589port = 42617
23590host = "[::]"
23591allow_public_bind = true
23592
23593[risk_profiles.default]
23594level = "supervised"
23595auto_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"]
23596"#;
23597
23598 #[test]
23599 async fn docker_config_template_is_parseable() {
23600 let cfg: Config = toml::from_str(DOCKER_CONFIG_TEMPLATE)
23601 .expect("Docker baked config.toml must be valid TOML that deserialises into Config");
23602
23603 let auto = &cfg
23604 .risk_profiles
23605 .get("default")
23606 .expect("Docker config must define [risk_profiles.default]")
23607 .auto_approve;
23608 for tool in &[
23609 "file_read",
23610 "file_write",
23611 "file_edit",
23612 "memory_recall",
23613 "memory_store",
23614 "web_search_tool",
23615 "web_fetch",
23616 "calculator",
23617 "glob_search",
23618 "content_search",
23619 "image_info",
23620 "weather",
23621 "git_operations",
23622 ] {
23623 assert!(
23624 auto.iter().any(|t| t == tool),
23625 "Docker config risk_profiles.default.auto_approve missing expected tool: {tool}"
23626 );
23627 }
23628 }
23629
23630 #[test]
23631 async fn cost_enforcement_config_defaults() {
23632 let config = CostEnforcementConfig::default();
23633 assert_eq!(config.mode, "warn");
23634 assert_eq!(config.route_down_model, None);
23635 assert_eq!(config.reserve_percent, 10);
23636 }
23637
23638 #[test]
23639 async fn cost_config_includes_enforcement() {
23640 let config = CostConfig::default();
23641 assert_eq!(config.enforcement.mode, "warn");
23642 assert_eq!(config.enforcement.reserve_percent, 10);
23643 }
23644
23645 #[test]
23648 async fn matrix_secret_fields_discovered() {
23649 let mx = MatrixConfig {
23650 enabled: true,
23651 homeserver: "https://m.org".into(),
23652 access_token: Some("tok".into()),
23653 user_id: None,
23654 device_id: None,
23655 allowed_rooms: vec!["!r:m".into()],
23656 interrupt_on_new_message: false,
23657 stream_mode: StreamMode::default(),
23658 draft_update_interval_ms: 1500,
23659 multi_message_delay_ms: 800,
23660 recovery_key: None,
23661 mention_only: false,
23662 password: None,
23663 approval_timeout_secs: 300,
23664 reply_in_thread: true,
23665 ack_reactions: Some(true),
23666 excluded_tools: vec![],
23667 reply_min_interval_secs: 0,
23668 reply_queue_depth_max: 0,
23669 };
23670 let fields = mx.secret_fields();
23671 assert_eq!(fields.len(), 3);
23672 assert_eq!(fields[0].name, "channels.matrix.access_token");
23673 assert_eq!(fields[0].category, "Channels");
23674 assert!(fields[0].is_set);
23675 assert_eq!(fields[1].name, "channels.matrix.recovery_key");
23676 assert!(!fields[1].is_set);
23677 assert_eq!(fields[2].name, "channels.matrix.password");
23678 assert!(!fields[2].is_set);
23679 }
23680
23681 #[test]
23682 async fn matrix_secret_fields_empty_not_set() {
23683 let mx = MatrixConfig {
23684 enabled: true,
23685 homeserver: "https://m.org".into(),
23686 access_token: None,
23687 user_id: None,
23688 device_id: None,
23689 allowed_rooms: vec!["!r:m".into()],
23690 interrupt_on_new_message: false,
23691 stream_mode: StreamMode::default(),
23692 draft_update_interval_ms: 1500,
23693 multi_message_delay_ms: 800,
23694 recovery_key: None,
23695 mention_only: false,
23696 password: None,
23697 approval_timeout_secs: 300,
23698 reply_in_thread: true,
23699 ack_reactions: Some(true),
23700 excluded_tools: vec![],
23701 reply_min_interval_secs: 0,
23702 reply_queue_depth_max: 0,
23703 };
23704 let fields = mx.secret_fields();
23705 assert!(!fields[0].is_set);
23706 }
23707
23708 #[test]
23709 async fn set_secret_updates_field() {
23710 let mut mx = MatrixConfig {
23711 enabled: true,
23712 homeserver: "https://m.org".into(),
23713 access_token: Some("old".into()),
23714 user_id: None,
23715 device_id: None,
23716 allowed_rooms: vec!["!r:m".into()],
23717 interrupt_on_new_message: false,
23718 stream_mode: StreamMode::default(),
23719 draft_update_interval_ms: 1500,
23720 multi_message_delay_ms: 800,
23721 recovery_key: None,
23722 mention_only: false,
23723 password: None,
23724 approval_timeout_secs: 300,
23725 reply_in_thread: true,
23726 ack_reactions: Some(true),
23727 excluded_tools: vec![],
23728 reply_min_interval_secs: 0,
23729 reply_queue_depth_max: 0,
23730 };
23731 mx.set_secret("channels.matrix.access_token", "new-token".into())
23732 .unwrap();
23733 assert_eq!(mx.access_token.as_deref(), Some("new-token"));
23734 }
23735
23736 #[test]
23737 async fn set_secret_unknown_name_fails() {
23738 let mut mx = MatrixConfig {
23739 enabled: true,
23740 homeserver: "https://m.org".into(),
23741 access_token: Some("tok".into()),
23742 user_id: None,
23743 device_id: None,
23744 allowed_rooms: vec!["!r:m".into()],
23745 interrupt_on_new_message: false,
23746 stream_mode: StreamMode::default(),
23747 draft_update_interval_ms: 1500,
23748 multi_message_delay_ms: 800,
23749 recovery_key: None,
23750 mention_only: false,
23751 password: None,
23752 approval_timeout_secs: 300,
23753 reply_in_thread: true,
23754 ack_reactions: Some(true),
23755 excluded_tools: vec![],
23756 reply_min_interval_secs: 0,
23757 reply_queue_depth_max: 0,
23758 };
23759 assert!(
23760 mx.set_secret("channels.matrix.nonexistent", "val".into())
23761 .is_err()
23762 );
23763 }
23764
23765 #[test]
23766 async fn config_tree_traversal_discovers_nested_secrets() {
23767 let mut config = Config::default();
23768 config
23770 .providers
23771 .models
23772 .ensure("anthropic", "default")
23773 .expect("anthropic typed slot")
23774 .api_key = Some("test-key".into());
23775 config.channels.matrix.insert(
23776 "default".to_string(),
23777 MatrixConfig {
23778 enabled: true,
23779 homeserver: "https://m.org".into(),
23780 access_token: Some("mx-tok".into()),
23781 user_id: None,
23782 device_id: None,
23783 allowed_rooms: vec!["!r:m".into()],
23784 interrupt_on_new_message: false,
23785 stream_mode: StreamMode::default(),
23786 draft_update_interval_ms: 1500,
23787 multi_message_delay_ms: 800,
23788 recovery_key: None,
23789 mention_only: false,
23790 password: None,
23791 approval_timeout_secs: 300,
23792 reply_in_thread: true,
23793 ack_reactions: Some(true),
23794 excluded_tools: vec![],
23795 reply_min_interval_secs: 0,
23796 reply_queue_depth_max: 0,
23797 },
23798 );
23799
23800 let fields = config.secret_fields();
23801 let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
23802 assert!(names.contains(&"channels.matrix.access_token"));
23803 assert!(names.contains(&"channels.matrix.recovery_key"));
23804 assert!(
23805 names.contains(&"http_request.secrets"),
23806 "http_request.secrets must be classified as a secret map"
23807 );
23808 }
23809
23810 #[test]
23811 async fn config_set_secret_dispatches_to_child() {
23812 let mut config = Config::default();
23813 config.channels.matrix.insert(
23814 "default".to_string(),
23815 MatrixConfig {
23816 enabled: true,
23817 homeserver: "https://m.org".into(),
23818 access_token: Some("old".into()),
23819 user_id: None,
23820 device_id: None,
23821 allowed_rooms: vec!["!r:m".into()],
23822 interrupt_on_new_message: false,
23823 stream_mode: StreamMode::default(),
23824 draft_update_interval_ms: 1500,
23825 multi_message_delay_ms: 800,
23826 recovery_key: None,
23827 mention_only: false,
23828 password: None,
23829 approval_timeout_secs: 300,
23830 reply_in_thread: true,
23831 ack_reactions: Some(true),
23832 excluded_tools: vec![],
23833 reply_min_interval_secs: 0,
23834 reply_queue_depth_max: 0,
23835 },
23836 );
23837
23838 config
23839 .set_secret("channels.matrix.access_token", "new".into())
23840 .unwrap();
23841 assert_eq!(
23842 config
23843 .channels
23844 .matrix
23845 .get("default")
23846 .unwrap()
23847 .access_token
23848 .as_deref(),
23849 Some("new")
23850 );
23851 }
23852
23853 #[test]
23854 async fn config_set_secret_dispatches_to_matrix_child() {
23855 let mut config = Config::default();
23856 config.channels.matrix.insert(
23857 "default".to_string(),
23858 MatrixConfig {
23859 enabled: true,
23860 homeserver: "https://m.org".into(),
23861 access_token: Some("old".into()),
23862 user_id: None,
23863 device_id: None,
23864 allowed_rooms: vec!["!r:m".into()],
23865 interrupt_on_new_message: false,
23866 stream_mode: StreamMode::default(),
23867 draft_update_interval_ms: 1500,
23868 multi_message_delay_ms: 800,
23869 mention_only: false,
23870 recovery_key: None,
23871 password: None,
23872 approval_timeout_secs: 300,
23873 reply_in_thread: true,
23874 ack_reactions: Some(true),
23875 excluded_tools: vec![],
23876 reply_min_interval_secs: 0,
23877 reply_queue_depth_max: 0,
23878 },
23879 );
23880 config
23881 .set_secret("channels.matrix.access_token", "sk-test".into())
23882 .unwrap();
23883 assert_eq!(
23884 config
23885 .channels
23886 .matrix
23887 .get("default")
23888 .unwrap()
23889 .access_token
23890 .as_deref(),
23891 Some("sk-test")
23892 );
23893 }
23894
23895 #[test]
23896 async fn config_set_secret_unknown_fails() {
23897 let mut config = Config::default();
23898 assert!(
23899 config
23900 .set_secret("nonexistent.field", "val".into())
23901 .is_err()
23902 );
23903 }
23904
23905 #[test]
23906 async fn config_set_http_request_secret_map_key_is_masked_and_encrypted() {
23907 let dir = TempDir::new().unwrap();
23908 let config_path = dir.path().join("config.toml");
23909 tokio::fs::write(&config_path, "schema_version = 1\n")
23910 .await
23911 .unwrap();
23912 let mut config = Config {
23913 config_path: config_path.clone(),
23914 data_dir: dir.path().join("workspace"),
23915 secrets: SecretsConfig { encrypt: true },
23916 ..Config::default()
23917 };
23918 let path = "http_request.secrets.api_token";
23919
23920 assert!(
23921 Config::prop_is_secret(path),
23922 "dynamic http_request secret map entries must be classified as secret before the key exists"
23923 );
23924 config
23925 .set_prop_persistent(path, "Bearer from-config-set")
23926 .unwrap();
23927
23928 assert_eq!(
23929 config
23930 .http_request
23931 .secrets
23932 .get("api_token")
23933 .map(String::as_str),
23934 Some("Bearer from-config-set")
23935 );
23936 assert_eq!(config.get_prop(path).unwrap(), "****");
23937
23938 let field = config
23939 .prop_fields()
23940 .into_iter()
23941 .find(|field| field.name == path)
23942 .expect("dynamic secret map prop field");
23943 assert!(field.is_secret);
23944 assert_eq!(field.display_value, "****");
23945 assert_eq!(
23946 field.credential_class,
23947 Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
23948 );
23949
23950 config.save_dirty().await.unwrap();
23951 let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
23952 assert!(
23953 !contents.contains("Bearer from-config-set"),
23954 "auth secret must not be written in plaintext: {contents}"
23955 );
23956
23957 let stored = crate::migration::migrate_to_current(&contents).unwrap();
23958 let encrypted = stored.http_request.secrets.get("api_token").unwrap();
23959 assert!(crate::secrets::SecretStore::is_encrypted(encrypted));
23960 let store = crate::secrets::SecretStore::new(dir.path(), true);
23961 assert_eq!(store.decrypt(encrypted).unwrap(), "Bearer from-config-set");
23962 }
23963
23964 #[test]
23965 async fn encrypt_decrypt_roundtrip_via_macro() {
23966 let dir = TempDir::new().unwrap();
23967 let store = crate::secrets::SecretStore::new(dir.path(), true);
23968
23969 let mut mx = MatrixConfig {
23970 enabled: true,
23971 homeserver: "https://m.org".into(),
23972 access_token: Some("plaintext-token".into()),
23973 user_id: None,
23974 device_id: None,
23975 allowed_rooms: vec!["!r:m".into()],
23976 interrupt_on_new_message: false,
23977 stream_mode: StreamMode::default(),
23978 draft_update_interval_ms: 1500,
23979 multi_message_delay_ms: 800,
23980 recovery_key: None,
23981 mention_only: false,
23982 password: None,
23983 approval_timeout_secs: 300,
23984 reply_in_thread: true,
23985 ack_reactions: Some(true),
23986 excluded_tools: vec![],
23987 reply_min_interval_secs: 0,
23988 reply_queue_depth_max: 0,
23989 };
23990
23991 mx.encrypt_secrets(&store).unwrap();
23993 assert!(crate::secrets::SecretStore::is_encrypted(
23994 mx.access_token.as_deref().unwrap_or_default()
23995 ));
23996 assert_ne!(mx.access_token.as_deref(), Some("plaintext-token"));
23997
23998 mx.decrypt_secrets(&store).unwrap();
24000 assert_eq!(mx.access_token.as_deref(), Some("plaintext-token"));
24001 }
24002
24003 #[test]
24004 async fn encrypt_skips_already_encrypted() {
24005 let dir = TempDir::new().unwrap();
24006 let store = crate::secrets::SecretStore::new(dir.path(), true);
24007
24008 let mut mx = MatrixConfig {
24009 enabled: true,
24010 homeserver: "https://m.org".into(),
24011 access_token: Some("plaintext-token".into()),
24012 user_id: None,
24013 device_id: None,
24014 allowed_rooms: vec!["!r:m".into()],
24015 interrupt_on_new_message: false,
24016 stream_mode: StreamMode::default(),
24017 draft_update_interval_ms: 1500,
24018 multi_message_delay_ms: 800,
24019 recovery_key: None,
24020 mention_only: false,
24021 password: None,
24022 approval_timeout_secs: 300,
24023 reply_in_thread: true,
24024 ack_reactions: Some(true),
24025 excluded_tools: vec![],
24026 reply_min_interval_secs: 0,
24027 reply_queue_depth_max: 0,
24028 };
24029
24030 mx.encrypt_secrets(&store).unwrap();
24031 let first_encrypted = mx.access_token.clone();
24032
24033 mx.encrypt_secrets(&store).unwrap();
24035 assert_eq!(mx.access_token, first_encrypted);
24036 }
24037
24038 #[test]
24039 async fn encrypt_no_op_on_disabled_store() {
24040 let dir = TempDir::new().unwrap();
24041 let store = crate::secrets::SecretStore::new(dir.path(), false);
24042
24043 let mut mx = MatrixConfig {
24044 enabled: true,
24045 homeserver: "https://m.org".into(),
24046 access_token: Some("plaintext-token".into()),
24047 user_id: None,
24048 device_id: None,
24049 allowed_rooms: vec!["!r:m".into()],
24050 interrupt_on_new_message: false,
24051 stream_mode: StreamMode::default(),
24052 draft_update_interval_ms: 1500,
24053 multi_message_delay_ms: 800,
24054 recovery_key: None,
24055 mention_only: false,
24056 password: None,
24057 approval_timeout_secs: 300,
24058 reply_in_thread: true,
24059 ack_reactions: Some(true),
24060 excluded_tools: vec![],
24061 reply_min_interval_secs: 0,
24062 reply_queue_depth_max: 0,
24063 };
24064
24065 mx.encrypt_secrets(&store).unwrap();
24066 assert_eq!(mx.access_token.as_deref(), Some("plaintext-token"));
24068 }
24069
24070 fn test_matrix_config() -> MatrixConfig {
24073 MatrixConfig {
24074 enabled: true,
24075 homeserver: "https://m.org".into(),
24076 access_token: Some("tok".into()),
24077 user_id: Some("@bot:m.org".into()),
24078 device_id: None,
24079 allowed_rooms: vec!["!r:m".into()],
24080 interrupt_on_new_message: false,
24081 stream_mode: StreamMode::default(),
24082 draft_update_interval_ms: 1500,
24083 multi_message_delay_ms: 800,
24084 recovery_key: None,
24085 mention_only: false,
24086 password: None,
24087 approval_timeout_secs: 300,
24088 reply_in_thread: true,
24089 ack_reactions: Some(true),
24090 excluded_tools: vec![],
24091 reply_min_interval_secs: 0,
24092 reply_queue_depth_max: 0,
24093 }
24094 }
24095
24096 #[test]
24097 async fn prop_fields_returns_typed_entries() {
24098 let mx = test_matrix_config();
24099 let fields = mx.prop_fields();
24100 let by_name: std::collections::HashMap<&str, &crate::traits::PropFieldInfo> =
24101 fields.iter().map(|f| (f.name.as_str(), f)).collect();
24102
24103 let homeserver = by_name["channels.matrix.homeserver"];
24105 assert_eq!(homeserver.type_hint, "String");
24106 assert_eq!(homeserver.display_value, "https://m.org");
24107
24108 let user_id = by_name["channels.matrix.user_id"];
24110 assert_eq!(user_id.type_hint, "Option<String>");
24111 assert_eq!(user_id.display_value, "@bot:m.org");
24112
24113 let device_id = by_name["channels.matrix.device_id"];
24115 assert_eq!(device_id.display_value, "<unset>");
24116
24117 let interval = by_name["channels.matrix.draft_update_interval_ms"];
24119 assert_eq!(interval.type_hint, "u64");
24120 assert_eq!(interval.display_value, "1500");
24121
24122 let stream = by_name["channels.matrix.stream_mode"];
24124 assert!(stream.is_enum());
24125 assert!(stream.enum_variants.is_some());
24126
24127 let token = by_name["channels.matrix.access_token"];
24129 assert!(token.is_secret);
24130 assert_eq!(token.display_value, "****");
24131
24132 for field in &fields {
24134 assert_eq!(field.category, "Channels");
24135 }
24136 }
24137
24138 #[test]
24139 async fn get_prop_returns_values_by_path() {
24140 let mx = test_matrix_config();
24141
24142 assert_eq!(
24143 mx.get_prop("channels.matrix.homeserver").unwrap(),
24144 "https://m.org"
24145 );
24146 assert_eq!(
24147 mx.get_prop("channels.matrix.draft_update_interval_ms")
24148 .unwrap(),
24149 "1500"
24150 );
24151 assert_eq!(
24152 mx.get_prop("channels.matrix.user_id").unwrap(),
24153 "@bot:m.org"
24154 );
24155 assert_eq!(mx.get_prop("channels.matrix.device_id").unwrap(), "<unset>");
24156 assert_eq!(
24158 mx.get_prop("channels.matrix.access_token").unwrap(),
24159 "**** (encrypted)"
24160 );
24161 }
24162
24163 #[test]
24164 async fn get_prop_unknown_path_fails() {
24165 let mx = test_matrix_config();
24166 assert!(mx.get_prop("channels.matrix.nonexistent").is_err());
24167 }
24168
24169 #[test]
24170 async fn set_prop_string() {
24171 let mut mx = test_matrix_config();
24172 mx.set_prop("channels.matrix.homeserver", "https://new.org")
24173 .unwrap();
24174 assert_eq!(mx.homeserver, "https://new.org");
24175 }
24176
24177 #[test]
24178 async fn set_prop_bool() {
24179 let mut mx = test_matrix_config();
24180 mx.set_prop("channels.matrix.interrupt_on_new_message", "true")
24181 .unwrap();
24182 assert!(mx.interrupt_on_new_message);
24183 }
24184
24185 #[test]
24186 async fn set_prop_bool_rejects_invalid() {
24187 let mut mx = test_matrix_config();
24188 let err = mx
24189 .set_prop("channels.matrix.interrupt_on_new_message", "yes")
24190 .unwrap_err();
24191 assert!(err.to_string().contains("bool"));
24192 }
24193
24194 #[test]
24195 async fn set_prop_u64() {
24196 let mut mx = test_matrix_config();
24197 mx.set_prop("channels.matrix.draft_update_interval_ms", "3000")
24198 .unwrap();
24199 assert_eq!(mx.draft_update_interval_ms, 3000);
24200 }
24201
24202 #[test]
24203 async fn set_prop_u64_rejects_invalid() {
24204 let mut mx = test_matrix_config();
24205 assert!(
24206 mx.set_prop("channels.matrix.draft_update_interval_ms", "abc")
24207 .is_err()
24208 );
24209 }
24210
24211 #[test]
24212 async fn set_prop_option_string_set_and_clear() {
24213 let mut mx = test_matrix_config();
24214 mx.set_prop("channels.matrix.user_id", "@new:m.org")
24215 .unwrap();
24216 assert_eq!(mx.user_id.as_deref(), Some("@new:m.org"));
24217
24218 mx.set_prop("channels.matrix.user_id", "").unwrap();
24220 assert!(mx.user_id.is_none());
24221 }
24222
24223 #[test]
24224 async fn set_prop_enum() {
24225 let mut mx = test_matrix_config();
24226 mx.set_prop("channels.matrix.stream_mode", "partial")
24227 .unwrap();
24228 assert_eq!(mx.stream_mode, StreamMode::Partial);
24229
24230 mx.set_prop("channels.matrix.stream_mode", "multi_message")
24231 .unwrap();
24232 assert_eq!(mx.stream_mode, StreamMode::MultiMessage);
24233 }
24234
24235 #[test]
24236 async fn set_prop_enum_rejects_invalid() {
24237 let mut mx = test_matrix_config();
24238 let err = mx
24239 .set_prop("channels.matrix.stream_mode", "invalid")
24240 .unwrap_err();
24241 assert!(err.to_string().contains("expected one of"));
24242 }
24243
24244 #[test]
24245 async fn set_prop_unknown_path_fails() {
24246 let mut mx = test_matrix_config();
24247 assert!(mx.set_prop("channels.matrix.nonexistent", "val").is_err());
24248 }
24249
24250 #[test]
24251 async fn prop_is_secret_static_check() {
24252 assert!(MatrixConfig::prop_is_secret("channels.matrix.access_token"));
24253 assert!(MatrixConfig::prop_is_secret("channels.matrix.recovery_key"));
24254 assert!(!MatrixConfig::prop_is_secret("channels.matrix.homeserver"));
24255 assert!(!MatrixConfig::prop_is_secret(
24256 "channels.matrix.interrupt_on_new_message"
24257 ));
24258 }
24259
24260 #[test]
24261 async fn apply_env_overrides_rejects_schema_version() {
24262 let _env_guard = env_override_lock().await;
24263 unsafe { std::env::set_var("ZEROCLAW_schema_version", "99") };
24265 let mut config = Config::default();
24266 let result = crate::env_overrides::apply_env_overrides(&mut config);
24267 unsafe { std::env::remove_var("ZEROCLAW_schema_version") };
24269
24270 let err = result.expect_err("schema_version override must be rejected");
24271 let msg = format!("{err:#}");
24272 assert!(
24273 msg.contains("schema_version") && msg.contains("not overridable"),
24274 "error must name the path and the reason: {msg}",
24275 );
24276 assert_eq!(
24278 config.schema_version,
24279 crate::migration::CURRENT_SCHEMA_VERSION
24280 );
24281 }
24282
24283 #[test]
24284 async fn prop_is_env_overridden_reflects_env_overridden_paths() {
24285 let mut cfg = Config::default();
24287 assert!(!cfg.prop_is_env_overridden("channels.matrix.homeserver"));
24288 assert!(!cfg.prop_is_env_overridden("gateway.request_timeout_secs"));
24289
24290 cfg.env_overridden_paths = std::collections::HashSet::from([
24293 "channels.matrix.homeserver".to_string(),
24294 "gateway.request_timeout_secs".to_string(),
24295 ]);
24296
24297 assert!(cfg.prop_is_env_overridden("channels.matrix.homeserver"));
24299 assert!(cfg.prop_is_env_overridden("gateway.request_timeout_secs"));
24300 assert!(!cfg.prop_is_env_overridden("channels.matrix.access_token"));
24301 assert!(!cfg.prop_is_env_overridden("gateway.host"));
24302 assert!(!cfg.prop_is_env_overridden(""));
24304 assert!(!cfg.prop_is_env_overridden("does.not.exist"));
24305 }
24306
24307 #[test]
24308 async fn prop_is_secret_routes_through_hashmap_keyed_paths() {
24309 assert!(Config::prop_is_secret(
24317 "providers.models.openrouter.default.api_key"
24318 ));
24319 assert!(Config::prop_is_secret(
24320 "providers.models.anthropic.default.api_key"
24321 ));
24322 assert!(!Config::prop_is_secret(
24323 "providers.models.openrouter.default.endpoint"
24324 ));
24325 assert!(!Config::prop_is_secret(
24326 "providers.models.openrouter.default.context-window"
24327 ));
24328 }
24329
24330 #[test]
24331 async fn typed_custom_slot_round_trips_uri_through_save_and_load() {
24332 let dir = TempDir::new().unwrap();
24337 let mut config = Config {
24338 config_path: dir.path().join("config.toml"),
24339 data_dir: dir.path().join("workspace"),
24340 ..Default::default()
24341 };
24342 let alias = "default";
24343 config
24344 .providers
24345 .models
24346 .ensure("custom", alias)
24347 .expect("custom typed slot");
24348
24349 let prefix = format!("providers.models.custom.{alias}");
24350 let api_key_path = format!("{prefix}.api_key");
24351 let uri_path = format!("{prefix}.uri");
24352 let model_path = format!("{prefix}.model");
24353 let temperature_path = format!("{prefix}.temperature");
24354
24355 assert!(
24356 Config::prop_is_secret(&api_key_path),
24357 "typed custom-slot api-key must route through the secret marker",
24358 );
24359
24360 config.set_prop(&api_key_path, "sk-test-custom").unwrap();
24361 config
24362 .set_prop(&uri_path, "https://api.example.invalid/v1")
24363 .unwrap();
24364 config.set_prop(&model_path, "local-large").unwrap();
24365 config.set_prop(&temperature_path, "0.2").unwrap();
24366
24367 let provider = config
24368 .providers
24369 .models
24370 .find("custom", alias)
24371 .expect("custom typed slot entry must be present");
24372 assert_eq!(provider.api_key.as_deref(), Some("sk-test-custom"));
24373 assert_eq!(
24374 provider.uri.as_deref(),
24375 Some("https://api.example.invalid/v1")
24376 );
24377 assert_eq!(provider.model.as_deref(), Some("local-large"));
24378 assert_eq!(provider.temperature, Some(0.2));
24379
24380 assert_eq!(config.get_prop(&api_key_path).unwrap(), "**** (encrypted)");
24381 assert_eq!(
24382 config.get_prop(&uri_path).unwrap(),
24383 "https://api.example.invalid/v1"
24384 );
24385
24386 config.save().await.unwrap();
24387 let raw_toml = tokio::fs::read_to_string(&config.config_path)
24388 .await
24389 .unwrap();
24390 assert!(
24391 raw_toml.contains("[providers.models.custom.default]"),
24392 "saved TOML should write under the typed custom slot",
24393 );
24394 assert!(
24395 !raw_toml.contains("sk-test-custom"),
24396 "saved TOML must not contain the plaintext custom provider API key",
24397 );
24398
24399 let mut loaded: Config = crate::migration::migrate_to_current(&raw_toml).unwrap();
24400 loaded.config_path = config.config_path.clone();
24401 loaded.data_dir = config.data_dir.clone();
24402 let store = crate::secrets::SecretStore::new(dir.path(), loaded.secrets.encrypt);
24403 loaded.decrypt_secrets(&store).unwrap();
24404 let loaded_provider = loaded
24405 .providers
24406 .models
24407 .find("custom", alias)
24408 .expect("typed custom slot entry must round-trip through save/load");
24409 assert_eq!(loaded_provider.api_key.as_deref(), Some("sk-test-custom"));
24410 assert_eq!(
24411 loaded_provider.uri.as_deref(),
24412 Some("https://api.example.invalid/v1")
24413 );
24414 assert_eq!(loaded_provider.model.as_deref(), Some("local-large"));
24415 assert_eq!(loaded_provider.temperature, Some(0.2));
24416 }
24417
24418 #[test]
24419 async fn env_override_save_cycle_preserves_on_disk_secret() {
24420 let dir = TempDir::new().unwrap();
24437 let mut config = Config {
24438 config_path: dir.path().join("config.toml"),
24439 data_dir: dir.path().join("workspace"),
24440 ..Default::default()
24441 };
24442 let original_secret = "sk-ant-real-on-disk-credential";
24443 let api_key_path = "providers.models.anthropic.default.api_key";
24444 config
24445 .providers
24446 .models
24447 .ensure("anthropic", "default")
24448 .expect("typed slot");
24449 config.set_prop(api_key_path, original_secret).unwrap();
24450
24451 config.save().await.unwrap();
24453
24454 let raw = tokio::fs::read_to_string(&config.config_path)
24456 .await
24457 .unwrap();
24458 let mut reloaded: Config = crate::migration::migrate_to_current(&raw).unwrap();
24459 reloaded.config_path = config.config_path.clone();
24460 reloaded.data_dir = config.data_dir.clone();
24461 let store = crate::secrets::SecretStore::new(dir.path(), reloaded.secrets.encrypt);
24462 reloaded.decrypt_secrets(&store).unwrap();
24463 assert_eq!(
24464 reloaded
24465 .providers
24466 .models
24467 .anthropic
24468 .get("default")
24469 .and_then(|c| c.base.api_key.as_deref()),
24470 Some(original_secret),
24471 "baseline: original secret round-trips through one save/reload cycle",
24472 );
24473
24474 let env_value = "sk-ant-from-env-DIFFERENT";
24480 reloaded.env_overridden_paths = std::collections::HashSet::from([api_key_path.to_string()]);
24481 reloaded.pre_override_snapshots = std::collections::HashMap::from([(
24482 api_key_path.to_string(),
24483 original_secret.to_string(),
24484 )]);
24485 reloaded.set_prop(api_key_path, env_value).unwrap();
24486
24487 reloaded.save().await.unwrap();
24490
24491 let raw_after = tokio::fs::read_to_string(&reloaded.config_path)
24495 .await
24496 .unwrap();
24497 assert!(
24498 !raw_after.contains(env_value),
24499 "env-injected value must never reach disk: {raw_after}",
24500 );
24501 assert!(
24502 !raw_after.contains("**** (encrypted)"),
24503 "display mask must never be persisted as a secret value: {raw_after}",
24504 );
24505
24506 let mut after: Config = crate::migration::migrate_to_current(&raw_after).unwrap();
24507 after.config_path = reloaded.config_path.clone();
24508 after.data_dir = reloaded.data_dir.clone();
24509 let store2 = crate::secrets::SecretStore::new(dir.path(), after.secrets.encrypt);
24510 after.decrypt_secrets(&store2).unwrap();
24511 assert_eq!(
24512 after
24513 .providers
24514 .models
24515 .anthropic
24516 .get("default")
24517 .and_then(|c| c.base.api_key.as_deref()),
24518 Some(original_secret),
24519 "original on-disk secret must survive an env-override + save cycle",
24520 );
24521 }
24522
24523 #[cfg(unix)]
24524 #[test]
24525 async fn onepassword_reference_survives_load_save_cycle() {
24526 let _env_guard = env_override_lock().await;
24527 let dir = TempDir::new().unwrap();
24528 let bin_dir = dir.path().join("bin");
24529 std::fs::create_dir_all(&bin_dir).unwrap();
24530 write_fake_op(
24531 &bin_dir,
24532 r#"#!/bin/sh
24533if [ "$1" = "read" ] && [ "$2" = "op://zeroclaw/provider/openai-api-key" ]; then
24534 printf '%s\n' 'sk-proj-from-onepassword'
24535 exit 0
24536fi
24537printf '%s\n' 'unexpected op invocation' >&2
24538exit 65
24539"#,
24540 );
24541 let path = match std::env::var_os("PATH") {
24542 Some(existing) if !existing.is_empty() => {
24543 format!("{}:{}", bin_dir.display(), existing.to_string_lossy())
24544 }
24545 _ => bin_dir.display().to_string(),
24546 };
24547 let _path_guard = EnvValueGuard::set("PATH", path);
24548 let _config_guard = EnvValueGuard::set("ZEROCLAW_CONFIG_DIR", dir.path());
24549 let _workspace_guard = EnvValueGuard::remove("ZEROCLAW_WORKSPACE");
24550
24551 let config_path = dir.path().join("config.toml");
24552 std::fs::write(
24553 &config_path,
24554 r#"
24555schema_version = 3
24556
24557[providers.models.openai.default]
24558model = "gpt-5"
24559api_key = "op://zeroclaw/provider/openai-api-key"
24560"#,
24561 )
24562 .unwrap();
24563
24564 let config = Config::load_or_init().await.unwrap();
24565 assert_eq!(
24566 config
24567 .providers
24568 .models
24569 .openai
24570 .get("default")
24571 .and_then(|entry| entry.base.api_key.as_deref()),
24572 Some("sk-proj-from-onepassword"),
24573 "runtime config uses resolved 1Password secret"
24574 );
24575
24576 config.save().await.unwrap();
24577 let raw_after = std::fs::read_to_string(&config_path).unwrap();
24578 assert!(
24579 raw_after.contains("op://zeroclaw/provider/openai-api-key"),
24580 "on-disk config must keep the 1Password reference: {raw_after}"
24581 );
24582 assert!(
24583 !raw_after.contains("sk-proj-from-onepassword"),
24584 "resolved secret must not be written back to disk: {raw_after}"
24585 );
24586 }
24587
24588 #[cfg(unix)]
24589 #[allow(
24590 clippy::disallowed_methods,
24591 reason = "test asserts Tokio worker responsiveness"
24592 )]
24593 #[test(flavor = "multi_thread", worker_threads = 1)]
24594 async fn onepassword_reference_load_does_not_block_runtime_worker() {
24595 let _env_guard = env_override_lock().await;
24596 let dir = TempDir::new().unwrap();
24597 let bin_dir = dir.path().join("bin");
24598 std::fs::create_dir_all(&bin_dir).unwrap();
24599 write_fake_op(
24600 &bin_dir,
24601 r#"#!/bin/sh
24602if [ "$1" = "read" ] && [ "$2" = "op://zeroclaw/provider/openai-api-key" ]; then
24603 sleep 1
24604 printf '%s\n' 'sk-proj-from-onepassword'
24605 exit 0
24606fi
24607exit 65
24608"#,
24609 );
24610 let path = match std::env::var_os("PATH") {
24611 Some(existing) if !existing.is_empty() => {
24612 format!("{}:{}", bin_dir.display(), existing.to_string_lossy())
24613 }
24614 _ => bin_dir.display().to_string(),
24615 };
24616 let _path_guard = EnvValueGuard::set("PATH", path);
24617 let _config_guard = EnvValueGuard::set("ZEROCLAW_CONFIG_DIR", dir.path());
24618 let _workspace_guard = EnvValueGuard::remove("ZEROCLAW_WORKSPACE");
24619
24620 let config_path = dir.path().join("config.toml");
24621 std::fs::write(
24622 &config_path,
24623 r#"
24624schema_version = 3
24625
24626[providers.models.openai.default]
24627model = "gpt-5"
24628api_key = "op://zeroclaw/provider/openai-api-key"
24629"#,
24630 )
24631 .unwrap();
24632
24633 let started = std::time::Instant::now();
24634 let load_task = tokio::spawn(Config::load_or_init());
24635 tokio::time::sleep(std::time::Duration::from_millis(50)).await;
24636
24637 assert!(
24638 started.elapsed() < std::time::Duration::from_millis(500),
24639 "op:// config load should not block the async runtime worker"
24640 );
24641
24642 let config = load_task.await.unwrap().unwrap();
24643 assert_eq!(
24644 config
24645 .providers
24646 .models
24647 .openai
24648 .get("default")
24649 .and_then(|entry| entry.base.api_key.as_deref()),
24650 Some("sk-proj-from-onepassword")
24651 );
24652 }
24653
24654 #[cfg(unix)]
24655 #[test]
24656 async fn dirty_onepassword_secret_edit_replaces_reference() {
24657 let _env_guard = env_override_lock().await;
24658 let dir = TempDir::new().unwrap();
24659 let bin_dir = dir.path().join("bin");
24660 std::fs::create_dir_all(&bin_dir).unwrap();
24661 write_fake_op(
24662 &bin_dir,
24663 r#"#!/bin/sh
24664printf '%s\n' 'sk-proj-from-onepassword'
24665"#,
24666 );
24667 let path = match std::env::var_os("PATH") {
24668 Some(existing) if !existing.is_empty() => {
24669 format!("{}:{}", bin_dir.display(), existing.to_string_lossy())
24670 }
24671 _ => bin_dir.display().to_string(),
24672 };
24673 let _path_guard = EnvValueGuard::set("PATH", path);
24674 let _config_guard = EnvValueGuard::set("ZEROCLAW_CONFIG_DIR", dir.path());
24675 let _workspace_guard = EnvValueGuard::remove("ZEROCLAW_WORKSPACE");
24676
24677 let config_path = dir.path().join("config.toml");
24678 std::fs::write(
24679 &config_path,
24680 r#"
24681schema_version = 3
24682
24683[providers.models.openai.default]
24684model = "gpt-5"
24685api_key = "op://zeroclaw/provider/openai-api-key"
24686"#,
24687 )
24688 .unwrap();
24689
24690 let mut config = Config::load_or_init().await.unwrap();
24691 config
24692 .set_prop_persistent(
24693 "providers.models.openai.default.api_key",
24694 "sk-proj-new-direct-key",
24695 )
24696 .unwrap();
24697 config.save_dirty().await.unwrap();
24698
24699 let raw_after = std::fs::read_to_string(&config_path).unwrap();
24700 assert!(
24701 !raw_after.contains("op://zeroclaw/provider/openai-api-key"),
24702 "dirty secret edits should replace the old 1Password reference: {raw_after}"
24703 );
24704 assert!(
24705 !raw_after.contains("sk-proj-new-direct-key"),
24706 "direct replacement should still be encrypted at rest: {raw_after}"
24707 );
24708
24709 let stored: Config = toml::from_str(&raw_after).unwrap();
24710 let encrypted = stored
24711 .providers
24712 .models
24713 .openai
24714 .get("default")
24715 .and_then(|entry| entry.base.api_key.as_deref())
24716 .unwrap();
24717 let store = crate::secrets::SecretStore::new(dir.path(), true);
24718 assert_eq!(store.decrypt(encrypted).unwrap(), "sk-proj-new-direct-key");
24719 }
24720
24721 #[test]
24722 async fn enum_variants_callback_returns_values() {
24723 let mx = test_matrix_config();
24724 let fields = mx.prop_fields();
24725 let stream_field = fields
24726 .iter()
24727 .find(|f| f.name == "channels.matrix.stream_mode")
24728 .unwrap();
24729 let variants = (stream_field.enum_variants.unwrap())();
24730 assert!(variants.contains(&"off".to_string()));
24731 assert!(variants.contains(&"partial".to_string()));
24732 assert!(variants.contains(&"multi_message".to_string()));
24733 }
24734
24735 #[test]
24736 async fn map_key_sections_discovers_per_family_provider_slots() {
24737 let sections = Config::map_key_sections();
24742 let anthropic = sections
24743 .iter()
24744 .find(|s| s.path == "providers.models.anthropic")
24745 .expect("providers.models.anthropic must be discoverable as a map-keyed section");
24746 assert_eq!(anthropic.kind, crate::traits::MapKeyKind::Map);
24747 assert_eq!(anthropic.value_type, "AnthropicModelProviderConfig");
24748
24749 assert!(
24751 sections.iter().any(|s| s.path == "agents"),
24752 "agents map should be discoverable"
24753 );
24754
24755 let mcp_servers = sections
24762 .iter()
24763 .find(|s| s.path == "mcp.servers")
24764 .expect("mcp.servers must be discoverable as a list-shaped section");
24765 assert_eq!(mcp_servers.kind, crate::traits::MapKeyKind::List);
24766 assert_eq!(mcp_servers.value_type, "McpServerConfig");
24767 }
24768
24769 #[test]
24770 async fn create_map_key_inserts_default_mcp_server() {
24771 let mut config = Config::default();
24775 assert!(config.mcp.servers.is_empty());
24776
24777 let created = config
24778 .create_map_key("mcp.servers", "github")
24779 .expect("mcp.servers should accept new list entries");
24780 assert!(created, "first add should report created=true");
24781 assert_eq!(config.mcp.servers.len(), 1);
24782 assert_eq!(
24783 config.mcp.servers[0].name, "github",
24784 "new entry must carry the supplied key as its name field"
24785 );
24786 }
24787
24788 #[test]
24789 async fn create_map_key_inserts_default_alias_under_typed_family() {
24790 let mut config = Config::default();
24793 assert!(
24794 !config
24795 .providers
24796 .models
24797 .contains_model_provider_type("anthropic")
24798 );
24799
24800 let created = config
24801 .create_map_key("providers.models.anthropic", "default")
24802 .expect("typed family slot should accept a new alias");
24803 assert!(created, "first add should report created=true");
24804 assert!(
24805 config
24806 .providers
24807 .models
24808 .find("anthropic", "default")
24809 .is_some(),
24810 "the new alias must show up under the typed family slot",
24811 );
24812
24813 let again = config
24815 .create_map_key("providers.models.anthropic", "default")
24816 .expect("second add still resolves the section");
24817 assert!(!again, "duplicate add should report created=false");
24818 }
24819
24820 #[test]
24821 async fn ensure_map_key_for_path_materializes_typed_provider_maps() {
24822 for (path, value) in [
24823 ("providers.models.openai.default.model", "gpt-4o"),
24824 ("providers.tts.openai.default.voice", "alloy"),
24825 ("providers.transcription.openai.default.model", "whisper-1"),
24826 ("channels.telegram.default.bot_token", "tok"),
24827 ] {
24828 let mut config = Config::default();
24829 assert!(
24830 config.set_prop(path, value).is_err(),
24831 "precondition: {path} is unknown on a fresh config"
24832 );
24833 config.ensure_map_key_for_path(path);
24834 assert!(
24835 config.set_prop(path, value).is_ok(),
24836 "{path} must be settable after ensure_map_key_for_path"
24837 );
24838 }
24839 }
24840
24841 #[test]
24842 async fn ensure_map_key_for_path_ignores_plain_fields() {
24843 let mut config = Config::default();
24844 config.ensure_map_key_for_path("gateway.port");
24845 config.ensure_map_key_for_path("locale");
24846 assert!(config.set_prop("gateway.port", "8080").is_ok());
24847 }
24848
24849 #[test]
24850 async fn create_map_key_rejects_unknown_section() {
24851 let mut config = Config::default();
24852 let err = config
24853 .create_map_key("not.a.real.section", "anything")
24854 .expect_err("unknown section path should error");
24855 assert!(err.contains("not.a.real.section"));
24856 }
24857
24858 #[test]
24859 async fn provider_slot_names_match_struct_fields() {
24860 let tts = toml::Value::try_from(crate::providers::TtsProviders {
24865 openai: std::iter::once(("a".to_string(), Default::default())).collect(),
24866 elevenlabs: std::iter::once(("a".to_string(), Default::default())).collect(),
24867 google: std::iter::once(("a".to_string(), Default::default())).collect(),
24868 edge: std::iter::once(("a".to_string(), Default::default())).collect(),
24869 piper: std::iter::once(("a".to_string(), Default::default())).collect(),
24870 })
24871 .unwrap();
24872 let mut tts_fields: Vec<&str> =
24873 tts.as_table().unwrap().keys().map(String::as_str).collect();
24874 tts_fields.sort_unstable();
24875 let mut tts_slots = crate::providers::TtsProviders::slot_names().to_vec();
24876 tts_slots.sort_unstable();
24877 assert_eq!(tts_fields, tts_slots);
24878
24879 let tr = toml::Value::try_from(crate::providers::TranscriptionProviders {
24880 groq: std::iter::once(("a".to_string(), Default::default())).collect(),
24881 openai: std::iter::once(("a".to_string(), Default::default())).collect(),
24882 deepgram: std::iter::once(("a".to_string(), Default::default())).collect(),
24883 assemblyai: std::iter::once(("a".to_string(), Default::default())).collect(),
24884 google: std::iter::once(("a".to_string(), Default::default())).collect(),
24885 local_whisper: std::iter::once(("a".to_string(), Default::default())).collect(),
24886 })
24887 .unwrap();
24888 let mut tr_fields: Vec<&str> = tr.as_table().unwrap().keys().map(String::as_str).collect();
24889 tr_fields.sort_unstable();
24890 let mut tr_slots = crate::providers::TranscriptionProviders::slot_names().to_vec();
24891 tr_slots.sort_unstable();
24892 assert_eq!(tr_fields, tr_slots);
24893 }
24894
24895 #[test]
24896 async fn unknown_provider_families_flags_silent_serde_drop() {
24897 let raw = r#"
24901schema_version = 3
24902
24903[providers.models.antropic.main]
24904model = "claude-sonnet-4-6"
24905
24906[providers.models.openai.work]
24907model = "gpt-4o"
24908"#;
24909 let parsed: Config = toml::from_str(raw).expect("unknown family must not fail parse");
24910 assert!(
24911 parsed.providers.models.find("antropic", "main").is_none(),
24912 "precondition: serde silently drops the unknown family"
24913 );
24914 assert_eq!(
24915 Config::unknown_provider_families(raw),
24916 vec!["models.antropic".to_string()]
24917 );
24918 assert_eq!(
24919 Config::unknown_provider_families(
24920 "schema_version = 3\n[providers.tts.bogustts.x]\nenabled = true\n",
24921 ),
24922 vec!["tts.bogustts".to_string()]
24923 );
24924 assert!(Config::unknown_provider_families("not even toml {{{").is_empty());
24925 assert!(Config::unknown_provider_families("providers = 3\n").is_empty());
24929 assert!(Config::unknown_provider_families("[providers]\nmodels = 3\n").is_empty());
24930 assert_eq!(
24931 Config::unknown_provider_families("[[providers.models.weird]]\nx = 1\n"),
24932 vec!["models.weird".to_string()],
24933 "array-of-tables under an unknown family is still an unknown family"
24934 );
24935 }
24936
24937 #[test]
24938 async fn map_key_create_survives_incremental_save() {
24939 let tmp = tempfile::TempDir::new().unwrap();
24945 let config_path = tmp.path().join("config.toml");
24946
24947 std::fs::write(
24950 &config_path,
24951 "schema_version = 9\n\n[observability]\nbackend = \"none\"\n",
24952 )
24953 .unwrap();
24954
24955 let mut config = Config {
24956 config_path: config_path.clone(),
24957 ..Default::default()
24958 };
24959 let created = config
24960 .create_map_key("providers.models.openai", "myalias")
24961 .expect("typed family slot accepts a new alias");
24962 assert!(created);
24963 config.mark_dirty("providers.models.openai.myalias");
24964 config.save_dirty().await.unwrap();
24965
24966 let written = std::fs::read_to_string(&config_path).unwrap();
24967 let reloaded: Config = toml::from_str(&written)
24968 .unwrap_or_else(|e| panic!("rewritten config must reparse: {e}\n---\n{written}"));
24969 assert!(
24970 reloaded
24971 .providers
24972 .models
24973 .find("openai", "myalias")
24974 .is_some(),
24975 "created alias must survive save_dirty + reload; got:\n{written}"
24976 );
24977 }
24978
24979 #[test]
24980 async fn init_defaults_instantiates_none_sections() {
24981 let mut config = Config::default();
24982 assert!(config.channels.matrix.is_empty());
24983
24984 config
24987 .create_map_key("channels.matrix", "default")
24988 .expect("create_map_key should insert a default matrix entry");
24989 assert!(
24990 config.channels.matrix.contains_key("default"),
24991 "create_map_key must add the 'default' alias"
24992 );
24993
24994 let initialized = config.init_defaults(Some("channels.matrix"));
24996 assert!(
24997 !initialized.contains(&"channels.matrix"),
24998 "init_defaults should not report channels.matrix when entry already exists"
24999 );
25000 }
25001
25002 #[test]
25003 async fn deserialized_matrix_set_prop_round_trips_vec_string() {
25004 let toml_src = r#"
25008schema_version = 3
25009
25010[channels.matrix.default]
25011enabled = false
25012homeserver = ""
25013access_token = ""
25014allowed_rooms = []
25015allowed_users = []
25016"#;
25017 let mut config: Config = toml::from_str(toml_src).expect("parse toml");
25018 assert!(
25019 config.channels.matrix.contains_key("default"),
25020 "matrix must have a 'default' alias after deserialize"
25021 );
25022
25023 config
25024 .set_prop(
25025 "channels.matrix.default.allowed_rooms",
25026 r#"["alice","bob"]"#,
25027 )
25028 .expect("set_prop should succeed against deserialized matrix");
25029 assert_eq!(
25030 config.channels.matrix.get("default").unwrap().allowed_rooms,
25031 vec!["alice".to_string(), "bob".to_string()],
25032 );
25033 }
25034
25035 #[test]
25036 async fn init_defaults_then_set_prop_round_trips_vec_string() {
25037 let mut config = Config::default();
25043 config
25044 .create_map_key("channels.matrix", "default")
25045 .expect("create_map_key should insert a default matrix entry");
25046 assert!(config.channels.matrix.contains_key("default"));
25047
25048 let has_field = config
25050 .prop_fields()
25051 .iter()
25052 .any(|f| f.name == "channels.matrix.default.allowed_rooms");
25053 assert!(
25054 has_field,
25055 "channels.matrix.default.allowed_rooms must appear in prop_fields after init"
25056 );
25057
25058 config
25060 .set_prop(
25061 "channels.matrix.default.allowed_rooms",
25062 r#"["alice","bob"]"#,
25063 )
25064 .expect("set_prop should accept JSON-array string for Vec<String>");
25065 assert_eq!(
25066 config.channels.matrix.get("default").unwrap().allowed_rooms,
25067 vec!["alice".to_string(), "bob".to_string()],
25068 );
25069 }
25070
25071 #[test]
25072 async fn mcp_servers_addable_via_create_map_key_and_per_entry_props() {
25073 let mut config = Config::default();
25085
25086 let sections = Config::map_key_sections();
25088 assert!(
25089 sections
25090 .iter()
25091 .any(|s| s.path == "mcp.servers" && s.kind == crate::traits::MapKeyKind::List),
25092 "mcp.servers should surface as a List section in map_key_sections()"
25093 );
25094
25095 config
25098 .create_map_key("mcp.servers", "fs")
25099 .expect("mcp.servers should accept new list entries via create_map_key");
25100 assert_eq!(config.mcp.servers.len(), 1);
25101 assert_eq!(config.mcp.servers[0].name, "fs");
25102
25103 }
25111
25112 #[test]
25113 async fn init_defaults_skips_already_set() {
25114 let mut config = Config::default();
25115 config
25116 .channels
25117 .matrix
25118 .insert("default".to_string(), test_matrix_config());
25119
25120 let initialized = config.init_defaults(Some("channels.matrix"));
25121 assert!(!initialized.contains(&"channels.matrix"));
25123 assert_eq!(
25125 config.channels.matrix.get("default").unwrap().homeserver,
25126 "https://m.org"
25127 );
25128 }
25129
25130 #[test]
25131 async fn nested_get_set_prop_traverses_config_tree() {
25132 let mut config = Config::default();
25133 config
25134 .channels
25135 .matrix
25136 .insert("default".to_string(), test_matrix_config());
25137
25138 assert_eq!(
25140 config
25141 .get_prop("channels.matrix.default.homeserver")
25142 .unwrap(),
25143 "https://m.org"
25144 );
25145
25146 config
25148 .set_prop("channels.matrix.default.homeserver", "https://new.org")
25149 .unwrap();
25150 assert_eq!(
25151 config.channels.matrix.get("default").unwrap().homeserver,
25152 "https://new.org"
25153 );
25154 }
25155
25156 #[test]
25157 async fn hashmap_nested_encrypt_decrypt_traverses_values() {
25158 let dir = TempDir::new().unwrap();
25159 let store = crate::secrets::SecretStore::new(dir.path(), true);
25160
25161 let mut config = Config::default();
25162 config.providers.models.openrouter.insert(
25163 "test".into(),
25164 crate::schema::OpenRouterModelProviderConfig {
25165 base: ModelProviderConfig {
25166 api_key: Some("secret-key".into()),
25167 ..Default::default()
25168 },
25169 },
25170 );
25171
25172 config.encrypt_secrets(&store).unwrap();
25173 let encrypted_key = config
25174 .providers
25175 .models
25176 .find("openrouter", "test")
25177 .expect("entry exists")
25178 .api_key
25179 .as_ref()
25180 .unwrap();
25181 assert!(crate::secrets::SecretStore::is_encrypted(encrypted_key));
25182
25183 config.decrypt_secrets(&store).unwrap();
25184 assert_eq!(
25185 config
25186 .providers
25187 .models
25188 .find("openrouter", "test")
25189 .expect("entry exists")
25190 .api_key
25191 .as_deref(),
25192 Some("secret-key")
25193 );
25194 }
25195
25196 #[test]
25197 async fn vec_secret_encrypt_decrypt_traverses_elements() {
25198 let dir = TempDir::new().unwrap();
25199 let store = crate::secrets::SecretStore::new(dir.path(), true);
25200
25201 let mut config = Config::default();
25202 config.gateway.paired_tokens = vec!["token-a".into(), "token-b".into()];
25203
25204 config.encrypt_secrets(&store).unwrap();
25205 for token in &config.gateway.paired_tokens {
25206 assert!(crate::secrets::SecretStore::is_encrypted(token));
25207 }
25208
25209 config.decrypt_secrets(&store).unwrap();
25210 assert_eq!(config.gateway.paired_tokens, vec!["token-a", "token-b"]);
25211 }
25212
25213 #[test]
25216 async fn every_prop_is_gettable_and_settable() {
25217 let mut config = Config::default();
25218 config.init_defaults(None);
25220
25221 let fields = config.prop_fields();
25222 assert!(
25223 fields.len() > 50,
25224 "Expected 50+ props, got {} — macro may be skipping fields",
25225 fields.len()
25226 );
25227
25228 for field in &fields {
25229 let get_result = config.get_prop(&field.name);
25231 assert!(
25232 get_result.is_ok(),
25233 "get_prop failed for '{}': {}",
25234 field.name,
25235 get_result.unwrap_err()
25236 );
25237
25238 if field.is_secret
25241 || field.is_enum()
25242 || field.display_value == crate::traits::UNSET_DISPLAY
25243 {
25244 continue;
25245 }
25246
25247 let set_result = config.set_prop(&field.name, &field.display_value);
25248 assert!(
25249 set_result.is_ok(),
25250 "set_prop failed for '{}' with value '{}': {}",
25251 field.name,
25252 field.display_value,
25253 set_result.unwrap_err()
25254 );
25255
25256 let after = config.get_prop(&field.name).unwrap();
25258 assert_eq!(
25259 after, field.display_value,
25260 "round-trip mismatch for '{}': set '{}', got '{}'",
25261 field.name, field.display_value, after
25262 );
25263 }
25264 }
25265
25266 #[test]
25278 async fn every_prop_field_path_is_reachable_via_get_prop() {
25279 let mut config = Config::default();
25280 config.init_defaults(None);
25281 for field in config.prop_fields() {
25282 let result = config.get_prop(&field.name);
25283 assert!(
25284 result.is_ok(),
25285 "get_prop('{}') failed: {} \u{2014} prop_fields() advertises a path \
25286 that the CLI / gateway / TUI all expect to be readable. \
25287 Either the macro emits the path but routing is missing, \
25288 or the field shouldn't be in prop_fields().",
25289 field.name,
25290 result.unwrap_err()
25291 );
25292 }
25293 }
25294
25295 #[test]
25300 async fn credential_shaped_prop_fields_have_explicit_classification() {
25301 let mut config = Config::default();
25302 config.init_defaults(None);
25303 config
25304 .providers
25305 .models
25306 .anthropic
25307 .insert("default".into(), AnthropicModelProviderConfig::default());
25308 config
25309 .providers
25310 .tts
25311 .openai
25312 .insert("default".into(), OpenAITtsProviderConfig::default());
25313 config.providers.transcription.openai.insert(
25314 "default".into(),
25315 OpenAiTranscriptionProviderConfig::default(),
25316 );
25317 config.providers.transcription.local_whisper.insert(
25318 "default".into(),
25319 LocalWhisperTranscriptionProviderConfig::default(),
25320 );
25321 config
25322 .channels
25323 .matrix
25324 .insert("default".into(), MatrixConfig::default());
25325 config
25326 .storage
25327 .qdrant
25328 .insert("default".into(), QdrantStorageConfig::default());
25329
25330 let fields = config.prop_fields();
25331 let missing: Vec<_> = fields
25332 .iter()
25333 .filter(|field| credential_shaped_prop_path(&field.name))
25334 .filter(|field| field.credential_class.is_none())
25335 .map(|field| field.name.clone())
25336 .collect();
25337
25338 assert!(
25339 missing.is_empty(),
25340 "credential-shaped config fields need explicit classification: {missing:?}"
25341 );
25342
25343 let unmarked_secrets: Vec<_> = fields
25344 .iter()
25345 .filter(|field| {
25346 field.credential_class
25347 == Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
25348 })
25349 .filter(|field| !field.is_secret && !Config::prop_is_secret(&field.name))
25350 .map(|field| field.name.clone())
25351 .collect();
25352
25353 assert!(
25354 unmarked_secrets.is_empty(),
25355 "EncryptedSecret classifications must route through #[secret]: {unmarked_secrets:?}"
25356 );
25357 }
25358
25359 #[test]
25360 async fn prop_fields_carry_credential_classification_from_schema_fields() {
25361 let mut config = Config::default();
25362 config.init_defaults(None);
25363 config.providers.models.openai.insert(
25364 "codex".into(),
25365 OpenAIModelProviderConfig {
25366 base: ModelProviderConfig {
25367 requires_openai_auth: true,
25368 ..ModelProviderConfig::default()
25369 },
25370 },
25371 );
25372 config
25373 .providers
25374 .tts
25375 .openai
25376 .insert("default".into(), OpenAITtsProviderConfig::default());
25377 config.providers.transcription.local_whisper.insert(
25378 "default".into(),
25379 LocalWhisperTranscriptionProviderConfig::default(),
25380 );
25381 config
25382 .channels
25383 .matrix
25384 .insert("default".into(), MatrixConfig::default());
25385
25386 let fields = config.prop_fields();
25387 let class_for = |name: &str| {
25388 fields
25389 .iter()
25390 .find(|field| field.name == name)
25391 .and_then(|field| field.credential_class)
25392 };
25393
25394 assert_eq!(
25395 class_for("providers.models.openai.codex.requires_openai_auth"),
25396 Some(crate::config::CredentialSurfaceClass::ExternalAuthStore)
25397 );
25398 assert_eq!(
25399 class_for("providers.tts.openai.default.api_key"),
25400 Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
25401 );
25402 assert_eq!(
25403 class_for("providers.transcription.local_whisper.default.bearer_token"),
25404 Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
25405 );
25406 assert_eq!(
25407 class_for("channels.matrix.default.access_token"),
25408 Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
25409 );
25410 assert_eq!(
25411 class_for("model_routes"),
25412 Some(crate::config::CredentialSurfaceClass::RequiresFollowUp)
25413 );
25414 assert_eq!(
25415 class_for("embedding_routes"),
25416 Some(crate::config::CredentialSurfaceClass::RequiresFollowUp)
25417 );
25418 assert!(Config::prop_is_secret(
25419 "providers.tts.openai.default.api_key"
25420 ));
25421 assert!(Config::prop_is_secret(
25422 "providers.transcription.local_whisper.default.bearer_token"
25423 ));
25424 assert!(Config::prop_is_secret(
25425 "channels.matrix.default.access_token"
25426 ));
25427 }
25428
25429 fn credential_shaped_prop_path(path: &str) -> bool {
25430 path.split('.').any(|part| {
25431 let normalized = part.replace('_', "-");
25432 let has_term = |needle| normalized.split('-').any(|term| term == needle);
25433 normalized.contains("api-key")
25434 || normalized.contains("api-token")
25435 || normalized.contains("auth-file")
25436 || normalized.contains("auth-header")
25437 || normalized.contains("auth-token")
25438 || normalized.contains("bearer-token")
25439 || normalized.contains("bot-token")
25440 || normalized.contains("access-token")
25441 || normalized.contains("refresh-token")
25442 || normalized.contains("verification-token")
25443 || normalized.contains("paired-tokens")
25444 || part == "token"
25445 || has_term("credential")
25446 || has_term("env")
25447 || has_term("header")
25448 || has_term("headers")
25449 || has_term("password")
25450 || has_term("secret")
25451 })
25452 }
25453
25454 #[test]
25455 async fn object_array_prop_display_redacts_nested_secret_fields() {
25456 let fixture = ObjectArraySecretFixture {
25457 entries: vec![
25458 ObjectArraySecretEntry {
25459 name: "primary".to_string(),
25460 token: Some("nested-token-credential".to_string()),
25461 headers: HashMap::from([
25462 (
25463 "Authorization".to_string(),
25464 "Bearer nested-header-credential".to_string(),
25465 ),
25466 ("X-Tenant".to_string(), "tenant-credential".to_string()),
25467 ]),
25468 },
25469 ObjectArraySecretEntry {
25470 name: "unset-secret".to_string(),
25471 token: None,
25472 headers: HashMap::new(),
25473 },
25474 ],
25475 };
25476
25477 let display_value = fixture
25478 .prop_fields()
25479 .into_iter()
25480 .find(|field| field.name == "test.object_array.entries")
25481 .expect("object-array field should be surfaced")
25482 .display_value;
25483 let readback = fixture
25484 .get_prop("test.object_array.entries")
25485 .expect("object-array field should be readable");
25486
25487 for rendered in [&display_value, &readback] {
25488 assert!(
25489 !rendered.contains("nested-token-credential"),
25490 "object-array display/readback must redact scalar nested secrets: {rendered}"
25491 );
25492 assert!(
25493 !rendered.contains("Bearer nested-header-credential"),
25494 "object-array display/readback must redact nested secret map values: {rendered}"
25495 );
25496 assert!(
25497 !rendered.contains("tenant-credential"),
25498 "object-array display/readback must redact every value in nested secret maps: {rendered}"
25499 );
25500 assert!(
25501 rendered.contains("primary"),
25502 "non-secret object-array fields should remain visible: {rendered}"
25503 );
25504 assert!(
25505 rendered.contains("unset-secret"),
25506 "non-secret fields on entries with unset secrets should remain visible: {rendered}"
25507 );
25508 assert!(
25509 rendered.contains("****"),
25510 "redacted object-array output should show masked placeholders: {rendered}"
25511 );
25512 }
25513
25514 assert!(
25515 display_value.contains(r#""token":null"#),
25516 "JSON display should preserve unset optional secrets as null, not a populated mask: {display_value}"
25517 );
25518 }
25519
25520 #[test]
25521 async fn onboard_state_prop_path_uses_top_level_kebab_field_name() {
25522 let mut config = Config::default();
25523
25524 config
25525 .set_prop("onboard_state.completed_sections", "agents")
25526 .expect("onboard state marker path should be writable");
25527 assert_eq!(
25528 config
25529 .get_prop("onboard_state.completed_sections")
25530 .expect("onboard state marker path should be readable"),
25531 "[\"agents\"]"
25532 );
25533 }
25534
25535 #[test]
25540 async fn onboard_state_quickstart_completed_round_trips() {
25541 let mut config = Config::default();
25542
25543 assert_eq!(
25544 config
25545 .get_prop("onboard_state.quickstart_completed")
25546 .expect("default quickstart-completed should be readable"),
25547 "false",
25548 "fresh configs default to quickstart-completed=false so the \
25549 Quickstart auto-opens on first launch",
25550 );
25551
25552 config
25553 .set_prop("onboard_state.quickstart_completed", "true")
25554 .expect("quickstart-completed should be writable via prop path");
25555 assert_eq!(
25556 config
25557 .get_prop("onboard_state.quickstart_completed")
25558 .expect("quickstart-completed should be readable after set"),
25559 "true"
25560 );
25561 }
25562
25563 #[test]
25564 async fn per_agent_nested_prop_fields_use_agent_alias_paths() {
25565 let mut config = Config::default();
25566 config
25567 .agents
25568 .insert("bob".to_string(), AliasedAgentConfig::default());
25569 config.runtime_profiles.insert(
25570 "fast".to_string(),
25571 crate::schema::RuntimeProfileConfig::default(),
25572 );
25573
25574 let fields = config.prop_fields();
25575 assert!(
25576 fields
25577 .iter()
25578 .any(|field| field.name == "runtime_profiles.fast.history_pruning.enabled"),
25579 "history-pruning is a runtime-profile field, emitted under the profile alias"
25580 );
25581 assert!(
25582 !fields
25583 .iter()
25584 .any(|field| field.name.starts_with("agents.bob.history_pruning")),
25585 "history-pruning must no longer be settable on the agent"
25586 );
25587
25588 config
25589 .set_prop("runtime_profiles.fast.history_pruning.enabled", "true")
25590 .expect("set_prop should accept the runtime-profile nested path");
25591 assert_eq!(
25592 config
25593 .get_prop("runtime_profiles.fast.history_pruning.enabled")
25594 .expect("get_prop should accept the runtime-profile nested path"),
25595 "true"
25596 );
25597 }
25598
25599 #[test]
25606 async fn every_scalar_prop_round_trips_through_set_prop() {
25607 let mut config = Config::default();
25608 config.init_defaults(None);
25609 let fields = config.prop_fields();
25610 for field in &fields {
25611 if field.is_secret
25612 || matches!(
25613 field.kind,
25614 crate::config::PropKind::StringArray | crate::config::PropKind::ObjectArray
25615 )
25616 {
25617 continue;
25618 }
25619 let value = match config.get_prop(&field.name) {
25620 Ok(v) => v,
25621 Err(_) => continue,
25622 };
25623 if value == crate::traits::UNSET_DISPLAY {
25625 continue;
25626 }
25627 let result = config.set_prop(&field.name, &value);
25628 assert!(
25629 result.is_ok(),
25630 "round-trip set_prop('{}', '{}') failed: {}",
25631 field.name,
25632 value,
25633 result.unwrap_err()
25634 );
25635 }
25636 }
25637
25638 #[test]
25641 async fn every_enum_variant_is_settable() {
25642 let mut config = Config::default();
25643 config.init_defaults(None);
25644
25645 for field in config.prop_fields() {
25646 if !field.is_enum() {
25647 continue;
25648 }
25649 let get_variants = field.enum_variants.unwrap_or_else(|| {
25650 panic!("enum field '{}' has no enum_variants callback", field.name)
25651 });
25652 let variants = get_variants();
25653 assert!(
25654 !variants.is_empty(),
25655 "enum field '{}' returned no variants",
25656 field.name
25657 );
25658
25659 for variant in &variants {
25660 let result = config.set_prop(&field.name, variant);
25661 assert!(
25662 result.is_ok(),
25663 "set_prop('{}', '{}') failed: {}",
25664 field.name,
25665 variant,
25666 result.unwrap_err()
25667 );
25668 }
25669 }
25670 }
25671
25672 #[test]
25673 async fn channel_approval_timeout_secs_defaults_to_300() {
25674 let discord: DiscordConfig = serde_json::from_str(r#"{"bot_token":"tok"}"#).unwrap();
25675 assert_eq!(discord.approval_timeout_secs, 300);
25676
25677 let slack: SlackConfig = serde_json::from_str(r#"{"bot_token":"tok"}"#).unwrap();
25678 assert_eq!(slack.approval_timeout_secs, 300);
25679
25680 let signal: SignalConfig =
25681 serde_json::from_str(r#"{"http_url":"http://localhost","account":"+1"}"#).unwrap();
25682 assert_eq!(signal.approval_timeout_secs, 300);
25683
25684 let matrix: MatrixConfig = serde_json::from_str(
25685 r#"{"homeserver":"https://matrix.org","access_token":"tok","allowed_users":[]}"#,
25686 )
25687 .unwrap();
25688 assert_eq!(matrix.approval_timeout_secs, 300);
25689
25690 let whatsapp: WhatsAppConfig = serde_json::from_str(r#"{}"#).unwrap();
25691 assert_eq!(whatsapp.approval_timeout_secs, 300);
25692 }
25693
25694 #[test]
25695 async fn channel_approval_timeout_secs_explicit_override() {
25696 let discord: DiscordConfig =
25697 serde_json::from_str(r#"{"bot_token":"tok","approval_timeout_secs":60}"#).unwrap();
25698 assert_eq!(discord.approval_timeout_secs, 60);
25699
25700 let slack: SlackConfig =
25701 serde_json::from_str(r#"{"bot_token":"tok","approval_timeout_secs":120}"#).unwrap();
25702 assert_eq!(slack.approval_timeout_secs, 120);
25703
25704 let signal: SignalConfig = serde_json::from_str(
25705 r#"{"http_url":"http://localhost","account":"+1","approval_timeout_secs":90}"#,
25706 )
25707 .unwrap();
25708 assert_eq!(signal.approval_timeout_secs, 90);
25709
25710 let matrix: MatrixConfig = serde_json::from_str(
25711 r#"{"homeserver":"https://matrix.org","access_token":"tok","allowed_users":[],"approval_timeout_secs":45}"#,
25712 )
25713 .unwrap();
25714 assert_eq!(matrix.approval_timeout_secs, 45);
25715
25716 let whatsapp: WhatsAppConfig =
25717 serde_json::from_str(r#"{"approval_timeout_secs":180}"#).unwrap();
25718 assert_eq!(whatsapp.approval_timeout_secs, 180);
25719 }
25720
25721 fn multi_agent_test_config() -> Config {
25727 use crate::providers::ChannelRef;
25728
25729 let mut config = Config::default();
25730
25731 config
25733 .risk_profiles
25734 .insert("default".to_string(), RiskProfileConfig::default());
25735
25736 config.providers.models.anthropic.insert(
25738 "default".to_string(),
25739 AnthropicModelProviderConfig::default(),
25740 );
25741
25742 config
25746 .channels
25747 .telegram
25748 .insert("draft".to_string(), TelegramConfig::default());
25749
25750 let agent = AliasedAgentConfig {
25753 channels: vec![ChannelRef::new("telegram.draft")],
25754 model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
25755 risk_profile: "default".to_string(),
25756 ..AliasedAgentConfig::default()
25757 };
25758 config.agents.insert("alpha".to_string(), agent);
25759
25760 config
25761 }
25762
25763 #[test]
25764 async fn validate_rejects_workspace_access_self_reference() {
25765 let mut config = multi_agent_test_config();
25766 let alpha = config.agents.get_mut("alpha").unwrap();
25767 alpha.workspace.access.insert(
25768 crate::multi_agent::AgentAlias::new("alpha"),
25769 crate::multi_agent::AccessMode::Read,
25770 );
25771 let err = config
25772 .validate()
25773 .expect_err("self-reference must fail validation");
25774 let msg = err.to_string();
25775 assert!(
25776 msg.contains("agents.alpha.workspace.access.alpha"),
25777 "expected field path in error, got: {msg}"
25778 );
25779 assert!(
25780 msg.contains("self-references"),
25781 "expected self-reference explanation, got: {msg}"
25782 );
25783 }
25784
25785 #[test]
25786 async fn validate_rejects_workspace_access_dangling_target() {
25787 let mut config = multi_agent_test_config();
25788 let alpha = config.agents.get_mut("alpha").unwrap();
25789 alpha.workspace.access.insert(
25790 crate::multi_agent::AgentAlias::new("ghost"),
25791 crate::multi_agent::AccessMode::ReadWrite,
25792 );
25793 let err = config
25794 .validate()
25795 .expect_err("dangling target must fail validation");
25796 let msg = err.to_string();
25797 assert!(
25798 msg.contains("agents.ghost is not configured"),
25799 "expected dangling-ref explanation, got: {msg}"
25800 );
25801 }
25802
25803 #[test]
25804 async fn validate_rejects_read_memory_from_self_reference() {
25805 let mut config = multi_agent_test_config();
25806 let alpha = config.agents.get_mut("alpha").unwrap();
25807 alpha
25808 .workspace
25809 .read_memory_from
25810 .push(crate::multi_agent::AgentAlias::new("alpha"));
25811 let err = config
25812 .validate()
25813 .expect_err("self-reference must fail validation");
25814 assert!(
25815 err.to_string().contains("read_memory_from[0]"),
25816 "expected indexed field path, got: {err}"
25817 );
25818 }
25819
25820 #[test]
25821 async fn validate_rejects_read_memory_from_cross_backend() {
25822 let mut config = multi_agent_test_config();
25823
25824 let beta = AliasedAgentConfig {
25826 channels: vec![crate::providers::ChannelRef::new("telegram.draft")],
25827 model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
25828 risk_profile: "default".to_string(),
25829 memory: crate::multi_agent::AgentMemoryConfig {
25830 backend: crate::multi_agent::MemoryBackendKind::Postgres,
25831 },
25832 ..AliasedAgentConfig::default()
25833 };
25834 config.agents.insert("beta".to_string(), beta);
25835
25836 let alpha = config.agents.get_mut("alpha").unwrap();
25838 alpha
25839 .workspace
25840 .read_memory_from
25841 .push(crate::multi_agent::AgentAlias::new("beta"));
25842
25843 let err = config
25844 .validate()
25845 .expect_err("cross-backend allowlist must fail validation");
25846 let msg = err.to_string();
25847 assert!(
25848 msg.contains("same-backend siblings only"),
25849 "expected cross-backend explanation, got: {msg}"
25850 );
25851 }
25852
25853 #[test]
25854 async fn validate_rejects_peer_group_dangling_member() {
25855 let mut config = multi_agent_test_config();
25856 let group = crate::multi_agent::PeerGroupConfig {
25857 channel: "telegram".to_string(),
25858 agents: vec![
25859 crate::multi_agent::AgentAlias::new("alpha"),
25860 crate::multi_agent::AgentAlias::new("ghost"),
25861 ],
25862 ..crate::multi_agent::PeerGroupConfig::default()
25863 };
25864 config.peer_groups.insert("team_chat".to_string(), group);
25865 let err = config
25866 .validate()
25867 .expect_err("dangling group member must fail validation");
25868 assert!(
25869 err.to_string().contains("peer_groups.team_chat.agents[1]"),
25870 "expected indexed field path, got: {err}"
25871 );
25872 }
25873
25874 #[test]
25875 async fn validate_rejects_peer_group_member_without_channel() {
25876 let mut config = multi_agent_test_config();
25877
25878 config
25880 .channels
25881 .discord
25882 .insert("ops".to_string(), DiscordConfig::default());
25883 let beta = AliasedAgentConfig {
25884 channels: vec![crate::providers::ChannelRef::new("discord.ops")],
25885 model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
25886 risk_profile: "default".to_string(),
25887 ..AliasedAgentConfig::default()
25888 };
25889 config.agents.insert("beta".to_string(), beta);
25890
25891 let group = crate::multi_agent::PeerGroupConfig {
25893 channel: "telegram".to_string(),
25894 agents: vec![
25895 crate::multi_agent::AgentAlias::new("alpha"),
25896 crate::multi_agent::AgentAlias::new("beta"),
25897 ],
25898 ..crate::multi_agent::PeerGroupConfig::default()
25899 };
25900 config.peer_groups.insert("team_chat".to_string(), group);
25901
25902 let err = config
25903 .validate()
25904 .expect_err("channel-mismatch group member must fail validation");
25905 let msg = err.to_string();
25906 assert!(
25907 msg.contains("agents.beta.channels has no entry of type"),
25908 "expected channel-mismatch explanation, got: {msg}"
25909 );
25910 }
25911
25912 #[test]
25913 async fn validate_accepts_valid_peer_group_with_two_compatible_members() {
25914 let mut config = multi_agent_test_config();
25915
25916 let beta = AliasedAgentConfig {
25918 channels: vec![crate::providers::ChannelRef::new("telegram.draft")],
25919 model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
25920 risk_profile: "default".to_string(),
25921 ..AliasedAgentConfig::default()
25922 };
25923 config.agents.insert("beta".to_string(), beta);
25924
25925 let group = crate::multi_agent::PeerGroupConfig {
25927 channel: "telegram".to_string(),
25928 agents: vec![
25929 crate::multi_agent::AgentAlias::new("alpha"),
25930 crate::multi_agent::AgentAlias::new("beta"),
25931 ],
25932 ..crate::multi_agent::PeerGroupConfig::default()
25933 };
25934 config.peer_groups.insert("team_chat".to_string(), group);
25935
25936 config
25937 .validate()
25938 .expect("two-member same-channel peer group must validate cleanly");
25939 }
25940
25941 #[test]
25942 async fn config_validate_rejects_classifier_provider_pointing_at_missing_alias() {
25943 let toml = r#"
25946 [providers.models.custom.default]
25947 api_key = "k"
25948 model = "qwen3.6-plus"
25949 uri = "https://example.com/v1"
25950 wire_api = "chat_completions"
25951
25952 [risk_profiles.default]
25953 level = "supervised"
25954
25955 [agents.default]
25956 enabled = true
25957 model_provider = "custom.default"
25958 risk_profile = "default"
25959 classifier_provider = "custom.does-not-exist"
25960 "#;
25961 let cfg: Config = toml::from_str(toml).unwrap();
25962 let err = cfg
25963 .validate()
25964 .expect_err("missing alias must fail validate");
25965 let msg = format!("{err:#}");
25966 assert!(
25967 msg.contains("classifier_provider")
25968 && msg.contains("does-not-exist")
25969 && msg.contains("providers.models.custom.does-not-exist is not configured"),
25970 "expected DanglingReference error mentioning field + alias + section, got: {msg}"
25971 );
25972 }
25973
25974 #[test]
25975 async fn config_validate_accepts_classifier_provider_pointing_at_existing_alias() {
25976 let toml = r#"
25977 [providers.models.custom.default]
25978 api_key = "k1"
25979 model = "qwen3.6-plus"
25980 uri = "https://example.com/v1"
25981 wire_api = "chat_completions"
25982
25983 [providers.models.custom.kimi-k2-5]
25984 api_key = "k2"
25985 model = "kimi-k2.5"
25986 uri = "https://example.com/v1"
25987 wire_api = "chat_completions"
25988
25989 [risk_profiles.default]
25990 level = "supervised"
25991
25992 [agents.default]
25993 enabled = true
25994 model_provider = "custom.default"
25995 risk_profile = "default"
25996 classifier_provider = "custom.kimi-k2-5"
25997 "#;
25998 let cfg: Config = toml::from_str(toml).unwrap();
25999 cfg.validate()
26000 .expect("validate must succeed for resolvable ref");
26001 assert_eq!(
26002 cfg.agents
26003 .get("default")
26004 .unwrap()
26005 .classifier_provider
26006 .as_str(),
26007 "custom.kimi-k2-5"
26008 );
26009 }
26010
26011 #[test]
26012 async fn config_validate_accepts_empty_classifier_provider_as_inheritance_signal() {
26013 let toml = r#"
26016 [providers.models.custom.default]
26017 api_key = "k"
26018 model = "qwen3.6-plus"
26019 uri = "https://example.com/v1"
26020 wire_api = "chat_completions"
26021
26022 [risk_profiles.default]
26023 level = "supervised"
26024
26025 [agents.default]
26026 enabled = true
26027 model_provider = "custom.default"
26028 risk_profile = "default"
26029 "#;
26030 let cfg: Config = toml::from_str(toml).unwrap();
26031 cfg.validate()
26032 .expect("missing classifier_provider must validate");
26033 assert!(
26034 cfg.agents
26035 .get("default")
26036 .unwrap()
26037 .classifier_provider
26038 .is_empty()
26039 );
26040 }
26041
26042 fn provider_entry_with_fallback(fallback: &[&str]) -> OpenAIModelProviderConfig {
26043 OpenAIModelProviderConfig {
26044 base: ModelProviderConfig {
26045 model: Some("gpt-4o".to_string()),
26046 fallback: fallback
26047 .iter()
26048 .map(|s| crate::providers::ModelProviderRef::new(*s))
26049 .collect(),
26050 ..Default::default()
26051 },
26052 }
26053 }
26054
26055 #[test]
26056 async fn fallback_warns_on_dangling_ref() {
26057 let mut config = Config::default();
26058 config.providers.models.openai.insert(
26059 "primary".to_string(),
26060 provider_entry_with_fallback(&["openai.ghost"]),
26061 );
26062
26063 let warnings = config.collect_warnings();
26064 assert_eq!(warnings.len(), 1);
26065 assert_eq!(warnings[0].code, "dangling_fallback_ref");
26066 assert_eq!(
26067 warnings[0].path,
26068 "providers.models.openai.primary.fallback[0]"
26069 );
26070 }
26071
26072 #[test]
26073 async fn fallback_no_warning_when_ref_resolves() {
26074 let mut config = Config::default();
26075 config.providers.models.openai.insert(
26076 "primary".to_string(),
26077 provider_entry_with_fallback(&["openai.backup"]),
26078 );
26079 config
26080 .providers
26081 .models
26082 .openai
26083 .insert("backup".to_string(), provider_entry_with_fallback(&[]));
26084
26085 assert!(config.collect_warnings().is_empty());
26086 }
26087
26088 #[test]
26089 async fn fallback_warns_on_two_node_cycle() {
26090 let mut config = Config::default();
26091 config
26092 .providers
26093 .models
26094 .openai
26095 .insert("a".to_string(), provider_entry_with_fallback(&["openai.b"]));
26096 config
26097 .providers
26098 .models
26099 .openai
26100 .insert("b".to_string(), provider_entry_with_fallback(&["openai.a"]));
26101
26102 let cycle_warnings: Vec<_> = config
26103 .collect_warnings()
26104 .into_iter()
26105 .filter(|w| w.code == "fallback_cycle")
26106 .collect();
26107 assert!(
26108 !cycle_warnings.is_empty(),
26109 "a->b->a must surface at least one fallback_cycle warning"
26110 );
26111 }
26112
26113 #[test]
26114 async fn fallback_self_reference_is_a_cycle() {
26115 let mut config = Config::default();
26116 config.providers.models.openai.insert(
26117 "loop".to_string(),
26118 provider_entry_with_fallback(&["openai.loop"]),
26119 );
26120
26121 let warnings = config.collect_warnings();
26122 assert_eq!(warnings.len(), 1);
26123 assert_eq!(warnings[0].code, "fallback_cycle");
26124 }
26125
26126 #[test]
26127 async fn fallback_empty_ref_is_skipped() {
26128 let mut config = Config::default();
26129 config
26130 .providers
26131 .models
26132 .openai
26133 .insert("primary".to_string(), provider_entry_with_fallback(&[""]));
26134
26135 assert!(config.collect_warnings().is_empty());
26136 }
26137
26138 #[test]
26139 async fn fallback_warns_when_chain_exceeds_max_depth() {
26140 let mut config = Config::default();
26141 let n = crate::providers::MAX_FALLBACK_DEPTH + 2;
26142 for i in 0..n {
26143 let next = if i + 1 < n {
26144 vec![format!("openai.a{}", i + 1)]
26145 } else {
26146 vec![]
26147 };
26148 let refs: Vec<&str> = next.iter().map(String::as_str).collect();
26149 config
26150 .providers
26151 .models
26152 .openai
26153 .insert(format!("a{i}"), provider_entry_with_fallback(&refs));
26154 }
26155
26156 let depth_warnings: Vec<_> = config
26157 .collect_warnings()
26158 .into_iter()
26159 .filter(|w| w.code == "max_fallback_depth_exceeded")
26160 .collect();
26161 assert!(
26162 !depth_warnings.is_empty(),
26163 "a chain deeper than MAX_FALLBACK_DEPTH must surface a max_fallback_depth_exceeded warning"
26164 );
26165 }
26166
26167 #[test]
26168 async fn fallback_models_warns_on_empty_entry() {
26169 let mut config = Config::default();
26170 let mut entry = provider_entry_with_fallback(&[]);
26171 entry.base.fallback_models = vec!["".to_string()];
26172 config
26173 .providers
26174 .models
26175 .openai
26176 .insert("primary".to_string(), entry);
26177
26178 let warnings = config.collect_warnings();
26179 assert_eq!(warnings.len(), 1);
26180 assert_eq!(warnings[0].code, "empty_fallback_model");
26181 }
26182
26183 #[test]
26184 async fn fallback_models_warns_on_duplicate_of_primary() {
26185 let mut config = Config::default();
26186 let mut entry = provider_entry_with_fallback(&[]);
26187 entry.base.fallback_models = vec!["gpt-4o".to_string()];
26188 config
26189 .providers
26190 .models
26191 .openai
26192 .insert("primary".to_string(), entry);
26193
26194 let warnings = config.collect_warnings();
26195 assert_eq!(warnings.len(), 1);
26196 assert_eq!(warnings[0].code, "fallback_model_duplicates_primary");
26197 }
26198
26199 #[test]
26200 async fn fallback_models_distinct_entries_do_not_warn() {
26201 let mut config = Config::default();
26202 let mut entry = provider_entry_with_fallback(&[]);
26203 entry.base.fallback_models = vec!["gpt-4o-mini".to_string()];
26204 config
26205 .providers
26206 .models
26207 .openai
26208 .insert("primary".to_string(), entry);
26209
26210 assert!(config.collect_warnings().is_empty());
26211 }
26212}