Skip to main content

zeroclaw_config/
schema.rs

1// Historical schema typed lenses for migration. Each module is frozen after
2// its corresponding version ships; only their `migrate(self) -> ...` methods
3// are referenced at runtime by `crate::migration`.
4pub mod v1;
5pub mod v2;
6
7use crate::autonomy::AutonomyLevel;
8use crate::domain_matcher::DomainMatcher;
9use crate::traits::{ChannelConfig, HasPropKind, PropKind};
10use crate::validation_bail;
11use anyhow::{Context, Result};
12use directories::UserDirs;
13use serde::{Deserialize, Serialize};
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16use std::sync::{OnceLock, RwLock};
17#[cfg(unix)]
18use tokio::fs::File;
19use tokio::fs::{self, OpenOptions};
20use tokio::io::AsyncWriteExt;
21use zeroclaw_macros::Configurable;
22
23const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
24    "model_provider.anthropic",
25    "model_provider.compatible",
26    "model_provider.copilot",
27    "model_provider.gemini",
28    "model_provider.glm",
29    "model_provider.ollama",
30    "model_provider.openai",
31    "model_provider.openrouter",
32    "channel.dingtalk",
33    "channel.discord",
34    "channel.lark",
35    "channel.matrix",
36    "channel.mattermost",
37    "channel.nextcloud_talk",
38    "channel.qq",
39    "channel.signal",
40    "channel.slack",
41    "channel.telegram",
42    "channel.wati",
43    "channel.wechat",
44    "channel.whatsapp",
45    "tool.browser",
46    "tool.composio",
47    "tool.http_request",
48    "tool.pushover",
49    "tool.web_search",
50    "memory.embeddings",
51    "tunnel.custom",
52    "transcription.groq",
53];
54
55const SUPPORTED_PROXY_SERVICE_SELECTORS: &[&str] = &[
56    "model_provider.*",
57    "channel.*",
58    "tool.*",
59    "memory.*",
60    "tunnel.*",
61    "transcription.*",
62];
63
64static RUNTIME_PROXY_CONFIG: OnceLock<RwLock<ProxyConfig>> = OnceLock::new();
65static RUNTIME_PROXY_CLIENT_CACHE: OnceLock<RwLock<HashMap<String, reqwest::Client>>> =
66    OnceLock::new();
67
68// ── Top-level config ──────────────────────────────────────────────
69
70/// Top-level ZeroClaw configuration, loaded from `config.toml`.
71///
72/// Resolution order: `ZEROCLAW_CONFIG_DIR` env → `ZEROCLAW_WORKSPACE` env → `~/.zeroclaw/config.toml`.
73#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
74#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
75pub struct Config {
76    /// Shared instance data directory (databases, hygiene state, cost
77    /// records, daemon state files). Computed from `ZEROCLAW_CONFIG_DIR`
78    /// / `ZEROCLAW_DATA_DIR` / `ZEROCLAW_WORKSPACE` (deprecated) at
79    /// load time, not serialized. Per-agent identity + markdown lives
80    /// at `agent_workspace_dir(&alias)`, not here.
81    #[serde(skip)]
82    pub data_dir: PathBuf,
83    /// Path to config.toml - computed from home, not serialized
84    #[serde(skip)]
85    pub config_path: PathBuf,
86    /// Dotted prop-paths overridden by `ZEROCLAW_*` env vars at load time.
87    /// Populated by `apply_env_overrides`; consulted by `save()` to mask the
88    /// env-injected values back to disk-or-default before encryption, and by
89    /// `prop_is_env_overridden` for O(1) display-layer lookup (config list,
90    /// dashboard, onboarding).
91    #[serde(skip)]
92    pub env_overridden_paths: std::collections::HashSet<String>,
93    /// Per-path snapshot of pre-override raw values, captured at apply time
94    /// from the post-`decrypt_secrets` in-memory state (so secret entries
95    /// hold plaintext, not the display mask). `save()` restores from this
96    /// map so env-injected values never reach disk and the operator's
97    /// original on-disk credentials survive any save cycle.
98    #[serde(skip)]
99    pub pre_override_snapshots: std::collections::HashMap<String, String>,
100    /// Dotted prop-paths mutated since the last persist; drives the
101    /// per-path PATCH applied by `save_dirty()`.
102    #[serde(skip)]
103    pub dirty_paths: std::collections::HashSet<String>,
104    /// Config file schema version.
105    #[serde(default = "default_schema_version")]
106    pub schema_version: u32,
107
108    /// All configured provider profiles, grouped by category under a
109    /// single `[providers]` root. Categories today: `models`, `tts`,
110    /// `transcription`. Shape: `[providers.<category>.<type>.<alias>]`,
111    /// e.g. `[providers.models.anthropic.default]`,
112    /// `[providers.tts.openai.default]`,
113    /// `[providers.transcription.groq.default]`.
114    #[serde(default)]
115    #[nested]
116    pub providers: crate::providers::Providers,
117
118    /// Model-routing rules — route `hint:<name>` to specific
119    /// model_provider + model combos.
120    #[serde(default, skip_serializing_if = "Vec::is_empty")]
121    pub model_routes: Vec<ModelRouteConfig>,
122
123    /// Embedding-routing rules — route `hint:<name>` to specific
124    /// model_provider + model combos for embedding requests.
125    #[serde(default, skip_serializing_if = "Vec::is_empty")]
126    pub embedding_routes: Vec<EmbeddingRouteConfig>,
127
128    /// Observability backend configuration (`[observability]`).
129    #[serde(default)]
130    #[nested]
131    pub observability: ObservabilityConfig,
132
133    /// Trust scoring and regression detection configuration (`[trust]`).
134    #[serde(default)]
135    #[nested]
136    pub trust: crate::scattered_types::TrustConfig,
137
138    /// Security subsystem configuration (`[security]`).
139    #[serde(default)]
140    #[nested]
141    pub security: SecurityConfig,
142
143    /// Backup tool configuration (`[backup]`).
144    #[serde(default)]
145    #[nested]
146    pub backup: BackupConfig,
147
148    /// Data retention and purge configuration (`[data_retention]`).
149    #[serde(default)]
150    #[nested]
151    pub data_retention: DataRetentionConfig,
152
153    /// Cloud transformation accelerator configuration (`[cloud_ops]`).
154    #[serde(default)]
155    #[nested]
156    pub cloud_ops: CloudOpsConfig,
157
158    /// Conversational AI agent builder configuration (`[conversational_ai]`).
159    ///
160    /// Experimental / future feature — not yet wired into the agent runtime.
161    /// Omitted from generated config files when disabled (the default).
162    /// Existing configs that already contain this section will continue to
163    /// deserialize correctly thanks to `#[serde(default)]`.
164    #[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")]
165    #[nested]
166    pub conversational_ai: ConversationalAiConfig,
167
168    /// Managed cybersecurity service configuration (`[security_ops]`).
169    #[serde(default)]
170    #[nested]
171    pub security_ops: SecurityOpsConfig,
172
173    /// Runtime adapter configuration (`[runtime]`). Controls native vs Docker execution.
174    #[serde(default)]
175    #[nested]
176    pub runtime: RuntimeConfig,
177
178    /// Reliability settings: retries, backoff, key rotation (`[reliability]`).
179    #[serde(default)]
180    #[nested]
181    pub reliability: ReliabilityConfig,
182
183    /// Scheduler configuration for periodic task execution (`[scheduler]`).
184    #[serde(default)]
185    #[nested]
186    pub scheduler: SchedulerConfig,
187
188    /// Pacing controls for slow/local LLM workloads (`[pacing]`).
189    #[serde(default)]
190    #[nested]
191    pub pacing: PacingConfig,
192
193    /// Skills loading and community repository behavior (`[skills]`).
194    #[serde(default)]
195    #[nested]
196    pub skills: SkillsConfig,
197
198    /// Pipeline tool configuration (`[pipeline]`).
199    #[serde(default)]
200    #[nested]
201    pub pipeline: PipelineConfig,
202
203    /// Automatic query classification — maps user messages to model hints.
204    #[serde(default)]
205    #[nested]
206    pub query_classification: QueryClassificationConfig,
207
208    /// Heartbeat configuration for periodic health pings (`[heartbeat]`).
209    #[serde(default)]
210    #[nested]
211    pub heartbeat: HeartbeatConfig,
212
213    /// Declarative cron jobs (`[cron.<alias>]`), alias-keyed.
214    ///
215    /// Each entry is a named scheduled job synced into the database at
216    /// scheduler startup. Subsystem runtime knobs (enable/disable, catch-up,
217    /// run-history retention) live on `[scheduler]`.
218    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
219    #[nested]
220    pub cron: HashMap<String, CronJobDecl>,
221
222    /// ACP (Agent Client Protocol) server configuration (`[acp]`).
223    #[serde(default)]
224    #[nested]
225    pub acp: AcpConfig,
226
227    /// Channel configurations: Telegram, Discord, Slack, etc. (`[channels]`).
228    #[serde(default, alias = "channels_config")]
229    #[nested]
230    pub channels: ChannelsConfig,
231
232    /// Memory backend configuration: sqlite, markdown, embeddings (`[memory]`).
233    #[serde(default)]
234    #[nested]
235    pub memory: MemoryConfig,
236
237    /// Persistent storage model_provider configuration (`[storage]`).
238    #[serde(default)]
239    #[nested]
240    pub storage: StorageConfig,
241
242    /// Tunnel configuration for exposing the gateway publicly (`[tunnel]`).
243    #[serde(default)]
244    #[nested]
245    pub tunnel: TunnelConfig,
246
247    /// Gateway server configuration: host, port, pairing, rate limits (`[gateway]`).
248    #[serde(default)]
249    #[nested]
250    pub gateway: GatewayConfig,
251
252    /// Composio managed OAuth tools integration (`[composio]`).
253    #[serde(default)]
254    #[nested]
255    pub composio: ComposioConfig,
256
257    /// Microsoft 365 Graph API integration (`[microsoft365]`).
258    #[serde(default)]
259    #[nested]
260    pub microsoft365: Microsoft365Config,
261
262    /// Secrets encryption configuration (`[secrets]`).
263    #[serde(default)]
264    #[nested]
265    pub secrets: SecretsConfig,
266
267    /// Browser automation configuration (`[browser]`).
268    #[serde(default)]
269    #[nested]
270    pub browser: BrowserConfig,
271
272    /// Browser delegation configuration (`[browser_delegate]`).
273    ///
274    /// Delegates browser-based tasks to a browser-capable CLI subprocess (e.g.
275    /// Claude Code with `claude-in-chrome` MCP tools). Useful for interacting
276    /// with corporate web apps (Teams, Outlook, Jira, Confluence) that lack
277    /// direct API access. A persistent Chrome profile can be configured so SSO
278    /// sessions survive across invocations.
279    ///
280    /// Fields:
281    /// - `enabled` (`bool`, default `false`) — enable the browser delegation tool.
282    /// - `cli_binary` (`String`, default `"claude"`) — CLI binary to spawn for browser tasks.
283    /// - `chrome_profile_dir` (`String`, default `""`) — Chrome user-data directory for
284    ///   persistent SSO sessions. When empty, a fresh profile is used each invocation.
285    /// - `allowed_domains` (`Vec<String>`, default `[]`) — allowlist of domains the browser
286    ///   may navigate to. Empty means all non-blocked domains are permitted.
287    /// - `blocked_domains` (`Vec<String>`, default `[]`) — denylist of domains. Blocked
288    ///   domains take precedence over allowed domains.
289    /// - `task_timeout_secs` (`u64`, default `120`) — per-task timeout in seconds.
290    ///
291    /// Compatibility: additive and disabled by default; existing configs remain valid when omitted.
292    /// Rollback/migration: remove `[browser_delegate]` or keep `enabled = false` to disable.
293    #[serde(default)]
294    #[nested]
295    pub browser_delegate: crate::scattered_types::BrowserDelegateConfig,
296
297    /// HTTP request tool configuration (`[http_request]`).
298    #[serde(default)]
299    #[nested]
300    pub http_request: HttpRequestConfig,
301
302    /// Multimodal (image) handling configuration (`[multimodal]`).
303    #[serde(default)]
304    #[nested]
305    pub multimodal: MultimodalConfig,
306
307    /// Automatic media understanding pipeline (`[media_pipeline]`).
308    #[serde(default)]
309    #[nested]
310    pub media_pipeline: MediaPipelineConfig,
311
312    /// Web fetch tool configuration (`[web_fetch]`).
313    #[serde(default)]
314    #[nested]
315    pub web_fetch: WebFetchConfig,
316
317    /// Link enricher configuration (`[link_enricher]`).
318    #[serde(default)]
319    #[nested]
320    pub link_enricher: LinkEnricherConfig,
321
322    /// Text browser tool configuration (`[text_browser]`).
323    #[serde(default)]
324    #[nested]
325    pub text_browser: TextBrowserConfig,
326
327    /// Web search tool configuration (`[web_search]`).
328    #[serde(default)]
329    #[nested]
330    pub web_search: WebSearchConfig,
331
332    /// Project delivery intelligence configuration (`[project_intel]`).
333    #[serde(default)]
334    #[nested]
335    pub project_intel: ProjectIntelConfig,
336
337    /// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]`).
338    #[serde(default)]
339    #[nested]
340    pub google_workspace: GoogleWorkspaceConfig,
341
342    /// Proxy configuration for outbound HTTP/HTTPS/SOCKS5 traffic (`[proxy]`).
343    #[serde(default)]
344    #[nested]
345    pub proxy: ProxyConfig,
346
347    /// Cost tracking and budget enforcement configuration (`[cost]`).
348    /// Also hosts the operator-managed rate sheet at
349    /// `[cost.rates.<type>.<model>]`.
350    #[serde(default)]
351    #[nested]
352    pub cost: CostConfig,
353
354    /// Peripheral board configuration for hardware integration (`[peripherals]`).
355    #[serde(default)]
356    #[nested]
357    pub peripherals: PeripheralsConfig,
358
359    /// Delegate tool global default configuration (`[delegate]`).
360    #[serde(default)]
361    #[nested]
362    pub delegate: DelegateToolConfig,
363
364    /// Aliased agents in this install. Each entry under `[agents.<alias>]`
365    /// is one user-facing agent with its own identity, channels, model
366    /// provider, risk profile, workspace, and memory scope.
367    /// `DelegateTool` consults this map when one agent delegates a
368    /// subtask to another.
369    #[serde(default)]
370    #[nested]
371    pub agents: HashMap<String, AliasedAgentConfig>,
372
373    /// Named risk/autonomy profiles (`[risk_profiles.<alias>]`).
374    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
375    #[nested]
376    pub risk_profiles: HashMap<String, RiskProfileConfig>,
377
378    /// Named runtime/LLM execution profiles (`[runtime_profiles.<alias>]`).
379    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
380    #[nested]
381    pub runtime_profiles: HashMap<String, RuntimeProfileConfig>,
382
383    /// Named skill bundles (`[skill_bundles.<alias>]`).
384    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
385    #[nested]
386    pub skill_bundles: HashMap<String, SkillBundleConfig>,
387
388    /// Named knowledge bundles (`[knowledge_bundles.<alias>]`).
389    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
390    #[nested]
391    pub knowledge_bundles: HashMap<String, KnowledgeBundleConfig>,
392
393    /// Named MCP server bundles (`[mcp_bundles.<alias>]`).
394    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
395    #[nested]
396    pub mcp_bundles: HashMap<String, McpBundleConfig>,
397
398    /// Named peer groups (`[peer_groups.<name>]`). Each entry binds a
399    /// channel, a list of member agents, and optional non-agent
400    /// (external) members and a per-group blocklist. Mutual opt-in:
401    /// two agents become peers only when both appear in the same
402    /// group's `agents`. Empty by default for single-agent installs.
403    /// See `crate::multi_agent::PeerGroupConfig`.
404    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
405    #[nested]
406    pub peer_groups: HashMap<String, crate::multi_agent::PeerGroupConfig>,
407
408    /// Hooks configuration (lifecycle hooks and built-in hook toggles).
409    #[serde(default)]
410    #[nested]
411    pub hooks: HooksConfig,
412
413    /// Hardware configuration (wizard-driven physical world setup).
414    #[serde(default)]
415    #[nested]
416    pub hardware: HardwareConfig,
417
418    /// Voice transcription configuration (Whisper API via Groq).
419    #[serde(default)]
420    #[nested]
421    pub transcription: TranscriptionConfig,
422
423    /// Text-to-Speech configuration (`[tts]`).
424    #[serde(default)]
425    #[nested]
426    pub tts: TtsConfig,
427
428    /// External MCP server connections (`[mcp]`).
429    #[serde(default, alias = "mcpServers")]
430    #[nested]
431    pub mcp: McpConfig,
432
433    /// Dynamic node discovery configuration (`[nodes]`).
434    #[serde(default)]
435    #[nested]
436    pub nodes: NodesConfig,
437
438    /// Meta-state for `zeroclaw onboard` (which sections the user has
439    /// already walked through). Not user-facing config (`[onboard_state]`).
440    #[serde(default)]
441    #[nested]
442    pub onboard_state: OnboardStateConfig,
443
444    /// Notion integration configuration (`[notion]`).
445    #[serde(default)]
446    #[nested]
447    pub notion: NotionConfig,
448
449    /// Jira integration configuration (`[jira]`).
450    #[serde(default)]
451    #[nested]
452    pub jira: JiraConfig,
453
454    /// Secure inter-node transport configuration (`[node_transport]`).
455    #[serde(default)]
456    #[nested]
457    pub node_transport: NodeTransportConfig,
458
459    /// Knowledge graph configuration (`[knowledge]`).
460    #[serde(default)]
461    #[nested]
462    pub knowledge: KnowledgeConfig,
463
464    /// LinkedIn integration configuration (`[linkedin]`).
465    #[serde(default)]
466    #[nested]
467    pub linkedin: LinkedInConfig,
468
469    /// Standalone image generation tool configuration (`[image_gen]`).
470    #[serde(default)]
471    #[nested]
472    pub image_gen: ImageGenConfig,
473
474    /// Standalone file upload tool configuration (`[file_upload]`).
475    #[serde(default)]
476    #[nested]
477    pub file_upload: FileUploadConfig,
478
479    /// Standalone multi-file bundle upload tool configuration
480    /// (`[file_upload_bundle]`).
481    #[serde(default)]
482    #[nested]
483    pub file_upload_bundle: FileUploadBundleConfig,
484
485    /// Standalone file download tool configuration (`[file_download]`).
486    #[serde(default)]
487    #[nested]
488    pub file_download: FileDownloadConfig,
489
490    /// Plugin system configuration (`[plugins]`).
491    #[serde(default)]
492    #[nested]
493    pub plugins: PluginsConfig,
494
495    /// Locale for tool descriptions (e.g. `"en"`, `"zh-CN"`).
496    ///
497    /// When set, tool descriptions shown in system prompts are loaded from
498    /// Fluent `.ftl` locale files. Falls back to embedded English, then to
499    /// hardcoded descriptions.
500    ///
501    /// If omitted or empty, the locale is auto-detected from `ZEROCLAW_LOCALE`,
502    /// `LANG`, or `LC_ALL` environment variables (defaulting to `"en"`).
503    #[serde(default)]
504    pub locale: Option<String>,
505
506    /// Verifiable Intent (VI) credential verification and issuance (`[verifiable_intent]`).
507    #[serde(default)]
508    #[nested]
509    pub verifiable_intent: VerifiableIntentConfig,
510
511    /// Claude Code tool configuration (`[claude_code]`).
512    #[serde(default)]
513    #[nested]
514    pub claude_code: ClaudeCodeConfig,
515
516    /// Claude Code task runner with Slack progress and SSH session handoff (`[claude_code_runner]`).
517    #[serde(default)]
518    #[nested]
519    pub claude_code_runner: ClaudeCodeRunnerConfig,
520
521    /// Codex CLI tool configuration (`[codex_cli]`).
522    #[serde(default)]
523    #[nested]
524    pub codex_cli: CodexCliConfig,
525
526    /// Gemini CLI tool configuration (`[gemini_cli]`).
527    #[serde(default)]
528    #[nested]
529    pub gemini_cli: GeminiCliConfig,
530
531    /// OpenCode CLI tool configuration (`[opencode_cli]`).
532    #[serde(default)]
533    #[nested]
534    pub opencode_cli: OpenCodeCliConfig,
535
536    /// Standard Operating Procedures engine configuration (`[sop]`).
537    #[serde(default)]
538    #[nested]
539    pub sop: SopConfig,
540
541    /// Shell tool configuration (`[shell_tool]`).
542    #[serde(default)]
543    #[nested]
544    pub shell_tool: ShellToolConfig,
545
546    /// Escalation routing configuration (`[escalation]`).
547    #[serde(default)]
548    #[nested]
549    pub escalation: EscalationConfig,
550}
551
552/// Multi-client workspace isolation configuration.
553///
554/// When enabled, each client engagement gets an isolated workspace with
555/// separate memory, audit, secrets, and tool restrictions.
556#[allow(clippy::struct_excessive_bools)]
557/// Opaque state the `zeroclaw onboard` flow writes so it can tell, on a
558/// re-run, which sections the user has already walked through at least
559/// once — which lets it offer "Reconfigure? [y/N]" skip gates instead of
560/// forcing users through every field again.
561///
562/// This is meta-state about the onboard process, not user-facing config.
563#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
564#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
565#[prefix = "onboard_state"]
566pub struct OnboardStateConfig {
567    /// Section keys the user has completed at least once via onboard.
568    /// Values are the lowercased Section variant names
569    /// (`"workspace"`, `"model_providers"`, …).
570    #[serde(default)]
571    pub completed_sections: Vec<String>,
572}
573
574/// Used by `#[serde(skip_serializing_if)]` on plain `bool` fields to omit
575/// them from TOML output when they carry their struct-level default (`false`).
576/// Keeps fresh model_provider entries clean — a default-constructed
577/// `ModelProviderConfig` for one model_provider family shouldn't write flag fields
578/// that only apply to a different family.
579fn is_false(value: &bool) -> bool {
580    !*value
581}
582
583/// One trait per family-endpoint enum. Returns the URI template for the chosen
584/// variant — a literal URL for fixed endpoints (`https://api.openai.com/v1`),
585/// or a substitution template for computed endpoints (Azure's
586/// `https://{resource}.openai.azure.com/...`). Substitution happens family-side
587/// in the runtime constructor; for non-templated families the return value is
588/// the final URL.
589///
590/// Resolution order at runtime is uniform across every model model_provider family:
591/// operator's `cfg.uri` first; family endpoint enum's `uri()` second; loud
592/// failure when neither is set.
593pub trait ModelEndpoint {
594    fn uri(&self) -> &'static str;
595}
596
597/// Implemented by every `*ModelProviderConfig`. Multi-region families
598/// override to return `Some(self.endpoint.uri())`; single-endpoint families
599/// inherit the `None` default. Drives `ModelProviders::resolved_endpoint_uri`,
600/// which is itself driven by the `for_each_model_provider_slot!` macro — so
601/// adding a new family without an impl is a compile error.
602pub trait FamilyEndpoint {
603    fn endpoint_uri(&self) -> Option<&'static str> {
604        None
605    }
606}
607
608/// Wire protocol flavor for the model_provider client. `responses` routes
609/// through OpenAI's Codex/Responses API (`POST /v1/responses`);
610/// `chat_completions` routes through the legacy `/v1/chat/completions` (or
611/// the family's chat-completions-compatible endpoint). Auto-selected per
612/// family when unset.
613#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
614#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
615#[serde(rename_all = "snake_case")]
616pub enum WireApi {
617    Responses,
618    ChatCompletions,
619}
620
621impl WireApi {
622    #[must_use]
623    pub fn as_str(self) -> &'static str {
624        match self {
625            Self::Responses => "responses",
626            Self::ChatCompletions => "chat_completions",
627        }
628    }
629}
630
631/// Authentication mode for model model_provider families that support more than one
632/// (e.g. Qwen, Minimax can use API key OR OAuth). Families that only support a
633/// single auth flow simply omit this field from their config struct.
634#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
635#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
636#[serde(rename_all = "snake_case")]
637pub enum AuthMode {
638    /// Standard API key authentication via the `api_key` field.
639    #[default]
640    ApiKey,
641    /// OAuth flow — credential resolution defers to the family runtime impl
642    /// (typically reading a vendor-specific token cache or env var).
643    OAuth,
644}
645
646/// Named model_provider profile definition.
647#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
648#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
649#[prefix = "providers.models"]
650pub struct ModelProviderConfig {
651    /// Secret API token for this model_provider — grab it from the model_provider's dashboard (OpenAI platform, Anthropic console, OpenRouter keys page, etc.). Stored via the OS keyring when possible; never commit it to config.toml directly.
652    #[secret]
653    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
654    #[serde(default, skip_serializing_if = "Option::is_none")]
655    pub api_key: Option<String>,
656    /// Provider implementation to instantiate for this profile. Use this
657    /// when a canonical typed slot should run through a compatible
658    /// implementation, e.g. `[providers.models.openai.proxy] kind =
659    /// "openai-compatible"`.
660    #[serde(default, skip_serializing_if = "Option::is_none")]
661    pub kind: Option<String>,
662    /// Endpoint URI the client hits. Override the family's default endpoint when pointing at a self-hosted gateway (LiteLLM, vLLM, Ollama), a custom proxy, or any non-standard URL. Leave unset to use the family's default URI from its `ModelEndpoint` impl. Set this to the FULL endpoint URL — there is no separate path-suffix field.
663    #[serde(default, skip_serializing_if = "Option::is_none")]
664    pub uri: Option<String>,
665    /// Model identifier to send with each request — the ID string from the model_provider's catalog (e.g. `gpt-4o`, `claude-sonnet-4-5`, `llama-3.3-70b`). Must match a model the model_provider actually serves on this account.
666    #[serde(default, skip_serializing_if = "Option::is_none")]
667    pub model: Option<String>,
668    /// Sampling temperature passed to the model. Lower values (0.0–0.3) give
669    /// deterministic, near-verbatim output — fits code, routing, summarization.
670    /// Higher values (0.7–1.2) give more varied output — fits open-ended chat.
671    #[serde(default, skip_serializing_if = "Option::is_none")]
672    pub temperature: Option<f64>,
673    /// HTTP request timeout in seconds. Bump this for slow local model_providers (Ollama on CPU, big local models) or high-latency networks; leave unset otherwise.
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub timeout_secs: Option<u64>,
676    /// Extra HTTP headers sent with every request. Niche — used for auth bridges, corporate proxies, or custom gateways that demand a tracing header. Most users never touch this; edit `config.toml` directly if you need it.
677    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
678    pub extra_headers: HashMap<String, String>,
679    /// Wire protocol flavor: `responses` for OpenAI's Codex/Responses API, `chat_completions` for everything else (OpenAI chat, Anthropic, OpenRouter, Groq, local gateways). Auto-selected per model_provider — only override if you're forcing an unusual combination.
680    #[serde(default, skip_serializing_if = "Option::is_none")]
681    pub wire_api: Option<WireApi>,
682    /// When true, the client pulls credentials from `OPENAI_API_KEY` or `~/.codex/auth.json` instead of the `api_key` field above. Turn on only for the OpenAI Codex model_provider; leave off for standard API-key model_providers.
683    #[serde(default, skip_serializing_if = "is_false")]
684    pub requires_openai_auth: bool,
685    /// Hard cap on response length in tokens. Most models enforce sensible built-in limits already — leave unset unless you specifically need to clip long outputs for cost or latency reasons.
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub max_tokens: Option<u32>,
688    /// ModelProvider-specific quirk: fold the system prompt into the first user message instead of sending a separate system role. Only needed for models that reject (or mishandle) a standalone system role — e.g. certain older Mistral variants.
689    #[serde(default, skip_serializing_if = "is_false")]
690    pub merge_system_into_user: bool,
691    /// Extra JSON parameters to include in API requests.
692    /// Merged at the top level of the request body, allowing provider-specific
693    /// features (routing, transforms, etc.) without code changes.
694    /// Example: `provider_extra = { model_provider = { only = ["Anthropic"] } }`
695    #[serde(default, skip_serializing_if = "Option::is_none")]
696    pub provider_extra: Option<serde_json::Value>,
697    /// Per-model pricing for cost tracking, USD per 1M tokens.
698    ///
699    /// Free-form key/value map. Keys are user-defined model identifiers; an
700    /// optional `.input` / `.output` suffix encodes pricing dimension when
701    /// the operator wants to split rates. A bare key without a suffix is
702    /// used as a flat per-token rate when neither dimension is specified.
703    /// Default is empty: cost tracking falls back to "unknown" rates and
704    /// only token usage is recorded.
705    ///
706    /// Example: `pricing = { opus = 15.0, sonnet = 3.0 }`
707    /// Or split: `pricing = { "opus.input" = 15.0, "opus.output" = 75.0 }`
708    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
709    pub pricing: HashMap<String, f64>,
710    /// Override the provider's default for native tool calling.
711    /// `None` (default) honors the provider's built-in choice. `Some(true)`
712    /// forces native tool calls on, `Some(false)` forces text-fallback.
713    /// Currently consulted only by the Groq factory, which defaults to
714    /// text-fallback because llama-family Groq models reject native tool
715    /// calls with HTTP 400. Setting `native_tools = true` re-enables native
716    /// tool calling for Groq models that support it.
717    #[serde(default, skip_serializing_if = "Option::is_none")]
718    pub native_tools: Option<bool>,
719    /// Enable or disable chain-of-thought thinking for models that support it
720    /// (e.g. Qwen3, GLM-4). `true` turns thinking on, `false` turns it off.
721    /// `None` (default) lets the model decide. Forwarded as `enable_thinking`
722    /// in the request body; mirrors the Ollama provider's `think` field.
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub think: Option<bool>,
725    /// Arbitrary key/value pairs forwarded verbatim as `chat_template_kwargs`
726    /// in the request body (llama.cpp-specific). Use this to pass model-family
727    /// template variables that control behaviour not exposed by other fields.
728    /// Example (Qwen3 thinking suppression):
729    ///   `chat_template_kwargs = { enable_thinking = false }`
730    #[serde(default, skip_serializing_if = "Option::is_none")]
731    pub chat_template_kwargs: Option<serde_json::Value>,
732}
733
734// ── Per-family model model_provider configs ────────────────────────────
735//
736// Each family carries its own typed config (composing `ModelProviderConfig`
737// via `#[serde(flatten)]`) plus a per-family `*Endpoint` enum that names the
738// known endpoints and resolves them via the `ModelEndpoint` trait. Families
739// that support multiple auth flows additionally carry an `auth_mode` field.
740//
741// Pattern reference for adding a new family:
742// - Single-endpoint family with no extras: see `AnthropicModelProviderConfig`
743// - Family with extras: see `OpenAIModelProviderConfig`
744// - Family with computed-endpoint template: see `AzureModelProviderConfig`
745// - Multi-region family with a required `endpoint` field: see `MoonshotModelProviderConfig`
746//
747// The `ModelProviders` container in `crates/zeroclaw-config/src/model_providers.rs`
748// holds a typed slot per family; the runtime impls in zeroclaw-providers
749// consume the typed configs directly.
750
751// ── OpenAI ──
752
753/// OpenAI canonical endpoint. Single variant — OpenAI publishes one base URL.
754#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
755#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
756#[serde(rename_all = "snake_case")]
757pub enum OpenAIEndpoint {
758    #[default]
759    Default,
760}
761
762impl ModelEndpoint for OpenAIEndpoint {
763    fn uri(&self) -> &'static str {
764        match self {
765            Self::Default => "https://api.openai.com/v1",
766        }
767    }
768}
769
770/// OpenAI model model_provider config. The OpenAI-family extras (`wire_api`,
771/// `requires_openai_auth`) live on the shared `ModelProviderConfig` base
772/// because they're consumed by validation and runtime helpers that operate
773/// on the base struct without family awareness; this wrapper is a thin
774/// typed slot, no extra fields.
775#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
776#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
777#[prefix = "providers.models.openai"]
778pub struct OpenAIModelProviderConfig {
779    #[nested]
780    #[serde(flatten)]
781    pub base: ModelProviderConfig,
782}
783
784// ── Azure OpenAI ──
785
786/// Azure OpenAI endpoint template. Single variant; the URL is computed at
787/// runtime by substituting `{resource}` and `{deployment}` from the typed
788/// config fields.
789#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
790#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
791#[serde(rename_all = "snake_case")]
792pub enum AzureEndpoint {
793    #[default]
794    Default,
795}
796
797impl ModelEndpoint for AzureEndpoint {
798    fn uri(&self) -> &'static str {
799        match self {
800            // Azure's URI is a template — substitution happens in the
801            // AzureModelProvider runtime constructor against the typed
802            // config's resource / deployment fields.
803            Self::Default => "https://{resource}.openai.azure.com/openai/deployments/{deployment}",
804        }
805    }
806}
807
808/// Azure OpenAI model model_provider config. Carries the Azure-specific connection
809/// fields (`resource`, `deployment`, `api_version`) — the URI template
810/// substitutes `{resource}` and `{deployment}` at runtime. Operators can
811/// still override the entire endpoint via `base.uri`.
812#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
813#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
814#[prefix = "providers.models.azure"]
815pub struct AzureModelProviderConfig {
816    #[nested]
817    #[serde(flatten)]
818    pub base: ModelProviderConfig,
819    /// Azure resource name (the `<resource>` part of `<resource>.openai.azure.com`).
820    #[serde(
821        default,
822        skip_serializing_if = "Option::is_none",
823        alias = "azure_openai_resource"
824    )]
825    pub resource: Option<String>,
826    /// Azure deployment name — the deployment created in Azure AI Studio.
827    #[serde(
828        default,
829        skip_serializing_if = "Option::is_none",
830        alias = "azure_openai_deployment"
831    )]
832    pub deployment: Option<String>,
833    /// Azure API version string (e.g. `2024-10-21`).
834    #[serde(
835        default,
836        skip_serializing_if = "Option::is_none",
837        alias = "azure_openai_api_version"
838    )]
839    pub api_version: Option<String>,
840}
841
842// ── Anthropic ──
843
844/// Anthropic canonical endpoint.
845#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
846#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
847#[serde(rename_all = "snake_case")]
848pub enum AnthropicEndpoint {
849    #[default]
850    Default,
851}
852
853impl ModelEndpoint for AnthropicEndpoint {
854    fn uri(&self) -> &'static str {
855        match self {
856            Self::Default => "https://api.anthropic.com",
857        }
858    }
859}
860
861/// Anthropic model model_provider config. No family-specific extras yet — typed
862/// slot reserved for future Anthropic-only knobs (cache_control, beta
863/// headers) so they land cleanly without another schema rework.
864#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
865#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
866#[prefix = "providers.models.anthropic"]
867pub struct AnthropicModelProviderConfig {
868    #[nested]
869    #[serde(flatten)]
870    pub base: ModelProviderConfig,
871}
872
873// ── Moonshot (multi-region exemplar) ──
874
875/// Moonshot endpoint variants. Operators pick the region that matches their
876/// account; the runtime resolves the URI from the chosen variant unless
877/// overridden by `base.uri`. Code variant is intl-only.
878#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
879#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
880#[serde(rename_all = "snake_case")]
881pub enum MoonshotEndpoint {
882    /// Mainland China endpoint.
883    Cn,
884    /// International endpoint.
885    #[default]
886    Intl,
887    /// Code-specialist endpoint (intl).
888    Code,
889}
890
891impl ModelEndpoint for MoonshotEndpoint {
892    fn uri(&self) -> &'static str {
893        match self {
894            Self::Cn => "https://api.moonshot.cn/v1",
895            Self::Intl => "https://api.moonshot.ai/v1",
896            Self::Code => "https://api.moonshot.cn/coder/v1",
897        }
898    }
899}
900
901/// Moonshot model model_provider config. The `endpoint` field is required (no
902/// implicit default) — operators must pick a region explicitly. Migration
903/// fills it in from collapsed `moonshot-cn` / `moonshot-intl` outer keys.
904#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
905#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
906#[prefix = "providers.models.moonshot"]
907pub struct MoonshotModelProviderConfig {
908    #[nested]
909    #[serde(flatten)]
910    pub base: ModelProviderConfig,
911    /// Required: pick `cn`, `intl`, or `code`. Defaults to `intl` when omitted
912    /// to ease transition; operators on the China endpoint should set
913    /// `endpoint = "cn"` explicitly.
914    #[serde(default)]
915    pub endpoint: MoonshotEndpoint,
916}
917
918impl FamilyEndpoint for MoonshotModelProviderConfig {
919    fn endpoint_uri(&self) -> Option<&'static str> {
920        Some(self.endpoint.uri())
921    }
922}
923
924// ── Qwen (multi-region + auth_mode exemplar) ──
925
926/// Qwen endpoint variants. Operators pick the region matching their account.
927#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
928#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
929#[serde(rename_all = "snake_case")]
930pub enum QwenEndpoint {
931    /// Mainland China (DashScope).
932    Cn,
933    /// International (alicloud international).
934    #[default]
935    Intl,
936    /// United States (DashScope US).
937    Us,
938    /// Code-specialist endpoint.
939    Code,
940}
941
942impl ModelEndpoint for QwenEndpoint {
943    fn uri(&self) -> &'static str {
944        match self {
945            Self::Cn => "https://dashscope.aliyuncs.com/compatible-mode/v1",
946            Self::Intl => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
947            Self::Us => "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
948            Self::Code => {
949                "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
950            }
951        }
952    }
953}
954
955/// Qwen model model_provider config. Multi-region (`endpoint` required) and
956/// supports both API key and OAuth flows (`auth_mode` chooses which).
957#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
958#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
959#[prefix = "providers.models.qwen"]
960pub struct QwenModelProviderConfig {
961    #[nested]
962    #[serde(flatten)]
963    pub base: ModelProviderConfig,
964    #[serde(default)]
965    pub endpoint: QwenEndpoint,
966    /// Auth flow. Defaults to `api_key`; set to `oauth` to use the vendor's
967    /// OAuth-cache integration instead of the `api_key` field.
968    #[serde(default, skip_serializing_if = "Option::is_none")]
969    pub auth_mode: Option<AuthMode>,
970    /// Long-lived Qwen OAuth refresh token. When set, the runtime
971    /// exchanges it for a short-lived access token at provider
972    /// construction time. Operators relying on the upstream `qwen login`
973    /// tool (which writes `~/.qwen/oauth_creds.json`) leave this unset —
974    /// the file-cache integration takes over.
975    #[serde(default, skip_serializing_if = "Option::is_none")]
976    #[secret(category = "model_provider")]
977    pub oauth_refresh_token: Option<String>,
978    /// Override of Qwen's published OAuth client_id. Most operators
979    /// should leave this unset.
980    #[serde(default, skip_serializing_if = "Option::is_none")]
981    pub oauth_client_id: Option<String>,
982    /// Operator override of the resource URL the refreshed access token
983    /// is paired with. When unset, the runtime falls back to the
984    /// `endpoint`-derived URL (or the cached `resource_url` when reading
985    /// from `~/.qwen/oauth_creds.json`).
986    #[serde(default, skip_serializing_if = "Option::is_none")]
987    pub oauth_resource_url: Option<String>,
988}
989
990impl FamilyEndpoint for QwenModelProviderConfig {
991    fn endpoint_uri(&self) -> Option<&'static str> {
992        Some(self.endpoint.uri())
993    }
994}
995
996// ── OpenRouter ──
997
998#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
999#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1000#[serde(rename_all = "snake_case")]
1001pub enum OpenRouterEndpoint {
1002    #[default]
1003    Default,
1004}
1005
1006impl ModelEndpoint for OpenRouterEndpoint {
1007    fn uri(&self) -> &'static str {
1008        match self {
1009            Self::Default => "https://openrouter.ai/api/v1",
1010        }
1011    }
1012}
1013
1014#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1015#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1016#[prefix = "providers.models.openrouter"]
1017pub struct OpenRouterModelProviderConfig {
1018    #[nested]
1019    #[serde(flatten)]
1020    pub base: ModelProviderConfig,
1021}
1022
1023// ── Ollama (local-default endpoint) ──
1024
1025#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1026#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1027#[serde(rename_all = "snake_case")]
1028pub enum OllamaEndpoint {
1029    #[default]
1030    LocalDefault,
1031}
1032
1033impl ModelEndpoint for OllamaEndpoint {
1034    fn uri(&self) -> &'static str {
1035        match self {
1036            Self::LocalDefault => "http://localhost:11434",
1037        }
1038    }
1039}
1040
1041#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1042#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1043#[prefix = "providers.models.ollama"]
1044pub struct OllamaModelProviderConfig {
1045    #[nested]
1046    #[serde(flatten)]
1047    pub base: ModelProviderConfig,
1048    /// Override the Ollama `num_ctx` (context window, in tokens) sent on
1049    /// every `/api/chat` request. Defaults to the framework constant
1050    /// (`OLLAMA_DEFAULT_NUM_CTX`) when unset.
1051    #[serde(default, skip_serializing_if = "Option::is_none")]
1052    pub num_ctx: Option<u32>,
1053    /// Override the Ollama `num_predict` (max output tokens) sent on every
1054    /// `/api/chat` request. Defaults to the framework constant
1055    /// (`OLLAMA_DEFAULT_NUM_PREDICT`) when unset.
1056    #[serde(default, skip_serializing_if = "Option::is_none")]
1057    pub num_predict: Option<i32>,
1058    /// Force every Ollama `/api/chat` request to use this temperature,
1059    /// overriding the per-call value passed through
1060    /// `ModelProvider::chat_with_system(.., temperature)`. When unset
1061    /// (`None`, the default), the per-call temperature wins — full
1062    /// backward compatibility.
1063    #[serde(default, skip_serializing_if = "Option::is_none")]
1064    pub temperature_override: Option<f64>,
1065}
1066
1067// ── Together ──
1068
1069#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1070#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1071#[serde(rename_all = "snake_case")]
1072pub enum TogetherEndpoint {
1073    #[default]
1074    Default,
1075}
1076
1077impl ModelEndpoint for TogetherEndpoint {
1078    fn uri(&self) -> &'static str {
1079        match self {
1080            Self::Default => "https://api.together.xyz/v1",
1081        }
1082    }
1083}
1084
1085#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1086#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1087#[prefix = "providers.models.together"]
1088pub struct TogetherModelProviderConfig {
1089    #[nested]
1090    #[serde(flatten)]
1091    pub base: ModelProviderConfig,
1092}
1093
1094// ── Fireworks ──
1095
1096#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1097#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1098#[serde(rename_all = "snake_case")]
1099pub enum FireworksEndpoint {
1100    #[default]
1101    Default,
1102}
1103
1104impl ModelEndpoint for FireworksEndpoint {
1105    fn uri(&self) -> &'static str {
1106        match self {
1107            Self::Default => "https://api.fireworks.ai/inference/v1",
1108        }
1109    }
1110}
1111
1112#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1113#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1114#[prefix = "providers.models.fireworks"]
1115pub struct FireworksModelProviderConfig {
1116    #[nested]
1117    #[serde(flatten)]
1118    pub base: ModelProviderConfig,
1119}
1120
1121// ── Groq ──
1122
1123#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1124#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1125#[serde(rename_all = "snake_case")]
1126pub enum GroqEndpoint {
1127    #[default]
1128    Default,
1129}
1130
1131impl ModelEndpoint for GroqEndpoint {
1132    fn uri(&self) -> &'static str {
1133        match self {
1134            Self::Default => "https://api.groq.com/openai/v1",
1135        }
1136    }
1137}
1138
1139#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1140#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1141#[prefix = "providers.models.groq"]
1142pub struct GroqModelProviderConfig {
1143    #[nested]
1144    #[serde(flatten)]
1145    pub base: ModelProviderConfig,
1146}
1147
1148// ── Mistral ──
1149
1150#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1151#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1152#[serde(rename_all = "snake_case")]
1153pub enum MistralEndpoint {
1154    #[default]
1155    Default,
1156}
1157
1158impl ModelEndpoint for MistralEndpoint {
1159    fn uri(&self) -> &'static str {
1160        match self {
1161            Self::Default => "https://api.mistral.ai/v1",
1162        }
1163    }
1164}
1165
1166#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1167#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1168#[prefix = "providers.models.mistral"]
1169pub struct MistralModelProviderConfig {
1170    #[nested]
1171    #[serde(flatten)]
1172    pub base: ModelProviderConfig,
1173}
1174
1175// ── Atomic Chat (local OpenAI-compatible runtime, e.g. Jan) ──
1176
1177#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1178#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1179#[serde(rename_all = "snake_case")]
1180pub enum AtomicChatEndpoint {
1181    #[default]
1182    Default,
1183}
1184
1185impl ModelEndpoint for AtomicChatEndpoint {
1186    fn uri(&self) -> &'static str {
1187        match self {
1188            Self::Default => "http://127.0.0.1:1337/v1",
1189        }
1190    }
1191}
1192
1193#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1194#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1195#[prefix = "providers.models.atomic_chat"]
1196pub struct AtomicChatModelProviderConfig {
1197    #[nested]
1198    #[serde(flatten)]
1199    pub base: ModelProviderConfig,
1200}
1201
1202// ── DeepSeek ──
1203
1204#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1205#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1206#[serde(rename_all = "snake_case")]
1207pub enum DeepseekEndpoint {
1208    #[default]
1209    Default,
1210}
1211
1212impl ModelEndpoint for DeepseekEndpoint {
1213    fn uri(&self) -> &'static str {
1214        match self {
1215            Self::Default => "https://api.deepseek.com/v1",
1216        }
1217    }
1218}
1219
1220#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1221#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1222#[prefix = "providers.models.deepseek"]
1223pub struct DeepseekModelProviderConfig {
1224    #[nested]
1225    #[serde(flatten)]
1226    pub base: ModelProviderConfig,
1227}
1228
1229// ── Cohere ──
1230
1231#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1232#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1233#[serde(rename_all = "snake_case")]
1234pub enum CohereEndpoint {
1235    #[default]
1236    Default,
1237}
1238
1239impl ModelEndpoint for CohereEndpoint {
1240    fn uri(&self) -> &'static str {
1241        match self {
1242            Self::Default => "https://api.cohere.ai/compatibility/v1",
1243        }
1244    }
1245}
1246
1247#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1248#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1249#[prefix = "providers.models.cohere"]
1250pub struct CohereModelProviderConfig {
1251    #[nested]
1252    #[serde(flatten)]
1253    pub base: ModelProviderConfig,
1254}
1255
1256// ── Perplexity ──
1257
1258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1259#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1260#[serde(rename_all = "snake_case")]
1261pub enum PerplexityEndpoint {
1262    #[default]
1263    Default,
1264}
1265
1266impl ModelEndpoint for PerplexityEndpoint {
1267    fn uri(&self) -> &'static str {
1268        match self {
1269            Self::Default => "https://api.perplexity.ai",
1270        }
1271    }
1272}
1273
1274#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1275#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1276#[prefix = "providers.models.perplexity"]
1277pub struct PerplexityModelProviderConfig {
1278    #[nested]
1279    #[serde(flatten)]
1280    pub base: ModelProviderConfig,
1281}
1282
1283// ── xAI (Grok) ──
1284
1285#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1286#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1287#[serde(rename_all = "snake_case")]
1288pub enum XaiEndpoint {
1289    #[default]
1290    Default,
1291}
1292
1293impl ModelEndpoint for XaiEndpoint {
1294    fn uri(&self) -> &'static str {
1295        match self {
1296            Self::Default => "https://api.x.ai/v1",
1297        }
1298    }
1299}
1300
1301#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1302#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1303#[prefix = "providers.models.xai"]
1304pub struct XaiModelProviderConfig {
1305    #[nested]
1306    #[serde(flatten)]
1307    pub base: ModelProviderConfig,
1308}
1309
1310// ── Cerebras ──
1311
1312#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1313#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1314#[serde(rename_all = "snake_case")]
1315pub enum CerebrasEndpoint {
1316    #[default]
1317    Default,
1318}
1319
1320impl ModelEndpoint for CerebrasEndpoint {
1321    fn uri(&self) -> &'static str {
1322        match self {
1323            Self::Default => "https://api.cerebras.ai/v1",
1324        }
1325    }
1326}
1327
1328#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1329#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1330#[prefix = "providers.models.cerebras"]
1331pub struct CerebrasModelProviderConfig {
1332    #[nested]
1333    #[serde(flatten)]
1334    pub base: ModelProviderConfig,
1335}
1336
1337// ── SambaNova ──
1338
1339#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1340#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1341#[serde(rename_all = "snake_case")]
1342pub enum SambanovaEndpoint {
1343    #[default]
1344    Default,
1345}
1346
1347impl ModelEndpoint for SambanovaEndpoint {
1348    fn uri(&self) -> &'static str {
1349        match self {
1350            Self::Default => "https://api.sambanova.ai/v1",
1351        }
1352    }
1353}
1354
1355#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1356#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1357#[prefix = "providers.models.sambanova"]
1358pub struct SambanovaModelProviderConfig {
1359    #[nested]
1360    #[serde(flatten)]
1361    pub base: ModelProviderConfig,
1362}
1363
1364// ── Hyperbolic ──
1365
1366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1367#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1368#[serde(rename_all = "snake_case")]
1369pub enum HyperbolicEndpoint {
1370    #[default]
1371    Default,
1372}
1373
1374impl ModelEndpoint for HyperbolicEndpoint {
1375    fn uri(&self) -> &'static str {
1376        match self {
1377            Self::Default => "https://api.hyperbolic.xyz/v1",
1378        }
1379    }
1380}
1381
1382#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1383#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1384#[prefix = "providers.models.hyperbolic"]
1385pub struct HyperbolicModelProviderConfig {
1386    #[nested]
1387    #[serde(flatten)]
1388    pub base: ModelProviderConfig,
1389}
1390
1391// ── DeepInfra ──
1392
1393#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1394#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1395#[serde(rename_all = "snake_case")]
1396pub enum DeepinfraEndpoint {
1397    #[default]
1398    Default,
1399}
1400
1401impl ModelEndpoint for DeepinfraEndpoint {
1402    fn uri(&self) -> &'static str {
1403        match self {
1404            Self::Default => "https://api.deepinfra.com/v1/openai",
1405        }
1406    }
1407}
1408
1409#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1410#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1411#[prefix = "providers.models.deepinfra"]
1412pub struct DeepinfraModelProviderConfig {
1413    #[nested]
1414    #[serde(flatten)]
1415    pub base: ModelProviderConfig,
1416}
1417
1418// ── Hugging Face ──
1419
1420#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1421#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1422#[serde(rename_all = "snake_case")]
1423pub enum HuggingfaceEndpoint {
1424    #[default]
1425    Default,
1426}
1427
1428impl ModelEndpoint for HuggingfaceEndpoint {
1429    fn uri(&self) -> &'static str {
1430        match self {
1431            Self::Default => "https://router.huggingface.co/v1",
1432        }
1433    }
1434}
1435
1436#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1437#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1438#[prefix = "providers.models.huggingface"]
1439pub struct HuggingfaceModelProviderConfig {
1440    #[nested]
1441    #[serde(flatten)]
1442    pub base: ModelProviderConfig,
1443}
1444
1445// ── AI21 ──
1446
1447#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1448#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1449#[serde(rename_all = "snake_case")]
1450pub enum Ai21Endpoint {
1451    #[default]
1452    Default,
1453}
1454impl ModelEndpoint for Ai21Endpoint {
1455    fn uri(&self) -> &'static str {
1456        match self {
1457            Self::Default => "https://api.ai21.com/studio/v1",
1458        }
1459    }
1460}
1461#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1462#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1463#[prefix = "providers.models.ai21"]
1464pub struct Ai21ModelProviderConfig {
1465    #[nested]
1466    #[serde(flatten)]
1467    pub base: ModelProviderConfig,
1468}
1469
1470// ── Reka ──
1471
1472#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1473#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1474#[serde(rename_all = "snake_case")]
1475pub enum RekaEndpoint {
1476    #[default]
1477    Default,
1478}
1479impl ModelEndpoint for RekaEndpoint {
1480    fn uri(&self) -> &'static str {
1481        match self {
1482            Self::Default => "https://api.reka.ai/v1",
1483        }
1484    }
1485}
1486#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1487#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1488#[prefix = "providers.models.reka"]
1489pub struct RekaModelProviderConfig {
1490    #[nested]
1491    #[serde(flatten)]
1492    pub base: ModelProviderConfig,
1493}
1494
1495// ── BaseTen ──
1496
1497#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1498#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1499#[serde(rename_all = "snake_case")]
1500pub enum BasetenEndpoint {
1501    #[default]
1502    Default,
1503}
1504impl ModelEndpoint for BasetenEndpoint {
1505    fn uri(&self) -> &'static str {
1506        match self {
1507            Self::Default => "https://inference.baseten.co/v1",
1508        }
1509    }
1510}
1511#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1512#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1513#[prefix = "providers.models.baseten"]
1514pub struct BasetenModelProviderConfig {
1515    #[nested]
1516    #[serde(flatten)]
1517    pub base: ModelProviderConfig,
1518}
1519
1520// ── NScale ──
1521
1522#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1523#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1524#[serde(rename_all = "snake_case")]
1525pub enum NscaleEndpoint {
1526    #[default]
1527    Default,
1528}
1529impl ModelEndpoint for NscaleEndpoint {
1530    fn uri(&self) -> &'static str {
1531        match self {
1532            Self::Default => "https://inference.api.nscale.com/v1",
1533        }
1534    }
1535}
1536#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1537#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1538#[prefix = "providers.models.nscale"]
1539pub struct NscaleModelProviderConfig {
1540    #[nested]
1541    #[serde(flatten)]
1542    pub base: ModelProviderConfig,
1543}
1544
1545// ── AnyScale ──
1546
1547#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1548#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1549#[serde(rename_all = "snake_case")]
1550pub enum AnyscaleEndpoint {
1551    #[default]
1552    Default,
1553}
1554impl ModelEndpoint for AnyscaleEndpoint {
1555    fn uri(&self) -> &'static str {
1556        match self {
1557            Self::Default => "https://api.endpoints.anyscale.com/v1",
1558        }
1559    }
1560}
1561#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1562#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1563#[prefix = "providers.models.anyscale"]
1564pub struct AnyscaleModelProviderConfig {
1565    #[nested]
1566    #[serde(flatten)]
1567    pub base: ModelProviderConfig,
1568}
1569
1570// ── Nebius ──
1571
1572#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1573#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1574#[serde(rename_all = "snake_case")]
1575pub enum NebiusEndpoint {
1576    #[default]
1577    Default,
1578}
1579impl ModelEndpoint for NebiusEndpoint {
1580    fn uri(&self) -> &'static str {
1581        match self {
1582            Self::Default => "https://api.studio.nebius.ai/v1",
1583        }
1584    }
1585}
1586#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1587#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1588#[prefix = "providers.models.nebius"]
1589pub struct NebiusModelProviderConfig {
1590    #[nested]
1591    #[serde(flatten)]
1592    pub base: ModelProviderConfig,
1593}
1594
1595// ── Friendli ──
1596
1597#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1598#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1599#[serde(rename_all = "snake_case")]
1600pub enum FriendliEndpoint {
1601    #[default]
1602    Default,
1603}
1604impl ModelEndpoint for FriendliEndpoint {
1605    fn uri(&self) -> &'static str {
1606        match self {
1607            Self::Default => "https://api.friendli.ai/serverless/v1",
1608        }
1609    }
1610}
1611#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1612#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1613#[prefix = "providers.models.friendli"]
1614pub struct FriendliModelProviderConfig {
1615    #[nested]
1616    #[serde(flatten)]
1617    pub base: ModelProviderConfig,
1618}
1619
1620// ── Stepfun ──
1621
1622#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1623#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1624#[serde(rename_all = "snake_case")]
1625pub enum StepfunEndpoint {
1626    /// Mainland China endpoint.
1627    Cn,
1628    /// International endpoint.
1629    #[default]
1630    Intl,
1631}
1632impl ModelEndpoint for StepfunEndpoint {
1633    fn uri(&self) -> &'static str {
1634        match self {
1635            Self::Cn => "https://api.stepfun.com/v1",
1636            Self::Intl => "https://api.stepfun.ai/v1",
1637        }
1638    }
1639}
1640#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1641#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1642#[prefix = "providers.models.stepfun"]
1643pub struct StepfunModelProviderConfig {
1644    #[nested]
1645    #[serde(flatten)]
1646    pub base: ModelProviderConfig,
1647    #[serde(default)]
1648    pub endpoint: StepfunEndpoint,
1649}
1650
1651impl FamilyEndpoint for StepfunModelProviderConfig {
1652    fn endpoint_uri(&self) -> Option<&'static str> {
1653        Some(self.endpoint.uri())
1654    }
1655}
1656
1657// ── AIHubMix ──
1658
1659#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1660#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1661#[serde(rename_all = "snake_case")]
1662pub enum AihubmixEndpoint {
1663    #[default]
1664    Default,
1665}
1666impl ModelEndpoint for AihubmixEndpoint {
1667    fn uri(&self) -> &'static str {
1668        match self {
1669            Self::Default => "https://aihubmix.com/v1",
1670        }
1671    }
1672}
1673#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1674#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1675#[prefix = "providers.models.aihubmix"]
1676pub struct AihubmixModelProviderConfig {
1677    #[nested]
1678    #[serde(flatten)]
1679    pub base: ModelProviderConfig,
1680}
1681
1682// ── SiliconFlow ──
1683
1684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1685#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1686#[serde(rename_all = "snake_case")]
1687pub enum SiliconflowEndpoint {
1688    #[default]
1689    Default,
1690}
1691impl ModelEndpoint for SiliconflowEndpoint {
1692    fn uri(&self) -> &'static str {
1693        match self {
1694            Self::Default => "https://api.siliconflow.com/v1",
1695        }
1696    }
1697}
1698#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1699#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1700#[prefix = "providers.models.siliconflow"]
1701pub struct SiliconflowModelProviderConfig {
1702    #[nested]
1703    #[serde(flatten)]
1704    pub base: ModelProviderConfig,
1705}
1706
1707// ── Astrai ──
1708
1709#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1710#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1711#[serde(rename_all = "snake_case")]
1712pub enum AstraiEndpoint {
1713    #[default]
1714    Default,
1715}
1716impl ModelEndpoint for AstraiEndpoint {
1717    fn uri(&self) -> &'static str {
1718        match self {
1719            Self::Default => "https://as-trai.com/v1",
1720        }
1721    }
1722}
1723#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1724#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1725#[prefix = "providers.models.astrai"]
1726pub struct AstraiModelProviderConfig {
1727    #[nested]
1728    #[serde(flatten)]
1729    pub base: ModelProviderConfig,
1730}
1731
1732// ── Avian ──
1733
1734#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1735#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1736#[serde(rename_all = "snake_case")]
1737pub enum AvianEndpoint {
1738    #[default]
1739    Default,
1740}
1741impl ModelEndpoint for AvianEndpoint {
1742    fn uri(&self) -> &'static str {
1743        match self {
1744            Self::Default => "https://api.avian.io/v1",
1745        }
1746    }
1747}
1748#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1749#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1750#[prefix = "providers.models.avian"]
1751pub struct AvianModelProviderConfig {
1752    #[nested]
1753    #[serde(flatten)]
1754    pub base: ModelProviderConfig,
1755}
1756
1757// ── DeepMyst ──
1758
1759#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1760#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1761#[serde(rename_all = "snake_case")]
1762pub enum DeepmystEndpoint {
1763    #[default]
1764    Default,
1765}
1766impl ModelEndpoint for DeepmystEndpoint {
1767    fn uri(&self) -> &'static str {
1768        match self {
1769            Self::Default => "https://api.deepmyst.com/v1",
1770        }
1771    }
1772}
1773#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1774#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1775#[prefix = "providers.models.deepmyst"]
1776pub struct DeepmystModelProviderConfig {
1777    #[nested]
1778    #[serde(flatten)]
1779    pub base: ModelProviderConfig,
1780}
1781
1782// ── Venice ──
1783
1784#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1785#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1786#[serde(rename_all = "snake_case")]
1787pub enum VeniceEndpoint {
1788    #[default]
1789    Default,
1790}
1791impl ModelEndpoint for VeniceEndpoint {
1792    fn uri(&self) -> &'static str {
1793        match self {
1794            Self::Default => "https://api.venice.ai",
1795        }
1796    }
1797}
1798#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1799#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1800#[prefix = "providers.models.venice"]
1801pub struct VeniceModelProviderConfig {
1802    #[nested]
1803    #[serde(flatten)]
1804    pub base: ModelProviderConfig,
1805}
1806
1807// ── Novita ──
1808
1809#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1810#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1811#[serde(rename_all = "snake_case")]
1812pub enum NovitaEndpoint {
1813    #[default]
1814    Default,
1815}
1816impl ModelEndpoint for NovitaEndpoint {
1817    fn uri(&self) -> &'static str {
1818        match self {
1819            Self::Default => "https://api.novita.ai/openai",
1820        }
1821    }
1822}
1823#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1824#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1825#[prefix = "providers.models.novita"]
1826pub struct NovitaModelProviderConfig {
1827    #[nested]
1828    #[serde(flatten)]
1829    pub base: ModelProviderConfig,
1830}
1831
1832// ── NVIDIA ──
1833
1834#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1835#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1836#[serde(rename_all = "snake_case")]
1837pub enum NvidiaEndpoint {
1838    #[default]
1839    Default,
1840}
1841impl ModelEndpoint for NvidiaEndpoint {
1842    fn uri(&self) -> &'static str {
1843        match self {
1844            Self::Default => "https://integrate.api.nvidia.com/v1",
1845        }
1846    }
1847}
1848#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1849#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1850#[prefix = "providers.models.nvidia"]
1851pub struct NvidiaModelProviderConfig {
1852    #[nested]
1853    #[serde(flatten)]
1854    pub base: ModelProviderConfig,
1855}
1856
1857// ── Telnyx ──
1858
1859#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1860#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1861#[serde(rename_all = "snake_case")]
1862pub enum TelnyxEndpoint {
1863    #[default]
1864    Default,
1865}
1866impl ModelEndpoint for TelnyxEndpoint {
1867    fn uri(&self) -> &'static str {
1868        match self {
1869            Self::Default => "https://api.telnyx.com/v2",
1870        }
1871    }
1872}
1873#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1874#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1875#[prefix = "providers.models.telnyx"]
1876pub struct TelnyxModelProviderConfig {
1877    #[nested]
1878    #[serde(flatten)]
1879    pub base: ModelProviderConfig,
1880}
1881
1882// ── Vercel AI Gateway ──
1883
1884#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1885#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1886#[serde(rename_all = "snake_case")]
1887pub enum VercelEndpoint {
1888    #[default]
1889    Default,
1890}
1891impl ModelEndpoint for VercelEndpoint {
1892    fn uri(&self) -> &'static str {
1893        match self {
1894            Self::Default => "https://ai-gateway.vercel.sh/v1",
1895        }
1896    }
1897}
1898#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1899#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1900#[prefix = "providers.models.vercel"]
1901pub struct VercelModelProviderConfig {
1902    #[nested]
1903    #[serde(flatten)]
1904    pub base: ModelProviderConfig,
1905}
1906
1907// ── Cloudflare AI Gateway ──
1908
1909#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1910#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1911#[serde(rename_all = "snake_case")]
1912pub enum CloudflareEndpoint {
1913    #[default]
1914    Default,
1915}
1916impl ModelEndpoint for CloudflareEndpoint {
1917    fn uri(&self) -> &'static str {
1918        match self {
1919            Self::Default => "https://gateway.ai.cloudflare.com/v1",
1920        }
1921    }
1922}
1923#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1924#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1925#[prefix = "providers.models.cloudflare"]
1926pub struct CloudflareModelProviderConfig {
1927    #[nested]
1928    #[serde(flatten)]
1929    pub base: ModelProviderConfig,
1930}
1931
1932// ── OVH ──
1933
1934#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1935#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1936#[serde(rename_all = "snake_case")]
1937pub enum OvhEndpoint {
1938    #[default]
1939    Default,
1940}
1941impl ModelEndpoint for OvhEndpoint {
1942    fn uri(&self) -> &'static str {
1943        match self {
1944            Self::Default => "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1",
1945        }
1946    }
1947}
1948#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1949#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1950#[prefix = "providers.models.ovh"]
1951pub struct OvhModelProviderConfig {
1952    #[nested]
1953    #[serde(flatten)]
1954    pub base: ModelProviderConfig,
1955}
1956
1957// ── GitHub Copilot ──
1958
1959#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1960#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1961#[serde(rename_all = "snake_case")]
1962pub enum CopilotEndpoint {
1963    #[default]
1964    Default,
1965}
1966impl ModelEndpoint for CopilotEndpoint {
1967    fn uri(&self) -> &'static str {
1968        match self {
1969            Self::Default => "https://api.githubcopilot.com",
1970        }
1971    }
1972}
1973#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1974#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1975#[prefix = "providers.models.copilot"]
1976pub struct CopilotModelProviderConfig {
1977    #[nested]
1978    #[serde(flatten)]
1979    pub base: ModelProviderConfig,
1980}
1981
1982// ── GLM (multi-region) ──
1983
1984#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1985#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1986#[serde(rename_all = "snake_case")]
1987pub enum GlmEndpoint {
1988    Cn,
1989    #[default]
1990    Global,
1991}
1992impl ModelEndpoint for GlmEndpoint {
1993    fn uri(&self) -> &'static str {
1994        match self {
1995            Self::Cn => "https://open.bigmodel.cn/api/paas/v4",
1996            Self::Global => "https://api.z.ai/api/paas/v4",
1997        }
1998    }
1999}
2000#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2001#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2002#[prefix = "providers.models.glm"]
2003pub struct GlmModelProviderConfig {
2004    #[nested]
2005    #[serde(flatten)]
2006    pub base: ModelProviderConfig,
2007    #[serde(default)]
2008    pub endpoint: GlmEndpoint,
2009}
2010
2011impl FamilyEndpoint for GlmModelProviderConfig {
2012    fn endpoint_uri(&self) -> Option<&'static str> {
2013        Some(self.endpoint.uri())
2014    }
2015}
2016
2017// ── Minimax (multi-region + auth_mode) ──
2018
2019#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2020#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2021#[serde(rename_all = "snake_case")]
2022pub enum MinimaxEndpoint {
2023    Cn,
2024    #[default]
2025    Intl,
2026}
2027impl ModelEndpoint for MinimaxEndpoint {
2028    fn uri(&self) -> &'static str {
2029        match self {
2030            Self::Cn => "https://api.minimaxi.com/v1",
2031            Self::Intl => "https://api.minimax.io/v1",
2032        }
2033    }
2034}
2035
2036impl MinimaxEndpoint {
2037    /// OAuth `/oauth/token` endpoint for this region. Used by
2038    /// `refresh_minimax_oauth_access_token` to mint short-lived access
2039    /// tokens from the operator-supplied `oauth_refresh_token`.
2040    pub fn oauth_token_endpoint(self) -> &'static str {
2041        match self {
2042            Self::Cn => "https://api.minimaxi.com/oauth/token",
2043            Self::Intl => "https://api.minimax.io/oauth/token",
2044        }
2045    }
2046}
2047
2048#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2049#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2050#[prefix = "providers.models.minimax"]
2051pub struct MinimaxModelProviderConfig {
2052    #[nested]
2053    #[serde(flatten)]
2054    pub base: ModelProviderConfig,
2055    #[serde(default)]
2056    pub endpoint: MinimaxEndpoint,
2057    #[serde(default, skip_serializing_if = "Option::is_none")]
2058    pub auth_mode: Option<AuthMode>,
2059    /// Long-lived OAuth refresh token issued by MiniMax. When set, the
2060    /// runtime exchanges it for a short-lived access token at provider
2061    /// construction time and uses that as the API credential. Operators
2062    /// who prefer dashboard-generated long-lived API keys can leave this
2063    /// unset and populate `api_key` directly.
2064    #[serde(default, skip_serializing_if = "Option::is_none")]
2065    #[secret(category = "model_provider")]
2066    pub oauth_refresh_token: Option<String>,
2067    /// Override of MiniMax's published OAuth client_id. Most operators
2068    /// should leave this unset — the runtime defaults to the
2069    /// vendor-published client_id (same one MiniMax's own portal uses).
2070    #[serde(default, skip_serializing_if = "Option::is_none")]
2071    pub oauth_client_id: Option<String>,
2072}
2073
2074impl FamilyEndpoint for MinimaxModelProviderConfig {
2075    fn endpoint_uri(&self) -> Option<&'static str> {
2076        Some(self.endpoint.uri())
2077    }
2078}
2079
2080// ── Z.AI (multi-region) ──
2081
2082#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2083#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2084#[serde(rename_all = "snake_case")]
2085pub enum ZaiEndpoint {
2086    Cn,
2087    #[default]
2088    Global,
2089}
2090impl ModelEndpoint for ZaiEndpoint {
2091    fn uri(&self) -> &'static str {
2092        match self {
2093            Self::Cn => "https://open.bigmodel.cn/api/coding/paas/v4",
2094            Self::Global => "https://api.z.ai/api/coding/paas/v4",
2095        }
2096    }
2097}
2098#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2099#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2100#[prefix = "providers.models.zai"]
2101pub struct ZaiModelProviderConfig {
2102    #[nested]
2103    #[serde(flatten)]
2104    pub base: ModelProviderConfig,
2105    #[serde(default)]
2106    pub endpoint: ZaiEndpoint,
2107}
2108
2109impl FamilyEndpoint for ZaiModelProviderConfig {
2110    fn endpoint_uri(&self) -> Option<&'static str> {
2111        Some(self.endpoint.uri())
2112    }
2113}
2114
2115// ── Doubao (Volcengine; single canonical endpoint) ──
2116
2117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2118#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2119#[serde(rename_all = "snake_case")]
2120pub enum DoubaoEndpoint {
2121    #[default]
2122    Default,
2123}
2124impl ModelEndpoint for DoubaoEndpoint {
2125    fn uri(&self) -> &'static str {
2126        match self {
2127            Self::Default => "https://ark.cn-beijing.volces.com/api/v3",
2128        }
2129    }
2130}
2131#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2132#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2133#[prefix = "providers.models.doubao"]
2134pub struct DoubaoModelProviderConfig {
2135    #[nested]
2136    #[serde(flatten)]
2137    pub base: ModelProviderConfig,
2138}
2139
2140// ── Yi (Lingyiwanwu; single endpoint) ──
2141
2142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2143#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2144#[serde(rename_all = "snake_case")]
2145pub enum YiEndpoint {
2146    #[default]
2147    Default,
2148}
2149impl ModelEndpoint for YiEndpoint {
2150    fn uri(&self) -> &'static str {
2151        match self {
2152            Self::Default => "https://api.lingyiwanwu.com/v1",
2153        }
2154    }
2155}
2156#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2157#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2158#[prefix = "providers.models.yi"]
2159pub struct YiModelProviderConfig {
2160    #[nested]
2161    #[serde(flatten)]
2162    pub base: ModelProviderConfig,
2163}
2164
2165// ── Hunyuan (Tencent; single endpoint) ──
2166
2167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2168#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2169#[serde(rename_all = "snake_case")]
2170pub enum HunyuanEndpoint {
2171    #[default]
2172    Default,
2173}
2174impl ModelEndpoint for HunyuanEndpoint {
2175    fn uri(&self) -> &'static str {
2176        match self {
2177            Self::Default => "https://api.hunyuan.cloud.tencent.com/v1",
2178        }
2179    }
2180}
2181#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2182#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2183#[prefix = "providers.models.hunyuan"]
2184pub struct HunyuanModelProviderConfig {
2185    #[nested]
2186    #[serde(flatten)]
2187    pub base: ModelProviderConfig,
2188}
2189
2190// ── Qianfan (Baidu) ──
2191
2192#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2193#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2194#[serde(rename_all = "snake_case")]
2195pub enum QianfanEndpoint {
2196    #[default]
2197    Default,
2198}
2199impl ModelEndpoint for QianfanEndpoint {
2200    fn uri(&self) -> &'static str {
2201        match self {
2202            Self::Default => "https://qianfan.baidubce.com/v2",
2203        }
2204    }
2205}
2206#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2207#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2208#[prefix = "providers.models.qianfan"]
2209pub struct QianfanModelProviderConfig {
2210    #[nested]
2211    #[serde(flatten)]
2212    pub base: ModelProviderConfig,
2213}
2214
2215// ── Baichuan ──
2216
2217#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2218#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2219#[serde(rename_all = "snake_case")]
2220pub enum BaichuanEndpoint {
2221    #[default]
2222    Default,
2223}
2224impl ModelEndpoint for BaichuanEndpoint {
2225    fn uri(&self) -> &'static str {
2226        match self {
2227            Self::Default => "https://api.baichuan-ai.com/v1",
2228        }
2229    }
2230}
2231#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2232#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2233#[prefix = "providers.models.baichuan"]
2234pub struct BaichuanModelProviderConfig {
2235    #[nested]
2236    #[serde(flatten)]
2237    pub base: ModelProviderConfig,
2238}
2239
2240// ── Gemini (OAuth-capable) ──
2241
2242#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2243#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2244#[serde(rename_all = "snake_case")]
2245pub enum GeminiEndpoint {
2246    #[default]
2247    Default,
2248}
2249impl ModelEndpoint for GeminiEndpoint {
2250    fn uri(&self) -> &'static str {
2251        match self {
2252            Self::Default => "https://generativelanguage.googleapis.com/v1beta",
2253        }
2254    }
2255}
2256#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2257#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2258#[prefix = "providers.models.gemini"]
2259pub struct GeminiModelProviderConfig {
2260    #[nested]
2261    #[serde(flatten)]
2262    pub base: ModelProviderConfig,
2263    /// Auth flow. Defaults to `api_key`; `oauth` uses GeminiModelProvider's
2264    /// OAuth-cache integration instead of the `api_key` field.
2265    #[serde(default, skip_serializing_if = "Option::is_none")]
2266    pub auth_mode: Option<AuthMode>,
2267    /// Google OAuth app `client_id`, used when this alias drives ZeroClaw's
2268    /// own browser/device-code login flow (`zeroclaw auth login
2269    /// --model-provider gemini --profile <alias>`). Operators relying on
2270    /// the upstream `gemini login` tool don't need this — that tool writes
2271    /// its own client_id / client_secret into `~/.gemini/oauth_creds.json`.
2272    #[serde(default, skip_serializing_if = "Option::is_none")]
2273    #[secret(category = "model_provider")]
2274    pub oauth_client_id: Option<String>,
2275    /// Google OAuth app `client_secret`. Set alongside `oauth_client_id`.
2276    #[serde(default, skip_serializing_if = "Option::is_none")]
2277    #[secret(category = "model_provider")]
2278    pub oauth_client_secret: Option<String>,
2279    /// Pin a specific GCP project ID for the OAuth `loadCodeAssist`
2280    /// discovery call. When unset, the discovery probes for an
2281    /// already-onboarded project on the credential's account. Replaces
2282    /// `GOOGLE_CLOUD_PROJECT` / `GOOGLE_CLOUD_PROJECT_ID` env vars.
2283    #[serde(default, skip_serializing_if = "Option::is_none")]
2284    pub oauth_project: Option<String>,
2285}
2286
2287// ── Gemini CLI (subprocess wrapper) ──
2288
2289#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2290#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2291#[serde(rename_all = "snake_case")]
2292pub enum GeminiCliEndpoint {
2293    #[default]
2294    LocalSubprocess,
2295}
2296impl ModelEndpoint for GeminiCliEndpoint {
2297    fn uri(&self) -> &'static str {
2298        // Subprocess — no remote endpoint. Sentinel for trait conformity.
2299        "subprocess://gemini-cli"
2300    }
2301}
2302#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2303#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2304#[prefix = "providers.models.gemini_cli"]
2305pub struct GeminiCliModelProviderConfig {
2306    #[nested]
2307    #[serde(flatten)]
2308    pub base: ModelProviderConfig,
2309    /// Path to the `gemini` CLI binary. Falls back to `gemini` (PATH lookup).
2310    #[serde(default, skip_serializing_if = "Option::is_none")]
2311    pub binary_path: Option<String>,
2312}
2313
2314// ── LMStudio (local default) ──
2315
2316#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2317#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2318#[serde(rename_all = "snake_case")]
2319pub enum LmstudioEndpoint {
2320    #[default]
2321    LocalDefault,
2322}
2323impl ModelEndpoint for LmstudioEndpoint {
2324    fn uri(&self) -> &'static str {
2325        match self {
2326            Self::LocalDefault => "http://localhost:1234/v1",
2327        }
2328    }
2329}
2330#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2331#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2332#[prefix = "providers.models.lmstudio"]
2333pub struct LmstudioModelProviderConfig {
2334    #[nested]
2335    #[serde(flatten)]
2336    pub base: ModelProviderConfig,
2337}
2338
2339// ── llama.cpp (local default) ──
2340
2341#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2342#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2343#[serde(rename_all = "snake_case")]
2344pub enum LlamacppEndpoint {
2345    #[default]
2346    LocalDefault,
2347}
2348impl ModelEndpoint for LlamacppEndpoint {
2349    fn uri(&self) -> &'static str {
2350        match self {
2351            Self::LocalDefault => "http://localhost:8080/v1",
2352        }
2353    }
2354}
2355#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2356#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2357#[prefix = "providers.models.llamacpp"]
2358pub struct LlamacppModelProviderConfig {
2359    #[nested]
2360    #[serde(flatten)]
2361    pub base: ModelProviderConfig,
2362}
2363
2364// ── SGLang (local default) ──
2365
2366#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2367#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2368#[serde(rename_all = "snake_case")]
2369pub enum SglangEndpoint {
2370    #[default]
2371    LocalDefault,
2372}
2373impl ModelEndpoint for SglangEndpoint {
2374    fn uri(&self) -> &'static str {
2375        match self {
2376            Self::LocalDefault => "http://localhost:30000/v1",
2377        }
2378    }
2379}
2380#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2381#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2382#[prefix = "providers.models.sglang"]
2383pub struct SglangModelProviderConfig {
2384    #[nested]
2385    #[serde(flatten)]
2386    pub base: ModelProviderConfig,
2387}
2388
2389// ── vLLM (local default) ──
2390
2391#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2392#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2393#[serde(rename_all = "snake_case")]
2394pub enum VllmEndpoint {
2395    #[default]
2396    LocalDefault,
2397}
2398impl ModelEndpoint for VllmEndpoint {
2399    fn uri(&self) -> &'static str {
2400        match self {
2401            Self::LocalDefault => "http://localhost:8000/v1",
2402        }
2403    }
2404}
2405#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2406#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2407#[prefix = "providers.models.vllm"]
2408pub struct VllmModelProviderConfig {
2409    #[nested]
2410    #[serde(flatten)]
2411    pub base: ModelProviderConfig,
2412}
2413
2414// ── Osaurus (local default) ──
2415
2416#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2417#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2418#[serde(rename_all = "snake_case")]
2419pub enum OsaurusEndpoint {
2420    #[default]
2421    LocalDefault,
2422}
2423impl ModelEndpoint for OsaurusEndpoint {
2424    fn uri(&self) -> &'static str {
2425        match self {
2426            Self::LocalDefault => "http://localhost:1337/v1",
2427        }
2428    }
2429}
2430#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2431#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2432#[prefix = "providers.models.osaurus"]
2433pub struct OsaurusModelProviderConfig {
2434    #[nested]
2435    #[serde(flatten)]
2436    pub base: ModelProviderConfig,
2437}
2438
2439// ── LiteLLM (operator-self-hosted gateway) ──
2440
2441#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2442#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2443#[serde(rename_all = "snake_case")]
2444pub enum LitellmEndpoint {
2445    #[default]
2446    LocalDefault,
2447}
2448impl ModelEndpoint for LitellmEndpoint {
2449    fn uri(&self) -> &'static str {
2450        match self {
2451            Self::LocalDefault => "http://localhost:4000/v1",
2452        }
2453    }
2454}
2455#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2456#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2457#[prefix = "providers.models.litellm"]
2458pub struct LitellmModelProviderConfig {
2459    #[nested]
2460    #[serde(flatten)]
2461    pub base: ModelProviderConfig,
2462}
2463
2464// ── Lepton ──
2465
2466#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2467#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2468#[serde(rename_all = "snake_case")]
2469pub enum LeptonEndpoint {
2470    #[default]
2471    Default,
2472}
2473impl ModelEndpoint for LeptonEndpoint {
2474    fn uri(&self) -> &'static str {
2475        match self {
2476            Self::Default => "https://llama3-1-405b.lepton.run/api/v1",
2477        }
2478    }
2479}
2480#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2481#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2482#[prefix = "providers.models.lepton"]
2483pub struct LeptonModelProviderConfig {
2484    #[nested]
2485    #[serde(flatten)]
2486    pub base: ModelProviderConfig,
2487}
2488
2489// ── Synthetic ──
2490
2491#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2492#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2493#[serde(rename_all = "snake_case")]
2494pub enum SyntheticEndpoint {
2495    #[default]
2496    Default,
2497}
2498impl ModelEndpoint for SyntheticEndpoint {
2499    fn uri(&self) -> &'static str {
2500        match self {
2501            Self::Default => "https://api.synthetic.new/openai/v1",
2502        }
2503    }
2504}
2505#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2506#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2507#[prefix = "providers.models.synthetic"]
2508pub struct SyntheticModelProviderConfig {
2509    #[nested]
2510    #[serde(flatten)]
2511    pub base: ModelProviderConfig,
2512}
2513
2514// ── OpenCode (Zen) ──
2515
2516#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2517#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2518#[serde(rename_all = "snake_case")]
2519pub enum OpencodeEndpoint {
2520    #[default]
2521    Default,
2522}
2523impl ModelEndpoint for OpencodeEndpoint {
2524    fn uri(&self) -> &'static str {
2525        match self {
2526            Self::Default => "https://opencode.ai/zen/v1",
2527        }
2528    }
2529}
2530#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2531#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2532#[prefix = "providers.models.opencode"]
2533pub struct OpencodeModelProviderConfig {
2534    #[nested]
2535    #[serde(flatten)]
2536    pub base: ModelProviderConfig,
2537}
2538
2539// ── KiloCli (subprocess wrapper) ──
2540
2541#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2542#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2543#[serde(rename_all = "snake_case")]
2544pub enum KiloCliEndpoint {
2545    #[default]
2546    LocalSubprocess,
2547}
2548impl ModelEndpoint for KiloCliEndpoint {
2549    fn uri(&self) -> &'static str {
2550        match self {
2551            Self::LocalSubprocess => "subprocess://kilocli",
2552        }
2553    }
2554}
2555#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2556#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2557#[prefix = "providers.models.kilocli"]
2558pub struct KiloCliModelProviderConfig {
2559    #[nested]
2560    #[serde(flatten)]
2561    pub base: ModelProviderConfig,
2562    /// Path to the `kilo` CLI binary. Falls back to `kilo` (PATH lookup).
2563    #[serde(default, skip_serializing_if = "Option::is_none")]
2564    pub binary_path: Option<String>,
2565}
2566
2567// ── Custom (user-supplied URL, no canonical default) ──
2568
2569/// Custom catch-all for operator-defined endpoints. The endpoint variant has
2570/// no canonical URL — operators must always set `base.uri`. The trait return
2571/// is a sentinel string; the runtime constructor must verify `base.uri` is
2572/// set for `custom` entries and fail with a clear error if not.
2573#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2574#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2575#[serde(rename_all = "snake_case")]
2576pub enum CustomEndpoint {
2577    #[default]
2578    OperatorSupplied,
2579}
2580impl ModelEndpoint for CustomEndpoint {
2581    fn uri(&self) -> &'static str {
2582        match self {
2583            Self::OperatorSupplied => "operator-supplied:set-cfg-uri",
2584        }
2585    }
2586}
2587#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2588#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2589#[prefix = "providers.models.custom"]
2590pub struct CustomModelProviderConfig {
2591    #[nested]
2592    #[serde(flatten)]
2593    pub base: ModelProviderConfig,
2594}
2595
2596// ── Bedrock (computed-endpoint exemplar, AWS region template) ──
2597
2598/// AWS Bedrock endpoint template. Single variant; the URL is computed at
2599/// runtime by substituting `{region}` from the typed config field.
2600#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2601#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2602#[serde(rename_all = "snake_case")]
2603pub enum BedrockEndpoint {
2604    #[default]
2605    Default,
2606}
2607
2608impl ModelEndpoint for BedrockEndpoint {
2609    fn uri(&self) -> &'static str {
2610        match self {
2611            // Bedrock URI is a template — substitution happens in the
2612            // BedrockModelProvider runtime constructor against cfg.region.
2613            Self::Default => "https://bedrock-runtime.{region}.amazonaws.com",
2614        }
2615    }
2616}
2617
2618/// AWS Bedrock model model_provider config. Carries the AWS region (the URI
2619/// template substitutes `{region}` from this field). Bedrock auth is
2620/// SigV4 — credentials come from the standard AWS credential chain
2621/// (env vars, instance metadata, profile), not from `api_key`.
2622#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2623#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2624#[prefix = "providers.models.bedrock"]
2625pub struct BedrockModelProviderConfig {
2626    #[nested]
2627    #[serde(flatten)]
2628    pub base: ModelProviderConfig,
2629    /// AWS region for the Bedrock endpoint (e.g. `us-east-1`, `eu-west-1`).
2630    #[serde(default, skip_serializing_if = "Option::is_none")]
2631    pub region: Option<String>,
2632}
2633
2634// ── FamilyEndpoint default impls (single-endpoint families) ─────
2635//
2636// Multi-endpoint families (Moonshot, Qwen, Glm, Minimax, Zai, Stepfun) define
2637// their own `impl FamilyEndpoint` next to the struct. Every other family
2638// gets the `None` default via this list. The list is exhaustive: a new
2639// family with no impl here AND no manual impl elsewhere will fail to
2640// compile against `ModelProviders::resolved_endpoint_uri`, which expands
2641// `endpoint_uri()` per slot through `for_each_model_provider_slot!`.
2642
2643macro_rules! impl_default_family_endpoint {
2644    ($($t:ty),+ $(,)?) => {
2645        $( impl FamilyEndpoint for $t {} )+
2646    };
2647}
2648
2649impl_default_family_endpoint! {
2650    OpenAIModelProviderConfig,
2651    AzureModelProviderConfig,
2652    AnthropicModelProviderConfig,
2653    AtomicChatModelProviderConfig,
2654    OpenRouterModelProviderConfig,
2655    OllamaModelProviderConfig,
2656    TogetherModelProviderConfig,
2657    FireworksModelProviderConfig,
2658    GroqModelProviderConfig,
2659    MistralModelProviderConfig,
2660    DeepseekModelProviderConfig,
2661    CohereModelProviderConfig,
2662    PerplexityModelProviderConfig,
2663    XaiModelProviderConfig,
2664    CerebrasModelProviderConfig,
2665    SambanovaModelProviderConfig,
2666    HyperbolicModelProviderConfig,
2667    DeepinfraModelProviderConfig,
2668    HuggingfaceModelProviderConfig,
2669    Ai21ModelProviderConfig,
2670    RekaModelProviderConfig,
2671    BasetenModelProviderConfig,
2672    NscaleModelProviderConfig,
2673    AnyscaleModelProviderConfig,
2674    NebiusModelProviderConfig,
2675    FriendliModelProviderConfig,
2676    AihubmixModelProviderConfig,
2677    SiliconflowModelProviderConfig,
2678    AstraiModelProviderConfig,
2679    AvianModelProviderConfig,
2680    DeepmystModelProviderConfig,
2681    VeniceModelProviderConfig,
2682    NovitaModelProviderConfig,
2683    NvidiaModelProviderConfig,
2684    TelnyxModelProviderConfig,
2685    VercelModelProviderConfig,
2686    CloudflareModelProviderConfig,
2687    OvhModelProviderConfig,
2688    CopilotModelProviderConfig,
2689    DoubaoModelProviderConfig,
2690    YiModelProviderConfig,
2691    HunyuanModelProviderConfig,
2692    QianfanModelProviderConfig,
2693    BaichuanModelProviderConfig,
2694    GeminiModelProviderConfig,
2695    GeminiCliModelProviderConfig,
2696    LmstudioModelProviderConfig,
2697    LlamacppModelProviderConfig,
2698    SglangModelProviderConfig,
2699    VllmModelProviderConfig,
2700    OsaurusModelProviderConfig,
2701    LitellmModelProviderConfig,
2702    LeptonModelProviderConfig,
2703    SyntheticModelProviderConfig,
2704    OpencodeModelProviderConfig,
2705    KiloCliModelProviderConfig,
2706    CustomModelProviderConfig,
2707    BedrockModelProviderConfig,
2708}
2709
2710// ── Delegate Tool Configuration ─────────────────────────────────
2711
2712/// Global delegate tool configuration for default timeout values.
2713#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
2714#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2715#[prefix = "delegate"]
2716pub struct DelegateToolConfig {
2717    /// Default timeout in seconds for non-agentic sub-agent model_provider calls.
2718    /// Can be overridden per-agent in `[agents.<name>]` config.
2719    /// Default: 120 seconds.
2720    #[serde(default = "default_delegate_timeout_secs")]
2721    pub timeout_secs: u64,
2722    /// Default timeout in seconds for agentic sub-agent runs.
2723    /// Can be overridden per-agent in `[agents.<name>]` config.
2724    /// Default: 300 seconds.
2725    #[serde(default = "default_delegate_agentic_timeout_secs")]
2726    pub agentic_timeout_secs: u64,
2727}
2728
2729impl Default for DelegateToolConfig {
2730    fn default() -> Self {
2731        Self {
2732            timeout_secs: DEFAULT_DELEGATE_TIMEOUT_SECS,
2733            agentic_timeout_secs: DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS,
2734        }
2735    }
2736}
2737
2738// ── Aliased Agents ───────────────────────────────────────────────
2739
2740/// Configuration for an aliased agent. Each `[agents.<alias>]` TOML
2741/// block deserializes into one of these. The `DelegateTool` looks up
2742/// entries here to dispatch a subtask to a named sibling agent.
2743#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
2744#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2745#[prefix = "delegate-agent"]
2746pub struct AliasedAgentConfig {
2747    /// Whether this agent is active. Set false to disable without removing the definition.
2748    #[serde(default = "default_true")]
2749    pub enabled: bool,
2750    /// Channel aliases this agent handles (e.g. `["telegram.<alias>", "discord.<alias>"]`).
2751    /// Each entry is a `ChannelRef` resolving through `[channels.<type>.<alias>]`;
2752    /// `Config::validate()` fails loud on dangling references.
2753    #[serde(default)]
2754    pub channels: Vec<crate::providers::ChannelRef>,
2755    /// Dotted model-provider alias (e.g. `"anthropic.<alias>"`).
2756    /// Resolves through `model_providers.<type>.<alias>` at runtime;
2757    /// `Config::validate()` fails loud on dangling references.
2758    #[serde(default)]
2759    pub model_provider: crate::providers::ModelProviderRef,
2760    /// Risk profile alias (e.g. `"default"`). Resolves delegation guardrails at runtime.
2761    #[serde(default)]
2762    pub risk_profile: String,
2763    /// Runtime profile alias (e.g. `"default"`). Resolves agentic/iteration settings.
2764    #[serde(default)]
2765    pub runtime_profile: String,
2766    /// Skill bundle aliases. Each entry resolves to
2767    /// `skill_bundles[key].directory` at runtime; the agent loads every
2768    /// listed bundle.
2769    #[serde(default)]
2770    pub skill_bundles: Vec<String>,
2771    /// Knowledge bundle aliases. Additive — the agent loads every listed
2772    /// bundle.
2773    #[serde(default)]
2774    pub knowledge_bundles: Vec<String>,
2775    /// MCP bundle aliases. Each entry references `mcp_bundles[key]`,
2776    /// itself a named group of MCP servers; agents pick which bundles to
2777    /// load.
2778    #[serde(default)]
2779    pub mcp_bundles: Vec<String>,
2780    /// Cron job aliases. Each entry references `cron[key]` — a declarative
2781    /// scheduled job invoked by the scheduler on its configured trigger.
2782    /// When the cron fires, this agent is the actor that executes the job.
2783    #[serde(default)]
2784    pub cron_jobs: Vec<String>,
2785    /// TTS provider as a dotted alias reference (`<type>.<alias>`,
2786    /// e.g. `"openai.<alias>"`). Resolves through `tts_providers.<type>.<alias>`.
2787    /// Empty = no TTS for this agent (there is no global default-provider concept;
2788    /// every agent that wants TTS sets its own `tts_provider`).
2789    #[serde(default)]
2790    pub tts_provider: crate::providers::TtsProviderRef,
2791    /// Transcription / STT provider as a dotted alias reference
2792    /// (`<type>.<alias>`, e.g. `"groq.<alias>"`). Resolves through
2793    /// `transcription_providers.<type>.<alias>`. Empty = agent has no
2794    /// transcription preference; channels that ingest voice still need a
2795    /// resolved provider (there is no global default), so an inbound voice
2796    /// flow into an agent with empty `transcription_provider` errors loudly
2797    /// at the channel boundary.
2798    #[serde(default)]
2799    pub transcription_provider: crate::providers::TranscriptionProviderRef,
2800
2801    /// Optional override for the per-message LLM reply-intent classifier
2802    /// (`classify_channel_reply_intent` in zeroclaw-channels). When non-empty,
2803    /// the channel orchestrator routes the "should this message be replied to?"
2804    /// classification call to `[providers.models.<type>.<alias>]` referenced
2805    /// here, instead of reusing the main agent's `model_provider`.
2806    ///
2807    /// Source of truth for api_key / uri / model / temperature etc. is the
2808    /// referenced `[providers.models.<type>.<alias>]` entry. This field is
2809    /// a reference only (NEVER a copy) — per AGENTS.md SINGLE SOURCE OF TRUTH.
2810    ///
2811    /// Empty (`Default`) = inherit the main agent's resolved provider+model
2812    /// (preserves pre-PR behavior; backward compatible).
2813    ///
2814    /// Use case: classification is a cheap REPLY/NO_REPLY decision, doesn't
2815    /// need a high-end model. Point this at a fast/free small model
2816    /// (e.g. `kimi-k2.5`, `qwen-turbo`) while `model_provider` stays on the
2817    /// expensive answering model (e.g. `qwen3.6-plus`).
2818    ///
2819    /// Note: TOML table names cannot contain `.`, so alias `kimi-k2.5`
2820    /// must be written as `[providers.models.custom.kimi-k2-5]`. The
2821    /// underlying `model = "kimi-k2.5"` string can still contain dots.
2822    ///
2823    /// ACP channels (IDE-direct) always reply and skip the classifier
2824    /// entirely — this field has no effect on ACP traffic.
2825    #[serde(default)]
2826    pub classifier_provider: crate::providers::ModelProviderRef,
2827
2828    // ── Agent loop / runtime tunables (folded from `[agent]` ──────
2829    // These are per-agent. Defaults preserve the legacy single-agent
2830    // behavior so an unconfigured agent runs identically to a config
2831    // that previously lived under a global `[agent]` table.
2832    /// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models.
2833    #[serde(default = "default_agent_compact_context")]
2834    pub compact_context: bool,
2835    /// Maximum tool-call loop turns per user message. Default: `10`.
2836    /// Setting to `0` falls back to the safe default of `10`.
2837    #[serde(default = "default_agent_max_tool_iterations")]
2838    pub max_tool_iterations: usize,
2839    /// Maximum conversation history messages retained per session. Default: `50`.
2840    #[serde(default = "default_agent_max_history_messages")]
2841    pub max_history_messages: usize,
2842    /// Maximum estimated tokens for conversation history before compaction triggers.
2843    /// Uses ~4 chars/token heuristic. When this threshold is exceeded, older messages
2844    /// are summarized to preserve context while staying within budget. Default: `32000`.
2845    #[serde(default = "default_agent_max_context_tokens")]
2846    pub max_context_tokens: usize,
2847    /// Enable parallel tool execution within a single iteration. Default: `false`.
2848    #[serde(default)]
2849    pub parallel_tools: bool,
2850    /// Tool dispatch strategy (e.g. `"auto"`). Default: `"auto"`.
2851    #[serde(default = "default_agent_tool_dispatcher")]
2852    pub tool_dispatcher: String,
2853    /// When true, only native provider tool calls are executable. Text fallback
2854    /// parsing remains disabled, so XML/markdown/GLM-looking text is treated as
2855    /// final assistant text.
2856    #[serde(default)]
2857    pub strict_tool_parsing: bool,
2858    /// Tools exempt from the within-turn duplicate-call dedup check. Default: `[]`.
2859    #[serde(default)]
2860    pub tool_call_dedup_exempt: Vec<String>,
2861    /// Per-turn MCP tool schema filtering groups.
2862    ///
2863    /// When non-empty, only MCP tools matched by an active group are included in the
2864    /// tool schema sent to the LLM for that turn. Built-in tools always pass through.
2865    /// Default: `[]` (no filtering — all tools included).
2866    #[serde(default)]
2867    pub tool_filter_groups: Vec<ToolFilterGroup>,
2868    /// Maximum characters for the assembled system prompt. When `> 0`, the prompt
2869    /// is truncated to this limit after assembly (keeping the top portion which
2870    /// contains identity and safety instructions). `0` means unlimited.
2871    /// Useful for small-context models (e.g. glm-4.5-air ~8K tokens → set to 8000).
2872    #[serde(default = "default_max_system_prompt_chars")]
2873    pub max_system_prompt_chars: usize,
2874    /// Thinking/reasoning level control. Configures how deeply the model reasons
2875    /// per message. Users can override per-message with `/think:<level>` directives.
2876    #[nested]
2877    #[serde(default)]
2878    pub thinking: crate::scattered_types::ThinkingConfig,
2879    /// History pruning configuration for token efficiency.
2880    #[nested]
2881    #[serde(default)]
2882    pub history_pruning: crate::scattered_types::HistoryPrunerConfig,
2883    /// Reply-intent precheck configuration for channel messages.
2884    #[nested]
2885    #[serde(default)]
2886    pub precheck: crate::scattered_types::ChannelPrecheckConfig,
2887    /// Enable context-aware tool filtering (only surface relevant tools per iteration).
2888    #[serde(default)]
2889    pub context_aware_tools: bool,
2890    /// Post-response quality evaluator configuration.
2891    #[nested]
2892    #[serde(default)]
2893    pub eval: crate::scattered_types::EvalConfig,
2894    /// Automatic complexity-based classification fallback.
2895    #[nested]
2896    #[serde(default)]
2897    pub auto_classify: Option<crate::scattered_types::AutoClassifyConfig>,
2898    /// Context compression configuration for automatic conversation compaction.
2899    #[nested]
2900    #[serde(default)]
2901    pub context_compression: crate::scattered_types::ContextCompressionConfig,
2902    /// Maximum characters for a single tool result before truncation.
2903    /// Head (2/3) and tail (1/3) are preserved with a truncation marker in the
2904    /// middle. Set to `0` to disable truncation. Default: `50000`.
2905    #[serde(default = "default_max_tool_result_chars")]
2906    pub max_tool_result_chars: usize,
2907    /// Number of most recent conversation turns whose full tool-call/result
2908    /// messages are preserved in channel conversation history. Older turns
2909    /// keep only the final assistant text. Set to `0` to disable (previous
2910    /// behavior). Default: `2`.
2911    #[serde(default = "default_keep_tool_context_turns")]
2912    pub keep_tool_context_turns: usize,
2913
2914    /// HMAC tool execution receipt configuration.
2915    #[nested]
2916    #[serde(default)]
2917    pub tool_receipts: ToolReceiptsConfig,
2918
2919    /// Per-agent workspace block (`[agents.<alias>.workspace]`).
2920    /// Holds the agent's filesystem path, cross-agent access allowlist,
2921    /// filesystem-escape boolean, and cross-agent memory allowlist.
2922    /// Default is fully jailed (no cross-agent access). See
2923    /// `crate::multi_agent::AgentWorkspaceConfig`.
2924    #[serde(default)]
2925    #[nested]
2926    pub workspace: crate::multi_agent::AgentWorkspaceConfig,
2927
2928    /// Per-agent memory backend selection (`[agents.<alias>.memory]`).
2929    /// The `backend` field is locked at agent creation and immutable on
2930    /// subsequent loads. Defaults to `Sqlite`. See
2931    /// `crate::multi_agent::AgentMemoryConfig`.
2932    #[serde(default)]
2933    #[nested]
2934    pub memory: crate::multi_agent::AgentMemoryConfig,
2935
2936    /// Per-agent identity format (`[agents.<alias>.identity]`). Each
2937    /// agent renders its own IDENTITY.md / SOUL.md inside its
2938    /// per-agent workspace; this block selects the format (OpenClaw or
2939    /// AIEOS) and optional inline/file source for the agent's identity
2940    /// document.
2941    #[serde(default)]
2942    #[nested]
2943    pub identity: IdentityConfig,
2944}
2945
2946fn default_agent_compact_context() -> bool {
2947    true
2948}
2949
2950impl Default for AliasedAgentConfig {
2951    fn default() -> Self {
2952        Self {
2953            enabled: true,
2954            channels: Vec::new(),
2955            model_provider: crate::providers::ModelProviderRef::default(),
2956            risk_profile: String::new(),
2957            runtime_profile: String::new(),
2958            skill_bundles: Vec::new(),
2959            knowledge_bundles: Vec::new(),
2960            mcp_bundles: Vec::new(),
2961            cron_jobs: Vec::new(),
2962            tts_provider: crate::providers::TtsProviderRef::default(),
2963            transcription_provider: crate::providers::TranscriptionProviderRef::default(),
2964            classifier_provider: crate::providers::ModelProviderRef::default(),
2965            compact_context: default_agent_compact_context(),
2966            max_tool_iterations: default_agent_max_tool_iterations(),
2967            max_history_messages: default_agent_max_history_messages(),
2968            max_context_tokens: default_agent_max_context_tokens(),
2969            parallel_tools: false,
2970            tool_dispatcher: default_agent_tool_dispatcher(),
2971            strict_tool_parsing: false,
2972            tool_call_dedup_exempt: Vec::new(),
2973            tool_filter_groups: Vec::new(),
2974            max_system_prompt_chars: default_max_system_prompt_chars(),
2975            thinking: crate::scattered_types::ThinkingConfig::default(),
2976            history_pruning: crate::scattered_types::HistoryPrunerConfig::default(),
2977            precheck: crate::scattered_types::ChannelPrecheckConfig::default(),
2978            context_aware_tools: false,
2979            eval: crate::scattered_types::EvalConfig::default(),
2980            auto_classify: None,
2981            context_compression: crate::scattered_types::ContextCompressionConfig::default(),
2982            max_tool_result_chars: default_max_tool_result_chars(),
2983            keep_tool_context_turns: default_keep_tool_context_turns(),
2984            tool_receipts: ToolReceiptsConfig::default(),
2985            workspace: crate::multi_agent::AgentWorkspaceConfig::default(),
2986            memory: crate::multi_agent::AgentMemoryConfig::default(),
2987            identity: IdentityConfig::default(),
2988        }
2989    }
2990}
2991
2992impl AliasedAgentConfig {
2993    /// True when this agent has the bindings required to dispatch a turn:
2994    /// enabled, non-empty `model_provider`, `risk_profile`, and
2995    /// `runtime_profile`. `Config::validate()` emits the per-field errors
2996    /// that, when all passed, mean this returns `true`.
2997    #[must_use]
2998    pub fn is_dispatchable(&self) -> bool {
2999        self.enabled
3000            && !self.model_provider.is_empty()
3001            && !self.risk_profile.trim().is_empty()
3002            && !self.runtime_profile.trim().is_empty()
3003    }
3004}
3005
3006/// One `[channels.<type>.<alias>]` block, with the owning agent (if any)
3007/// resolved via `agents.<agent>.channels`. Returned by
3008/// `Config::channels_by_alias()`.
3009#[derive(Debug, Clone, Serialize, Deserialize)]
3010#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3011pub struct ChannelAliasInfo {
3012    /// Channel type as the schema emits it (kebab; e.g. `"discord"`,
3013    /// `"nextcloud-talk"`).
3014    pub channel_type: String,
3015    /// Per-alias HashMap key (e.g. `"loneliness"`).
3016    pub alias: String,
3017    /// The agent whose `channels` list contains `<type>.<alias>`. `None`
3018    /// when the block is orphaned (config error caught at startup).
3019    pub owning_agent: Option<String>,
3020    /// Resolved value of `[channels.<type>.<alias>].enabled` at scan time.
3021    /// `false` when the field is unset (matches the serde bool default).
3022    pub enabled: bool,
3023}
3024
3025impl Config {
3026    /// Return the first concrete `model` string available for use as a
3027    /// default. Scans every typed slot's entries (iteration order is
3028    /// the macro slot order) for one with `model` set. Returns `None`
3029    /// only when no model-provider entry has any model configured at
3030    /// all.
3031    #[must_use]
3032    pub fn resolve_default_model(&self) -> Option<String> {
3033        self.providers
3034            .models
3035            .iter_entries()
3036            .filter_map(|(_, _, base)| base.model.as_deref().map(str::trim))
3037            .find(|m| !m.is_empty())
3038            .map(ToString::to_string)
3039    }
3040
3041    /// Return the first `ModelProviderConfig` (the shared base) from
3042    /// `model_providers`, if any exists.
3043    #[must_use]
3044    pub fn first_model_provider(&self) -> Option<&ModelProviderConfig> {
3045        self.providers
3046            .models
3047            .iter_entries()
3048            .next()
3049            .map(|(_, _, base)| base)
3050    }
3051
3052    /// Mutable form of [`Self::first_model_provider`].
3053    pub fn first_model_provider_mut(&mut self) -> Option<&mut ModelProviderConfig> {
3054        self.providers
3055            .models
3056            .iter_entries_mut()
3057            .next()
3058            .map(|(_, _, base)| base)
3059    }
3060
3061    /// Return the model-provider type key of the first entry in
3062    /// `model_providers`, if any. Use this when callers need the bare
3063    /// type name (e.g. provider routing factories that take
3064    /// `"openrouter"` not `"openrouter.default"`).
3065    #[must_use]
3066    pub fn first_model_provider_type(&self) -> Option<&'static str> {
3067        self.providers
3068            .models
3069            .iter_entries()
3070            .next()
3071            .map(|(ty, _, _)| ty)
3072    }
3073
3074    /// Return the dotted `<type>.<alias>` identifier of the first
3075    /// configured model-provider entry, if any. Use this when callers
3076    /// need the alias reference (matches `agents.<x>.model_provider`
3077    /// values).
3078    #[must_use]
3079    pub fn first_model_provider_alias(&self) -> Option<String> {
3080        self.providers
3081            .models
3082            .iter_entries()
3083            .next()
3084            .map(|(ty, alias, _)| format!("{ty}.{alias}"))
3085    }
3086
3087    /// Resolve the risk profile for an explicit agent alias.
3088    ///
3089    /// Each agent's `risk_profile` field names a `[risk_profiles.<alias>]`
3090    /// entry that gates its actions. There is no "global" risk profile in
3091    /// every callsite must come through an agent. When the agent has
3092    /// no profile set or names a missing entry, returns `None` and the
3093    /// caller decides how to handle it (validation rejects this shape at
3094    /// load time; the runtime treating `None` as a config error).
3095    #[must_use]
3096    pub fn risk_profile_for_agent(&self, agent_alias: &str) -> Option<&RiskProfileConfig> {
3097        let agent = self.agents.get(agent_alias)?;
3098        let profile_alias = agent.risk_profile.trim();
3099        if profile_alias.is_empty() {
3100            return None;
3101        }
3102        self.risk_profiles.get(profile_alias)
3103    }
3104
3105    /// Resolve the `[runtime_profiles.<alias>]` entry owned by an agent
3106    /// (via `agents.<alias>.runtime_profile`). Returns `None` when the
3107    /// agent has no runtime profile set or names a missing entry. Unlike
3108    /// `risk_profile_for_agent`, the missing case is not a hard error
3109    /// because runtime budgets and tunables fall back to global defaults.
3110    #[must_use]
3111    pub fn runtime_profile_for_agent(&self, agent_alias: &str) -> Option<&RuntimeProfileConfig> {
3112        let agent = self.agents.get(agent_alias)?;
3113        let profile_alias = agent.runtime_profile.trim();
3114        if profile_alias.is_empty() {
3115            return None;
3116        }
3117        self.runtime_profiles.get(profile_alias)
3118    }
3119
3120    /// Resolve an agent's `model_provider` reference (`"<type>.<alias>"`) to
3121    /// its concrete `ModelProviderConfig` entry. Returns `None` when the
3122    /// agent doesn't exist, the reference is unparseable, or the
3123    /// `<type>.<alias>` pair doesn't resolve in `providers.models`.
3124    ///
3125    /// This is the lookup the orchestrator uses to build per-agent
3126    /// model_provider runtime options instead of falling back to
3127    /// `first_model_provider()`, which silently collapses multiple aliases
3128    /// under the same model_provider family to whichever entry happens to be
3129    /// first. The matching split logic lives in
3130    /// `crates/zeroclaw-runtime/src/tools/delegate.rs::resolve_brain` for
3131    /// the delegation path; this helper exposes the same contract for the
3132    /// channel-server startup path.
3133    #[must_use]
3134    pub fn model_provider_for_agent(&self, agent_alias: &str) -> Option<&ModelProviderConfig> {
3135        let agent = self.agents.get(agent_alias)?;
3136        let (type_key, alias_key) = agent.model_provider.split_once('.')?;
3137        self.providers.models.find(type_key, alias_key)
3138    }
3139
3140    /// Resolve `(provider_type, provider_alias, &ModelProviderConfig)` for an
3141    /// agent. Same lookup as `model_provider_for_agent` but also returns the
3142    /// `'static` type key that downstream provider factories
3143    /// (`create_routed_model_provider_with_options`, etc.) need. Returns
3144    /// `None` when the agent has no `model_provider` set, when the reference
3145    /// is unparseable, or when the resolved entry has been deleted from
3146    /// `providers.models`.
3147    #[must_use]
3148    pub fn resolved_model_provider_for_agent(
3149        &self,
3150        agent_alias: &str,
3151    ) -> Option<(&'static str, &str, &ModelProviderConfig)> {
3152        let agent = self.agents.get(agent_alias)?;
3153        let (type_key, alias_key) = agent.model_provider.split_once('.')?;
3154        self.providers
3155            .models
3156            .iter_entries()
3157            .find(|(ty, al, _)| *ty == type_key && *al == alias_key)
3158    }
3159
3160    /// Reverse-lookup the agent alias that owns a configured channel
3161    /// (`<type>.<alias>`). Returns the first agent listing the channel in
3162    /// its `channels` field. `None` when no agent owns the channel —
3163    /// orphaned channels are a config error the orchestrator surfaces at
3164    /// startup.
3165    #[must_use]
3166    pub fn agent_for_channel(&self, channel_alias: &str) -> Option<&str> {
3167        self.agents
3168            .iter()
3169            .find(|(_, agent)| agent.enabled && agent.channels.iter().any(|c| c == channel_alias))
3170            .map(|(alias, _)| alias.as_str())
3171    }
3172
3173    /// Workspace dir a channel's inbound-media handler writes into. Resolves
3174    /// the channel's owning agent and returns `<install>/agents/<alias>/workspace/`;
3175    /// falls back to `data_dir` for orphan channels (no owning agent enabled).
3176    #[must_use]
3177    pub fn channel_workspace_dir(&self, channel_ref: &str) -> PathBuf {
3178        self.agent_for_channel(channel_ref)
3179            .map_or_else(|| self.data_dir.clone(), |a| self.agent_workspace_dir(a))
3180    }
3181
3182    /// Schema-walk: every populated `[channels.<type>.<alias>]` block.
3183    /// Type names come from the `prop_fields()` enumeration (kebab as the
3184    /// macro emits them) so adding a new channel type via the macro
3185    /// surfaces here without touching this code. Alias keys are HashMap
3186    /// keys; not kebab-converted.
3187    #[must_use]
3188    pub fn channels_by_alias(&self) -> Vec<ChannelAliasInfo> {
3189        use std::collections::BTreeMap;
3190        let mut seen: BTreeMap<(String, String), bool> = BTreeMap::new();
3191        for field in self.prop_fields() {
3192            let parts: Vec<&str> = field.name.split('.').collect();
3193            if parts.len() < 4 || parts[0] != "channels" {
3194                continue;
3195            }
3196            let key = (parts[1].to_string(), parts[2].to_string());
3197            let entry = seen.entry(key).or_insert(false);
3198            if parts.len() == 4 && parts[3] == "enabled" {
3199                *entry = field.display_value == "true";
3200            }
3201        }
3202        seen.into_iter()
3203            .map(|((channel_type, alias), enabled)| {
3204                let composite = format!("{channel_type}.{alias}");
3205                let owning_agent = self.agent_for_channel(&composite).map(str::to_string);
3206                ChannelAliasInfo {
3207                    channel_type,
3208                    alias,
3209                    owning_agent,
3210                    enabled,
3211                }
3212            })
3213            .collect()
3214    }
3215
3216    /// Reverse-lookup the agent alias that owns a declaratively-configured
3217    /// cron job (`[cron.<alias>]`). Returns the first agent listing the
3218    /// alias in its `cron_jobs` field. `None` when no agent claims the
3219    /// job — orphaned cron jobs are skipped at scheduler time with a
3220    /// warning. Imperative jobs (created at runtime via `cron_add`) have
3221    /// UUID-shaped ids that won't match any agent's `cron_jobs`; the
3222    /// scheduler treats those separately (carrying their owning agent
3223    /// alongside the DB row is a follow-up).
3224    #[must_use]
3225    pub fn agent_for_cron_job(&self, cron_alias: &str) -> Option<&str> {
3226        self.agents
3227            .iter()
3228            .find(|(_, agent)| agent.enabled && agent.cron_jobs.iter().any(|c| c == cron_alias))
3229            .map(|(alias, _)| alias.as_str())
3230    }
3231
3232    /// Resolve the per-agent workspace directory for `alias`.
3233    ///
3234    /// Returns the agent's `[agents.<alias>.workspace.path]` override
3235    /// when set (operator-explicit, e.g. for putting a workspace on a
3236    /// different disk), otherwise derives
3237    /// `<install>/agents/<alias>/workspace/` from the install root
3238    /// (the directory containing `config.toml`).
3239    ///
3240    /// Per-agent workspaces live under
3241    /// `<install>/agents/<alias>/workspace/` and hold the agent's
3242    /// markdown memory (MEMORY.md), identity files (IDENTITY.md,
3243    /// SOUL.md), and any other per-agent plaintext state. Shared
3244    /// databases (SQLite memory, sessions, cost records) live under
3245    /// `config.data_dir` instead and partition by agent at the row
3246    /// level. Per-agent overrides via `[agents.<alias>.workspace.path]`
3247    /// pin an arbitrary filesystem path (e.g. a different mount).
3248    #[must_use]
3249    pub fn agent_workspace_dir(&self, agent_alias: &str) -> std::path::PathBuf {
3250        if let Some(cfg) = self.agents.get(agent_alias)
3251            && let Some(custom) = cfg.workspace.path.as_ref()
3252        {
3253            return custom.clone();
3254        }
3255        self.install_root_dir()
3256            .join("agents")
3257            .join(agent_alias)
3258            .join("workspace")
3259    }
3260
3261    /// `<install>/shared/` — directory shared across every agent on this
3262    /// host. Holds skills, skill bundles, knowledge bundles, and any
3263    /// other content not scoped to a single agent's workspace. Distinct
3264    /// from `agent_workspace_dir(alias)` (per-agent state) and
3265    /// `data_dir` (databases + runtime state).
3266    #[must_use]
3267    pub fn shared_workspace_dir(&self) -> std::path::PathBuf {
3268        self.install_root_dir().join("shared")
3269    }
3270
3271    /// Install root: `<install>/` derived from `config_path`'s parent. Used
3272    /// to compute `<install>/shared/`, `<install>/agents/`, and the
3273    /// skill-bundle directory defaults. Public so consumers (gateway, CLI,
3274    /// SkillsService) share the same anchor.
3275    #[must_use]
3276    pub fn install_root_dir(&self) -> std::path::PathBuf {
3277        self.config_path
3278            .parent()
3279            .map(std::path::Path::to_path_buf)
3280            .unwrap_or_else(|| std::path::PathBuf::from("."))
3281    }
3282
3283    /// Resolve an aliased-agent config by alias. `None` when the alias
3284    /// isn't configured; callers should treat this as a config error
3285    /// rather than synthesizing a default.
3286    #[must_use]
3287    pub fn agent(&self, agent_alias: &str) -> Option<&AliasedAgentConfig> {
3288        self.agents.get(agent_alias)
3289    }
3290
3291    /// Resolve the runtime-active agent alias the orchestrator binds
3292    /// channels to. Mirrors the same selection logic as
3293    /// `start_channels()` in zeroclaw-channels: prefer the migration-
3294    /// synthesized `"default"` agent, otherwise fall back to the
3295    /// lexicographically-smallest enabled alias. Returns `None` only
3296    /// when no enabled agent is configured.
3297    ///
3298    /// Used by per-agent infrastructure (TtsManager, TranscriptionManager)
3299    /// to pick which agent's `tts_provider` / `transcription_provider`
3300    /// drives the manager's resolved alias. Until the per-channel
3301    /// dispatch refactor lands, the orchestrator runs in single-agent
3302    /// mode, so all manager instances share the same resolved agent.
3303    #[must_use]
3304    pub fn resolved_runtime_agent_alias(&self) -> Option<&str> {
3305        self.agents
3306            .keys()
3307            .find(|k| k.as_str() == "default")
3308            .map(String::as_str)
3309            .or_else(|| {
3310                self.agents
3311                    .iter()
3312                    .filter(|(_, a)| a.enabled)
3313                    .map(|(alias, _)| alias.as_str())
3314                    .min()
3315            })
3316    }
3317
3318    /// Resolve the active storage backend for the memory subsystem.
3319    ///
3320    /// `MemoryConfig.backend` is a dotted reference (`<backend>.<alias>`) into
3321    /// `Config.storage.<backend>.<alias>`. Bare backend names are interpreted
3322    /// as `<backend>.default` for back-compat.
3323    ///
3324    /// Returns `ActiveStorage::None` when no backend is configured, when the
3325    /// backend is `"none"`, or when the dotted alias does not resolve to a
3326    /// configured entry.
3327    pub fn resolve_active_storage(&self) -> ActiveStorage<'_> {
3328        let backend = self.memory.backend.trim();
3329        if backend.is_empty() || backend.eq_ignore_ascii_case("none") {
3330            return ActiveStorage::None;
3331        }
3332        let (kind, alias) = backend.split_once('.').unwrap_or((backend, "default"));
3333        match kind {
3334            "sqlite" => self
3335                .storage
3336                .sqlite
3337                .get(alias)
3338                .map(ActiveStorage::Sqlite)
3339                .unwrap_or(ActiveStorage::None),
3340            "postgres" => self
3341                .storage
3342                .postgres
3343                .get(alias)
3344                .map(ActiveStorage::Postgres)
3345                .unwrap_or(ActiveStorage::None),
3346            "qdrant" => self
3347                .storage
3348                .qdrant
3349                .get(alias)
3350                .map(ActiveStorage::Qdrant)
3351                .unwrap_or(ActiveStorage::None),
3352            "markdown" => self
3353                .storage
3354                .markdown
3355                .get(alias)
3356                .map(ActiveStorage::Markdown)
3357                .unwrap_or(ActiveStorage::None),
3358            "lucid" => self
3359                .storage
3360                .lucid
3361                .get(alias)
3362                .map(ActiveStorage::Lucid)
3363                .unwrap_or(ActiveStorage::None),
3364            _ => ActiveStorage::None,
3365        }
3366    }
3367}
3368
3369/// Resolved storage backend variant.
3370///
3371/// Returned from [`Config::resolve_active_storage`]. Each variant carries a
3372/// borrow of the typed config from the corresponding `Config.storage` map.
3373#[derive(Debug, Clone, Copy)]
3374pub enum ActiveStorage<'a> {
3375    /// No storage configured (`memory.backend = "none"` or unresolved alias).
3376    None,
3377    /// SQLite storage instance.
3378    Sqlite(&'a SqliteStorageConfig),
3379    /// PostgreSQL storage instance.
3380    Postgres(&'a PostgresStorageConfig),
3381    /// Qdrant storage instance.
3382    Qdrant(&'a QdrantStorageConfig),
3383    /// Markdown directory storage instance.
3384    Markdown(&'a MarkdownStorageConfig),
3385    /// Lucid CLI sync instance.
3386    Lucid(&'a LucidStorageConfig),
3387}
3388
3389impl ActiveStorage<'_> {
3390    /// Backend type name (`"sqlite"`, `"postgres"`, etc.); `"none"` for unconfigured.
3391    #[must_use]
3392    pub fn kind(&self) -> &'static str {
3393        match self {
3394            ActiveStorage::None => "none",
3395            ActiveStorage::Sqlite(_) => "sqlite",
3396            ActiveStorage::Postgres(_) => "postgres",
3397            ActiveStorage::Qdrant(_) => "qdrant",
3398            ActiveStorage::Markdown(_) => "markdown",
3399            ActiveStorage::Lucid(_) => "lucid",
3400        }
3401    }
3402}
3403
3404fn default_delegate_timeout_secs() -> u64 {
3405    DEFAULT_DELEGATE_TIMEOUT_SECS
3406}
3407
3408fn default_delegate_agentic_timeout_secs() -> u64 {
3409    DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS
3410}
3411
3412/// Valid temperature range for all paths (config, CLI, env override).
3413pub const TEMPERATURE_RANGE: std::ops::RangeInclusive<f64> = 0.0..=2.0;
3414
3415/// Defaults to 0 so configs without an explicit `schema_version` are recognized
3416/// as pre-versioning and get migrated.
3417fn default_schema_version() -> u32 {
3418    0
3419}
3420
3421/// Default delegate tool timeout for non-agentic calls: 120 seconds.
3422pub const DEFAULT_DELEGATE_TIMEOUT_SECS: u64 = 120;
3423
3424/// Default delegate tool timeout for agentic runs: 300 seconds.
3425pub const DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS: u64 = 300;
3426
3427/// Validate that a temperature value is within the allowed range.
3428pub fn validate_temperature(value: f64) -> std::result::Result<f64, String> {
3429    if TEMPERATURE_RANGE.contains(&value) {
3430        Ok(value)
3431    } else {
3432        Err(format!(
3433            "temperature {value} is out of range (expected {}..={})",
3434            TEMPERATURE_RANGE.start(),
3435            TEMPERATURE_RANGE.end()
3436        ))
3437    }
3438}
3439
3440fn normalize_reasoning_effort(value: &str) -> std::result::Result<String, String> {
3441    let normalized = value.trim().to_ascii_lowercase();
3442    match normalized.as_str() {
3443        "minimal" | "low" | "medium" | "high" | "xhigh" => Ok(normalized),
3444        _ => Err(format!(
3445            "reasoning_effort {value:?} is invalid (expected one of: minimal, low, medium, high, xhigh)"
3446        )),
3447    }
3448}
3449
3450fn deserialize_reasoning_effort_opt<'de, D>(
3451    deserializer: D,
3452) -> std::result::Result<Option<String>, D::Error>
3453where
3454    D: serde::Deserializer<'de>,
3455{
3456    let value: Option<String> = Option::deserialize(deserializer)?;
3457    value
3458        .map(|raw| normalize_reasoning_effort(&raw).map_err(serde::de::Error::custom))
3459        .transpose()
3460}
3461
3462/// Deserialize an `Option<String>` that maps an empty literal `""` to
3463/// `None`. Used by `JiraConfig::email` so a config that round-tripped
3464/// `email = ""` to disk (the legacy `email: String` had no
3465/// `skip_serializing_if`) doesn't deserialize as `Some("")` and silently
3466/// break Basic auth — the email-required validation was removed when
3467/// Server/DC Bearer-token support landed, so this is the last line of
3468/// defense.
3469fn deserialize_optional_email_skip_empty<'de, D>(
3470    deserializer: D,
3471) -> std::result::Result<Option<String>, D::Error>
3472where
3473    D: serde::Deserializer<'de>,
3474{
3475    let value: Option<String> = Option::deserialize(deserializer)?;
3476    Ok(value.filter(|s| !s.trim().is_empty()))
3477}
3478
3479// ── Hardware Config (wizard-driven) ─────────────────────────────
3480
3481/// Hardware transport mode.
3482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
3483#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3484pub enum HardwareTransport {
3485    #[default]
3486    None,
3487    Native,
3488    Serial,
3489    Probe,
3490}
3491
3492impl std::fmt::Display for HardwareTransport {
3493    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3494        match self {
3495            Self::None => write!(f, "none"),
3496            Self::Native => write!(f, "native"),
3497            Self::Serial => write!(f, "serial"),
3498            Self::Probe => write!(f, "probe"),
3499        }
3500    }
3501}
3502
3503/// Wizard-driven hardware configuration for physical world interaction.
3504#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3505#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3506#[prefix = "hardware"]
3507pub struct HardwareConfig {
3508    /// Opt in to direct physical-hardware control — GPIO pins, USB-tethered microcontrollers (Arduino, ESP32, Nucleo), or SWD/JTAG debug probes. Leave off for software-only use; turning it on without the right transport configured does nothing.
3509    #[serde(default)]
3510    pub enabled: bool,
3511    /// How ZeroClaw reaches the hardware: `native` = Linux SBC with direct GPIO access (Raspberry Pi, Orange Pi); `serial` = USB-tethered microcontroller speaking over a TTY; `probe` = SWD/JTAG debug probe driving a target chip via probe-rs; `none` = disabled.
3512    #[serde(default)]
3513    pub transport: HardwareTransport,
3514    /// TTY path for the `serial` transport — e.g. `/dev/ttyACM0` on Linux, `/dev/tty.usbmodem1` on macOS, `COM3` on Windows. Ignored for other transports.
3515    #[serde(default)]
3516    pub serial_port: Option<String>,
3517    /// Baud rate negotiated on the serial link. 115200 matches the common Arduino / ESP32 bootloader default; bump to 230400+ when your firmware explicitly supports faster rates and you need the throughput.
3518    #[serde(default = "default_baud_rate")]
3519    pub baud_rate: u32,
3520    /// Target chip identifier for `transport = probe` (e.g. `STM32F401RE`, `nRF52840_xxAA`). Passed straight to probe-rs for flash/debug operations; must match a chip probe-rs recognizes.
3521    #[serde(default)]
3522    pub probe_target: Option<String>,
3523    /// Index PDF schematics and datasheets from the workspace into a local RAG store, so the agent can look up pin assignments and electrical specs inline when you ask hardware questions. Off by default — turn on once the workspace has relevant PDFs dropped in.
3524    #[serde(default)]
3525    pub workspace_datasheets: bool,
3526}
3527
3528fn default_baud_rate() -> u32 {
3529    115_200
3530}
3531
3532impl HardwareConfig {
3533    /// Return the active transport mode.
3534    pub fn transport_mode(&self) -> HardwareTransport {
3535        self.transport.clone()
3536    }
3537}
3538
3539impl Default for HardwareConfig {
3540    fn default() -> Self {
3541        Self {
3542            enabled: false,
3543            transport: HardwareTransport::None,
3544            serial_port: None,
3545            baud_rate: default_baud_rate(),
3546            probe_target: None,
3547            workspace_datasheets: false,
3548        }
3549    }
3550}
3551
3552// ── Transcription ────────────────────────────────────────────────
3553
3554fn default_transcription_api_url() -> String {
3555    "https://api.groq.com/openai/v1/audio/transcriptions".into()
3556}
3557
3558fn default_transcription_model() -> String {
3559    "whisper-large-v3-turbo".into()
3560}
3561
3562fn default_transcription_max_duration_secs() -> u64 {
3563    120
3564}
3565
3566fn default_openai_stt_model() -> String {
3567    "whisper-1".into()
3568}
3569
3570fn default_deepgram_stt_model() -> String {
3571    "nova-2".into()
3572}
3573
3574fn default_google_stt_language_code() -> String {
3575    "en-US".into()
3576}
3577
3578/// Voice transcription configuration with multi-provider support.
3579///
3580/// The top-level `api_url`, `model`, and `api_key` fields remain for backward
3581/// compatibility with existing Groq-based configurations.
3582#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3583#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3584#[prefix = "transcription"]
3585pub struct TranscriptionConfig {
3586    /// Enable voice transcription for channels that support it.
3587    #[serde(default)]
3588    pub enabled: bool,
3589    /// API key used for transcription requests (Groq transcription provider).
3590    ///
3591    /// If unset, runtime falls back to `GROQ_API_KEY` for backward compatibility.
3592    #[serde(default)]
3593    #[secret]
3594    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
3595    pub api_key: Option<String>,
3596    /// Whisper API endpoint URL (Groq transcription provider).
3597    #[serde(default = "default_transcription_api_url")]
3598    pub api_url: String,
3599    /// Whisper model name (Groq transcription provider).
3600    #[serde(default = "default_transcription_model")]
3601    pub model: String,
3602    /// Optional language hint (ISO-639-1, e.g. "en", "ru") for Groq transcription provider.
3603    #[serde(default)]
3604    pub language: Option<String>,
3605    /// Optional initial prompt to bias transcription toward expected vocabulary
3606    /// (proper nouns, technical terms, etc.). Sent as the `prompt` field in the
3607    /// Whisper API request.
3608    #[serde(default)]
3609    pub initial_prompt: Option<String>,
3610    /// Maximum voice duration in seconds (messages longer than this are skipped).
3611    #[serde(default = "default_transcription_max_duration_secs")]
3612    pub max_duration_secs: u64,
3613    /// OpenAI Whisper STT model_provider configuration.
3614    #[serde(default)]
3615    #[nested]
3616    pub openai: Option<OpenAiSttConfig>,
3617    /// Deepgram STT model_provider configuration.
3618    #[serde(default)]
3619    #[nested]
3620    pub deepgram: Option<DeepgramSttConfig>,
3621    /// AssemblyAI STT model_provider configuration.
3622    #[serde(default)]
3623    #[nested]
3624    pub assemblyai: Option<AssemblyAiSttConfig>,
3625    /// Google Cloud Speech-to-Text model_provider configuration.
3626    #[serde(default)]
3627    #[nested]
3628    pub google: Option<GoogleSttConfig>,
3629    /// Local/self-hosted Whisper-compatible STT model_provider.
3630    #[serde(default)]
3631    #[nested]
3632    pub local_whisper: Option<LocalWhisperConfig>,
3633    /// Also transcribe non-PTT (forwarded/regular) audio messages on WhatsApp,
3634    /// not just voice notes.  Default: `false` (preserves legacy behavior).
3635    #[serde(default)]
3636    pub transcribe_non_ptt_audio: bool,
3637}
3638
3639impl Default for TranscriptionConfig {
3640    fn default() -> Self {
3641        Self {
3642            enabled: false,
3643            api_key: None,
3644            api_url: default_transcription_api_url(),
3645            model: default_transcription_model(),
3646            language: None,
3647            initial_prompt: None,
3648            max_duration_secs: default_transcription_max_duration_secs(),
3649            openai: None,
3650            deepgram: None,
3651            assemblyai: None,
3652            google: None,
3653            local_whisper: None,
3654            transcribe_non_ptt_audio: false,
3655        }
3656    }
3657}
3658
3659// ── MCP ─────────────────────────────────────────────────────────
3660
3661/// Transport type for MCP server connections.
3662#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
3663#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3664#[serde(rename_all = "lowercase")]
3665pub enum McpTransport {
3666    /// Spawn a local process and communicate over stdin/stdout.
3667    #[default]
3668    Stdio,
3669    /// Connect via HTTP POST.
3670    Http,
3671    /// Connect via HTTP + Server-Sent Events.
3672    Sse,
3673}
3674
3675/// Configuration for a single external MCP server.
3676#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
3677#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3678#[prefix = "mcp.servers"]
3679pub struct McpServerConfig {
3680    /// Display name used as a tool prefix (`<server>__<tool>`). Filled in
3681    /// from the supplied `map_key` when the entry is created via
3682    /// `create_map_key("mcp.servers", "<name>")`; `#[serde(default)]` lets
3683    /// the macro default-construct from `{}` before the name gets injected.
3684    #[serde(default)]
3685    pub name: String,
3686    /// Transport type (default: stdio).
3687    #[serde(default)]
3688    pub transport: McpTransport,
3689    /// URL for HTTP/SSE transports.
3690    #[serde(default)]
3691    pub url: Option<String>,
3692    /// Executable to spawn for stdio transport.
3693    #[serde(default)]
3694    pub command: String,
3695    /// Command arguments for stdio transport.
3696    #[serde(default)]
3697    pub args: Vec<String>,
3698    /// Optional environment variables for stdio transport.
3699    #[serde(default)]
3700    pub env: HashMap<String, String>,
3701    /// Optional HTTP headers for HTTP/SSE transports. Treated as secret —
3702    /// the values commonly carry Bearer tokens for the upstream MCP server.
3703    #[serde(default)]
3704    #[secret]
3705    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
3706    pub headers: HashMap<String, String>,
3707    /// Optional per-call timeout in seconds (hard capped in validation).
3708    #[serde(default)]
3709    pub tool_timeout_secs: Option<u64>,
3710}
3711
3712/// External MCP client configuration (`[mcp]` section).
3713#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3714#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3715#[prefix = "mcp"]
3716pub struct McpConfig {
3717    /// Enable MCP tool loading.
3718    #[serde(default)]
3719    pub enabled: bool,
3720    /// Load MCP tool schemas on-demand via `tool_search` instead of eagerly
3721    /// including them in the LLM context window. When `true` (the default),
3722    /// only tool names are listed in the system prompt; the LLM must call
3723    /// `tool_search` to fetch full schemas before invoking a deferred tool.
3724    #[serde(default = "default_deferred_loading")]
3725    pub deferred_loading: bool,
3726    /// Configured MCP servers. The `#[nested]` annotation makes the macro
3727    /// expose this as a List section in `map_key_sections()`, so the
3728    /// dashboard's `+ Add MCP server` affordance and the `POST
3729    /// /api/config/map-key?path=mcp.servers&key=<name>` endpoint pick it
3730    /// up automatically (no hand-table on the gateway side).
3731    #[serde(default, alias = "mcpServers")]
3732    #[nested]
3733    pub servers: Vec<McpServerConfig>,
3734}
3735
3736fn default_deferred_loading() -> bool {
3737    true
3738}
3739
3740impl Default for McpConfig {
3741    fn default() -> Self {
3742        Self {
3743            enabled: false,
3744            deferred_loading: default_deferred_loading(),
3745            servers: Vec::new(),
3746        }
3747    }
3748}
3749
3750/// Verifiable Intent (VI) credential verification and issuance (`[verifiable_intent]` section).
3751#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3752#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3753#[prefix = "verifiable-intent"]
3754pub struct VerifiableIntentConfig {
3755    /// Enable VI credential verification on commerce tool calls (default: false).
3756    #[serde(default)]
3757    pub enabled: bool,
3758
3759    /// Strictness mode for constraint evaluation: "strict" (fail-closed on unknown
3760    /// constraint types) or "permissive" (skip unknown types with a warning).
3761    /// Default: "strict".
3762    #[serde(default = "default_vi_strictness")]
3763    pub strictness: String,
3764}
3765
3766fn default_vi_strictness() -> String {
3767    "strict".to_owned()
3768}
3769
3770impl Default for VerifiableIntentConfig {
3771    fn default() -> Self {
3772        Self {
3773            enabled: false,
3774            strictness: default_vi_strictness(),
3775        }
3776    }
3777}
3778
3779// ── Nodes (Dynamic Node Discovery) ───────────────────────────────
3780
3781/// Configuration for the dynamic node discovery system (`[nodes]`).
3782///
3783/// When enabled, external processes/devices can connect via WebSocket
3784/// at `/ws/nodes` and advertise their capabilities at runtime.
3785#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3786#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3787#[prefix = "nodes"]
3788pub struct NodesConfig {
3789    /// Enable dynamic node discovery endpoint.
3790    #[serde(default)]
3791    pub enabled: bool,
3792    /// Maximum number of concurrent node connections.
3793    #[serde(default = "default_max_nodes")]
3794    pub max_nodes: usize,
3795    /// Optional bearer token for node authentication.
3796    #[serde(default)]
3797    pub auth_token: Option<String>,
3798}
3799
3800fn default_max_nodes() -> usize {
3801    16
3802}
3803
3804impl Default for NodesConfig {
3805    fn default() -> Self {
3806        Self {
3807            enabled: false,
3808            max_nodes: default_max_nodes(),
3809            auth_token: None,
3810        }
3811    }
3812}
3813
3814// ── TTS (Text-to-Speech) ─────────────────────────────────────────
3815
3816fn default_tts_voice() -> String {
3817    "alloy".into()
3818}
3819
3820fn default_tts_format() -> String {
3821    "mp3".into()
3822}
3823
3824fn default_tts_max_text_length() -> usize {
3825    4096
3826}
3827
3828/// Text-to-Speech subsystem configuration (`[tts]`).
3829///
3830/// Per-instance TTS configs live under `[tts_providers.<type>.<alias>]`
3831/// (parallel to `providers.models`). What remains here are the global
3832/// runtime knobs that apply to every model_provider invocation.
3833#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3834#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3835#[prefix = "tts"]
3836pub struct TtsConfig {
3837    /// Enable TTS synthesis.
3838    #[serde(default)]
3839    pub enabled: bool,
3840    /// Default voice ID passed to the selected tts provider.
3841    #[serde(default = "default_tts_voice")]
3842    pub default_voice: String,
3843    /// Default audio output format (`"mp3"`, `"opus"`, `"wav"`).
3844    #[serde(default = "default_tts_format")]
3845    pub default_format: String,
3846    /// Maximum input text length in characters (default 4096).
3847    #[serde(default = "default_tts_max_text_length")]
3848    pub max_text_length: usize,
3849}
3850
3851impl Default for TtsConfig {
3852    fn default() -> Self {
3853        Self {
3854            enabled: false,
3855            default_voice: default_tts_voice(),
3856            default_format: default_tts_format(),
3857            max_text_length: default_tts_max_text_length(),
3858        }
3859    }
3860}
3861
3862/// Per-instance TTS model_provider configuration (`[tts_providers.<type>.<alias>]`).
3863///
3864/// Mirrors `ModelProviderConfig` in shape — one struct holds the union of
3865/// fields across backends. Only the fields relevant to the selected backend
3866/// (determined by the outer `<type>` map key) are read at runtime; others
3867/// are quietly ignored.
3868#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
3869#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3870#[prefix = "tts-provider"]
3871#[serde(default)]
3872pub struct TtsProviderConfig {
3873    /// API key (openai, elevenlabs, google).
3874    #[secret]
3875    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
3876    pub api_key: Option<String>,
3877    /// Model name. OpenAI uses this for `tts-1`/`tts-1-hd`; elevenlabs uses
3878    /// it as the model_id (e.g. `eleven_monolingual_v1`).
3879    pub model: Option<String>,
3880    /// Voice override for this instance. When empty, falls back to
3881    /// `[tts].default_voice`.
3882    pub voice: Option<String>,
3883    /// Playback speed multiplier (openai only; default `1.0`).
3884    pub speed: Option<f64>,
3885    /// Voice stability for elevenlabs (0.0-1.0; default `0.5`).
3886    pub stability: Option<f64>,
3887    /// Similarity boost for elevenlabs (0.0-1.0; default `0.5`).
3888    pub similarity_boost: Option<f64>,
3889    /// Language code for google (e.g. `en-US`).
3890    pub language_code: Option<String>,
3891    /// Path to backend binary (edge-tts subprocess; piper local server).
3892    pub binary_path: Option<String>,
3893    /// Audio response format sent to the TTS backend (e.g. `"opus"`, `"mp3"`,
3894    /// `"wav"`). Defaults to `"opus"` for the OpenAI family. Override to
3895    /// `"wav"` for Orpheus-class models (e.g. `canopylabs/orpheus-v1-english`
3896    /// on Groq) or `"mp3"` for broader compatibility.
3897    pub response_format: Option<String>,
3898    /// Endpoint URI for HTTP-based backends. Overrides the family default
3899    /// when pointing at a compatible third-party API (Groq, Azure, self-hosted
3900    /// proxies). Set to the **full** URL — there is no separate path-suffix
3901    /// field. Renamed from `api_url` for parity with `ModelProviderConfig.uri`.
3902    #[serde(alias = "api_url")]
3903    pub uri: Option<String>,
3904}
3905
3906// ── TTS endpoint trait + per-family typed configs ──────────────────────────
3907//
3908// Mirrors the model provider typed-family pattern. Each TTS family carries
3909// its own typed config (composing TtsProviderConfig as the shared base via
3910// `#[serde(flatten)]`) and a single-variant `*TtsEndpoint` enum impl'ing
3911// `TtsEndpoint`. Edge and Piper skip the base — they're subprocess / local
3912// runtimes with no shared `api_key` / `voice` defaults.
3913
3914/// One trait per family-endpoint enum. Returns the URI for the chosen
3915/// variant. Mirrors `ModelEndpoint` for parity across model and TTS.
3916pub trait TtsEndpoint {
3917    fn uri(&self) -> &'static str;
3918}
3919
3920#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3921#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3922#[serde(rename_all = "snake_case")]
3923pub enum OpenAITtsEndpoint {
3924    #[default]
3925    Default,
3926}
3927impl TtsEndpoint for OpenAITtsEndpoint {
3928    fn uri(&self) -> &'static str {
3929        match self {
3930            Self::Default => "https://api.openai.com/v1/audio/speech",
3931        }
3932    }
3933}
3934
3935#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
3936#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3937#[prefix = "providers.tts.openai"]
3938pub struct OpenAITtsProviderConfig {
3939    #[nested]
3940    #[serde(flatten)]
3941    pub base: TtsProviderConfig,
3942}
3943
3944#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3945#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3946#[serde(rename_all = "snake_case")]
3947pub enum ElevenLabsTtsEndpoint {
3948    #[default]
3949    Default,
3950}
3951impl TtsEndpoint for ElevenLabsTtsEndpoint {
3952    fn uri(&self) -> &'static str {
3953        match self {
3954            Self::Default => "https://api.elevenlabs.io/v1/text-to-speech",
3955        }
3956    }
3957}
3958
3959#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
3960#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3961#[prefix = "providers.tts.elevenlabs"]
3962pub struct ElevenLabsTtsProviderConfig {
3963    #[nested]
3964    #[serde(flatten)]
3965    pub base: TtsProviderConfig,
3966}
3967
3968#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3969#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3970#[serde(rename_all = "snake_case")]
3971pub enum GoogleTtsEndpoint {
3972    #[default]
3973    Default,
3974}
3975impl TtsEndpoint for GoogleTtsEndpoint {
3976    fn uri(&self) -> &'static str {
3977        match self {
3978            Self::Default => "https://texttospeech.googleapis.com/v1/text:synthesize",
3979        }
3980    }
3981}
3982
3983#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
3984#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3985#[prefix = "providers.tts.google"]
3986pub struct GoogleTtsProviderConfig {
3987    #[nested]
3988    #[serde(flatten)]
3989    pub base: TtsProviderConfig,
3990}
3991
3992#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
3993#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3994#[serde(rename_all = "snake_case")]
3995pub enum EdgeTtsEndpoint {
3996    /// Subprocess — no remote endpoint. Sentinel for trait conformity.
3997    #[default]
3998    LocalSubprocess,
3999}
4000impl TtsEndpoint for EdgeTtsEndpoint {
4001    fn uri(&self) -> &'static str {
4002        match self {
4003            Self::LocalSubprocess => "subprocess://edge-tts",
4004        }
4005    }
4006}
4007
4008#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4009#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4010#[prefix = "providers.tts.edge"]
4011pub struct EdgeTtsProviderConfig {
4012    #[nested]
4013    #[serde(flatten)]
4014    pub base: TtsProviderConfig,
4015}
4016
4017#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4018#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4019#[serde(rename_all = "snake_case")]
4020pub enum PiperTtsEndpoint {
4021    #[default]
4022    LocalDefault,
4023}
4024impl TtsEndpoint for PiperTtsEndpoint {
4025    fn uri(&self) -> &'static str {
4026        match self {
4027            Self::LocalDefault => "http://127.0.0.1:5000/v1/audio/speech",
4028        }
4029    }
4030}
4031
4032#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4033#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4034#[prefix = "providers.tts.piper"]
4035pub struct PiperTtsProviderConfig {
4036    #[nested]
4037    #[serde(flatten)]
4038    pub base: TtsProviderConfig,
4039}
4040
4041// ── Transcription providers (typed-family split, mirrors models/tts) ────
4042//
4043// Six family slots: `groq`, `openai`, `deepgram`, `assemblyai`, `google`,
4044// `local_whisper`. Each is a `HashMap<String, *TranscriptionProviderConfig>`
4045// keyed by operator-chosen alias. The shared `TranscriptionProviderConfig`
4046// base carries `api_key` + `language` since every cloud STT family takes
4047// both; `local_whisper` skips the base because it's a self-hosted endpoint
4048// with its own auth token, not a vendor API key.
4049
4050/// Shared base for cloud transcription providers. Each cloud family
4051/// composes this via `#[serde(flatten)] base: TranscriptionProviderConfig`.
4052#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4053#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4054#[prefix = "providers.transcription"]
4055pub struct TranscriptionProviderConfig {
4056    /// API key for the transcription provider.
4057    #[serde(default)]
4058    #[secret]
4059    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4060    pub api_key: Option<String>,
4061    /// Optional language hint passed to the provider (ISO-639-1 like `"en"` /
4062    /// `"ru"`, or BCP-47 like `"en-US"` for Google). Most providers auto-detect
4063    /// when this is unset.
4064    #[serde(default)]
4065    pub language: Option<String>,
4066    /// Whisper-style initial prompt to bias the model toward expected
4067    /// vocabulary (proper nouns, technical terms). Provider-specific support;
4068    /// silently ignored where not applicable.
4069    #[serde(default)]
4070    pub initial_prompt: Option<String>,
4071}
4072
4073/// Trait that every transcription endpoint enum implements. Mirrors
4074/// `ModelEndpoint` / `TtsEndpoint` for parity.
4075pub trait TranscriptionEndpoint {
4076    fn uri(&self) -> &'static str;
4077}
4078
4079#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4080#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4081#[serde(rename_all = "snake_case")]
4082pub enum GroqTranscriptionEndpoint {
4083    #[default]
4084    Default,
4085}
4086impl TranscriptionEndpoint for GroqTranscriptionEndpoint {
4087    fn uri(&self) -> &'static str {
4088        match self {
4089            Self::Default => "https://api.groq.com/openai/v1/audio/transcriptions",
4090        }
4091    }
4092}
4093
4094#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4095#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4096#[prefix = "providers.transcription.groq"]
4097pub struct GroqTranscriptionProviderConfig {
4098    #[nested]
4099    #[serde(flatten)]
4100    pub base: TranscriptionProviderConfig,
4101    /// Whisper model name (default: `"whisper-large-v3-turbo"`).
4102    #[serde(default)]
4103    pub model: Option<String>,
4104}
4105
4106#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4107#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4108#[serde(rename_all = "snake_case")]
4109pub enum OpenAiTranscriptionEndpoint {
4110    #[default]
4111    Default,
4112}
4113impl TranscriptionEndpoint for OpenAiTranscriptionEndpoint {
4114    fn uri(&self) -> &'static str {
4115        match self {
4116            Self::Default => "https://api.openai.com/v1/audio/transcriptions",
4117        }
4118    }
4119}
4120
4121#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4122#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4123#[prefix = "providers.transcription.openai"]
4124pub struct OpenAiTranscriptionProviderConfig {
4125    #[nested]
4126    #[serde(flatten)]
4127    pub base: TranscriptionProviderConfig,
4128    /// Whisper model name (default: `"whisper-1"`).
4129    #[serde(default)]
4130    pub model: Option<String>,
4131}
4132
4133#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4134#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4135#[serde(rename_all = "snake_case")]
4136pub enum DeepgramTranscriptionEndpoint {
4137    #[default]
4138    Default,
4139}
4140impl TranscriptionEndpoint for DeepgramTranscriptionEndpoint {
4141    fn uri(&self) -> &'static str {
4142        match self {
4143            Self::Default => "https://api.deepgram.com/v1/listen",
4144        }
4145    }
4146}
4147
4148#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4149#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4150#[prefix = "providers.transcription.deepgram"]
4151pub struct DeepgramTranscriptionProviderConfig {
4152    #[nested]
4153    #[serde(flatten)]
4154    pub base: TranscriptionProviderConfig,
4155    /// Deepgram model name (default: `"nova-2"`).
4156    #[serde(default)]
4157    pub model: Option<String>,
4158}
4159
4160#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4161#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4162#[serde(rename_all = "snake_case")]
4163pub enum AssemblyAiTranscriptionEndpoint {
4164    #[default]
4165    Default,
4166}
4167impl TranscriptionEndpoint for AssemblyAiTranscriptionEndpoint {
4168    fn uri(&self) -> &'static str {
4169        match self {
4170            Self::Default => "https://api.assemblyai.com/v2/transcript",
4171        }
4172    }
4173}
4174
4175#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4176#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4177#[prefix = "providers.transcription.assemblyai"]
4178pub struct AssemblyAiTranscriptionProviderConfig {
4179    #[nested]
4180    #[serde(flatten)]
4181    pub base: TranscriptionProviderConfig,
4182}
4183
4184#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4185#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4186#[serde(rename_all = "snake_case")]
4187pub enum GoogleTranscriptionEndpoint {
4188    #[default]
4189    Default,
4190}
4191impl TranscriptionEndpoint for GoogleTranscriptionEndpoint {
4192    fn uri(&self) -> &'static str {
4193        match self {
4194            Self::Default => "https://speech.googleapis.com/v1/speech:recognize",
4195        }
4196    }
4197}
4198
4199#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4200#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4201#[prefix = "providers.transcription.google"]
4202pub struct GoogleTranscriptionProviderConfig {
4203    #[nested]
4204    #[serde(flatten)]
4205    pub base: TranscriptionProviderConfig,
4206}
4207
4208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4209#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4210#[serde(rename_all = "snake_case")]
4211pub enum LocalWhisperTranscriptionEndpoint {
4212    /// Self-hosted endpoint — no remote URL. Sentinel for trait conformity.
4213    /// The actual URL lives on `LocalWhisperTranscriptionProviderConfig.uri`.
4214    #[default]
4215    SelfHosted,
4216}
4217impl TranscriptionEndpoint for LocalWhisperTranscriptionEndpoint {
4218    fn uri(&self) -> &'static str {
4219        match self {
4220            Self::SelfHosted => "self-hosted",
4221        }
4222    }
4223}
4224
4225/// Local / self-hosted Whisper-compatible transcription endpoint. Skips the
4226/// shared `TranscriptionProviderConfig` base because it uses a bearer-token
4227/// scheme and a per-instance URL rather than a vendor API key.
4228#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4229#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4230#[prefix = "providers.transcription.local_whisper"]
4231pub struct LocalWhisperTranscriptionProviderConfig {
4232    /// Endpoint URL, e.g. `"http://10.10.0.1:8001/v1/transcribe"`.
4233    pub uri: String,
4234    /// Bearer token for endpoint authentication. Omit for unauthenticated
4235    /// local endpoints.
4236    #[serde(default)]
4237    #[secret]
4238    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4239    pub bearer_token: Option<String>,
4240    /// Optional language hint (passed through to the local endpoint).
4241    #[serde(default)]
4242    pub language: Option<String>,
4243    /// Maximum audio file size in bytes accepted by this endpoint.
4244    /// Defaults to 25 MB to match the cloud cap; raise as needed.
4245    #[serde(default = "default_local_whisper_max_audio_bytes")]
4246    pub max_audio_bytes: usize,
4247    /// Request timeout in seconds.
4248    #[serde(default = "default_local_whisper_timeout_secs")]
4249    pub timeout_secs: u64,
4250}
4251
4252/// Determines when a `ToolFilterGroup` is active.
4253#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
4254#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4255#[serde(rename_all = "snake_case")]
4256pub enum ToolFilterGroupMode {
4257    /// Tools in this group are always included in every turn.
4258    Always,
4259    /// Tools in this group are included only when the user message contains
4260    /// at least one of the configured `keywords` (case-insensitive substring match).
4261    #[default]
4262    Dynamic,
4263}
4264
4265/// A named group of MCP tool patterns with an activation mode.
4266///
4267/// Each group lists glob patterns for MCP tool names (prefix `mcp_`) and an
4268/// optional set of keywords that trigger inclusion in `dynamic` mode.
4269/// Built-in (non-MCP) tools always pass through and are never affected by
4270/// `tool_filter_groups`.
4271///
4272/// # Example
4273/// ```toml
4274/// [[agent.tool_filter_groups]]
4275/// mode = "always"
4276/// tools = ["mcp_filesystem_*"]
4277/// keywords = []
4278///
4279/// [[agent.tool_filter_groups]]
4280/// mode = "dynamic"
4281/// tools = ["mcp_browser_*"]
4282/// keywords = ["browse", "website", "url", "search"]
4283/// ```
4284#[derive(Debug, Clone, Serialize, Deserialize)]
4285#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4286pub struct ToolFilterGroup {
4287    /// Activation mode: `"always"` or `"dynamic"`.
4288    #[serde(default)]
4289    pub mode: ToolFilterGroupMode,
4290    /// Glob patterns matching MCP tool names (single `*` wildcard supported).
4291    #[serde(default)]
4292    pub tools: Vec<String>,
4293    /// Keywords that activate this group in `dynamic` mode (case-insensitive substring).
4294    /// Ignored when `mode = "always"`.
4295    #[serde(default)]
4296    pub keywords: Vec<String>,
4297    /// When true, also filter built-in tools (not just MCP tools).
4298    #[serde(default)]
4299    pub filter_builtins: bool,
4300}
4301
4302/// OpenAI Whisper STT model_provider configuration (`[transcription.openai]`).
4303#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4304#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4305#[prefix = "transcription.openai"]
4306pub struct OpenAiSttConfig {
4307    /// OpenAI API key for Whisper transcription.
4308    #[serde(default)]
4309    #[secret]
4310    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4311    pub api_key: Option<String>,
4312    /// Whisper model name (default: "whisper-1").
4313    #[serde(default = "default_openai_stt_model")]
4314    pub model: String,
4315}
4316
4317/// Deepgram STT model_provider configuration (`[transcription.deepgram]`).
4318#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4319#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4320#[prefix = "transcription.deepgram"]
4321pub struct DeepgramSttConfig {
4322    /// Deepgram API key.
4323    #[serde(default)]
4324    #[secret]
4325    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4326    pub api_key: Option<String>,
4327    /// Deepgram model name (default: "nova-2").
4328    #[serde(default = "default_deepgram_stt_model")]
4329    pub model: String,
4330}
4331
4332/// AssemblyAI STT model_provider configuration (`[transcription.assemblyai]`).
4333#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4334#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4335#[prefix = "transcription.assemblyai"]
4336pub struct AssemblyAiSttConfig {
4337    /// AssemblyAI API key.
4338    #[serde(default)]
4339    #[secret]
4340    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4341    pub api_key: Option<String>,
4342}
4343
4344/// Google Cloud Speech-to-Text model_provider configuration (`[transcription.google]`).
4345#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4346#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4347#[prefix = "transcription.google"]
4348pub struct GoogleSttConfig {
4349    /// Google Cloud API key.
4350    #[serde(default)]
4351    #[secret]
4352    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4353    pub api_key: Option<String>,
4354    /// BCP-47 language code (default: "en-US").
4355    #[serde(default = "default_google_stt_language_code")]
4356    pub language_code: String,
4357}
4358
4359/// Local/self-hosted Whisper-compatible STT endpoint (`[transcription.local_whisper]`).
4360///
4361/// Configures a self-hosted STT endpoint. Can be on localhost, a private network host, or any reachable URL.
4362#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4363#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4364#[prefix = "transcription.local-whisper"]
4365pub struct LocalWhisperConfig {
4366    /// HTTP or HTTPS endpoint URL, e.g. `"http://10.10.0.1:8001/v1/transcribe"`.
4367    pub url: String,
4368    /// Bearer token for endpoint authentication.
4369    /// Omit for unauthenticated local endpoints.
4370    #[serde(default)]
4371    #[secret]
4372    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4373    pub bearer_token: Option<String>,
4374    /// Maximum audio file size in bytes accepted by this endpoint.
4375    /// Defaults to 25 MB — matching the cloud API cap for a safe out-of-the-box
4376    /// experience. Self-hosted endpoints can accept much larger files; raise this
4377    /// as needed, but note that each transcription call clones the audio buffer
4378    /// into a multipart payload, so peak memory per request is ~2× this value.
4379    #[serde(default = "default_local_whisper_max_audio_bytes")]
4380    pub max_audio_bytes: usize,
4381    /// Request timeout in seconds. Defaults to 300 (large files on local GPU).
4382    #[serde(default = "default_local_whisper_timeout_secs")]
4383    pub timeout_secs: u64,
4384}
4385
4386fn default_local_whisper_max_audio_bytes() -> usize {
4387    25 * 1024 * 1024
4388}
4389
4390fn default_local_whisper_timeout_secs() -> u64 {
4391    300
4392}
4393
4394/// HMAC tool execution receipt configuration, per agent
4395/// (`[agents.<alias>.tool_receipts]`).
4396///
4397/// Receipts are short HMAC-SHA256 tags appended to tool results so the model
4398/// cannot claim it ran a tool that never actually executed. See
4399/// `docs/book/src/security/tool-receipts.md`.
4400#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4401#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4402#[prefix = "delegate-agent.tool_receipts"]
4403pub struct ToolReceiptsConfig {
4404    /// Generate HMAC receipts on every tool execution. Default: `false`.
4405    /// When false, the entire receipt subsystem is inert (no key, no
4406    /// generation, no append, no system-prompt addendum).
4407    #[serde(default)]
4408    pub enabled: bool,
4409    /// Append a trailing `Tool receipts:` block to user-visible replies so
4410    /// receipts are auditable from the channel surface, not just the
4411    /// internal history. Default: `false`.
4412    #[serde(default)]
4413    pub show_in_response: bool,
4414    /// Inject the receipt-echo instruction into the system prompt so the
4415    /// model carries receipts verbatim into its response. Default: `true`.
4416    /// No effect when `enabled = false`.
4417    #[serde(default = "default_inject_system_prompt")]
4418    pub inject_system_prompt: bool,
4419}
4420
4421fn default_inject_system_prompt() -> bool {
4422    true
4423}
4424
4425impl Default for ToolReceiptsConfig {
4426    fn default() -> Self {
4427        Self {
4428            enabled: false,
4429            show_in_response: false,
4430            inject_system_prompt: default_inject_system_prompt(),
4431        }
4432    }
4433}
4434
4435fn default_max_tool_result_chars() -> usize {
4436    50_000
4437}
4438
4439fn default_keep_tool_context_turns() -> usize {
4440    2
4441}
4442
4443fn default_agent_max_tool_iterations() -> usize {
4444    10
4445}
4446
4447fn default_agent_max_history_messages() -> usize {
4448    50
4449}
4450
4451fn default_agent_max_context_tokens() -> usize {
4452    32_000
4453}
4454
4455fn default_agent_tool_dispatcher() -> String {
4456    "auto".into()
4457}
4458
4459fn default_max_system_prompt_chars() -> usize {
4460    0
4461}
4462
4463// ── Pacing ────────────────────────────────────────────────────────
4464
4465/// Pacing controls for slow/local LLM workloads (`[pacing]` section).
4466///
4467/// All fields are optional and default to values that preserve existing
4468/// behavior. When set, they extend — not replace — the existing timeout
4469/// and loop-detection subsystems.
4470#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4471#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4472#[prefix = "pacing"]
4473pub struct PacingConfig {
4474    /// Per-step timeout in seconds: the maximum time allowed for a single
4475    /// LLM inference turn, independent of the total message budget.
4476    /// `None` means no per-step timeout (existing behavior).
4477    #[serde(default)]
4478    pub step_timeout_secs: Option<u64>,
4479
4480    /// Minimum elapsed seconds before loop detection activates.
4481    /// Tasks completing under this threshold get aggressive loop protection;
4482    /// longer-running tasks receive a grace period before the detector starts
4483    /// counting. `None` means loop detection is always active (existing behavior).
4484    #[serde(default)]
4485    pub loop_detection_min_elapsed_secs: Option<u64>,
4486
4487    /// Tool names excluded from identical-output / alternating-pattern loop
4488    /// detection. Useful for browser workflows where `browser_screenshot`
4489    /// structurally resembles a loop even when making progress.
4490    #[serde(default)]
4491    pub loop_ignore_tools: Vec<String>,
4492
4493    /// Override for the hardcoded timeout scaling cap (default: 4).
4494    /// The channel message timeout budget is computed as:
4495    ///   `message_timeout_secs * min(max_tool_iterations, message_timeout_scale_max)`
4496    /// Raising this value lets long multi-step tasks with slow local models
4497    /// receive a proportionally larger budget without inflating the base timeout.
4498    #[serde(default)]
4499    pub message_timeout_scale_max: Option<u64>,
4500
4501    /// Enable pattern-based loop detection (exact repeat, ping-pong,
4502    /// no-progress). Defaults to `true`.
4503    #[serde(default = "default_loop_detection_enabled")]
4504    pub loop_detection_enabled: bool,
4505
4506    /// Sliding window size for the pattern-based loop detector.
4507    /// Defaults to 20.
4508    #[serde(default = "default_loop_detection_window_size")]
4509    pub loop_detection_window_size: usize,
4510
4511    /// Number of consecutive identical tool+args calls before the first
4512    /// escalation (Warning). Defaults to 3.
4513    #[serde(default = "default_loop_detection_max_repeats")]
4514    pub loop_detection_max_repeats: usize,
4515}
4516
4517fn default_loop_detection_enabled() -> bool {
4518    true
4519}
4520
4521fn default_loop_detection_window_size() -> usize {
4522    20
4523}
4524
4525fn default_loop_detection_max_repeats() -> usize {
4526    3
4527}
4528
4529impl Default for PacingConfig {
4530    fn default() -> Self {
4531        Self {
4532            step_timeout_secs: None,
4533            loop_detection_min_elapsed_secs: None,
4534            loop_ignore_tools: Vec::new(),
4535            message_timeout_scale_max: None,
4536            loop_detection_enabled: default_loop_detection_enabled(),
4537            loop_detection_window_size: default_loop_detection_window_size(),
4538            loop_detection_max_repeats: default_loop_detection_max_repeats(),
4539        }
4540    }
4541}
4542
4543/// Skills loading configuration (`[skills]` section).
4544#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4545#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4546#[serde(rename_all = "snake_case")]
4547pub enum SkillsPromptInjectionMode {
4548    /// Inline full skill instructions and tool metadata into the system prompt.
4549    #[default]
4550    Full,
4551    /// Inline only compact skill metadata (name/description/location) and load details on demand.
4552    Compact,
4553}
4554
4555/// Skills loading configuration (`[skills]` section).
4556#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
4557#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4558#[prefix = "skills"]
4559pub struct SkillsConfig {
4560    /// Enable loading and syncing the community open-skills repository.
4561    /// Default: `false` (opt-in).
4562    #[serde(default)]
4563    pub open_skills_enabled: bool,
4564    /// Optional path to a local open-skills repository.
4565    /// If unset, defaults to `$HOME/open-skills` when enabled.
4566    #[serde(default)]
4567    pub open_skills_dir: Option<String>,
4568    /// Allow script-like files in skills (`.sh`, `.bash`, `.ps1`, shebang shell files).
4569    /// Default: `false` (secure by default).
4570    #[serde(default)]
4571    pub allow_scripts: bool,
4572    /// URL of the skills registry repository for bare-name installs.
4573    /// Default: `https://github.com/zeroclaw-labs/zeroclaw-skills`
4574    #[serde(default)]
4575    pub registry_url: Option<String>,
4576    /// Controls how skills are injected into the system prompt.
4577    /// `full` preserves legacy behavior. `compact` keeps context small and loads skills on demand.
4578    #[serde(default)]
4579    pub prompt_injection_mode: SkillsPromptInjectionMode,
4580    /// Autonomous skill creation from successful multi-step task executions.
4581    #[serde(default)]
4582    #[nested]
4583    pub skill_creation: SkillCreationConfig,
4584    /// Prompt-triggered install suggestions for missing skills.
4585    #[serde(default, alias = "install-suggestions")]
4586    #[nested]
4587    pub install_suggestions: SkillInstallSuggestionsConfig,
4588    /// Automatic skill self-improvement after successful skill usage.
4589    #[serde(default)]
4590    #[nested]
4591    pub skill_improvement: SkillImprovementConfig,
4592}
4593
4594/// Autonomous skill creation configuration (`[skills.skill_creation]` section).
4595#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4596#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4597#[prefix = "skills.skill-creation"]
4598#[serde(default)]
4599pub struct SkillCreationConfig {
4600    /// Enable automatic skill creation after successful multi-step tasks.
4601    /// Default: `false`.
4602    pub enabled: bool,
4603    /// Maximum number of auto-generated skills to keep.
4604    /// When exceeded, the oldest auto-generated skill is removed (LRU eviction).
4605    pub max_skills: usize,
4606    /// Embedding similarity threshold for deduplication.
4607    /// Skills with descriptions more similar than this value are skipped.
4608    pub similarity_threshold: f64,
4609}
4610
4611impl Default for SkillCreationConfig {
4612    fn default() -> Self {
4613        Self {
4614            enabled: false,
4615            max_skills: 500,
4616            similarity_threshold: 0.85,
4617        }
4618    }
4619}
4620
4621/// Prompt-triggered skill install suggestions (`[skills.install_suggestions]` section).
4622#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
4623#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4624#[prefix = "skills.install-suggestions"]
4625#[serde(default)]
4626pub struct SkillInstallSuggestionsConfig {
4627    /// Enable suggestions for installable skills before normal agent turns.
4628    /// Default: `false`.
4629    pub enabled: bool,
4630}
4631
4632/// Skill self-improvement configuration (`[skills.auto_improve]` section).
4633#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4634#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4635#[prefix = "skills.skill-improvement"]
4636pub struct SkillImprovementConfig {
4637    /// Enable automatic skill improvement after successful skill usage.
4638    /// Default: `true`.
4639    #[serde(default = "default_true")]
4640    pub enabled: bool,
4641    /// Minimum interval (in seconds) between improvements for the same skill.
4642    /// Default: `3600` (1 hour).
4643    #[serde(default = "default_skill_improvement_cooldown")]
4644    pub cooldown_secs: u64,
4645}
4646
4647fn default_skill_improvement_cooldown() -> u64 {
4648    3600
4649}
4650
4651impl Default for SkillImprovementConfig {
4652    fn default() -> Self {
4653        Self {
4654            enabled: true,
4655            cooldown_secs: 3600,
4656        }
4657    }
4658}
4659
4660/// Pipeline tool configuration (`[pipeline]` section).
4661#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4662#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4663#[prefix = "pipeline"]
4664pub struct PipelineConfig {
4665    /// Enable the `execute_pipeline` meta-tool.
4666    /// Default: `false`.
4667    #[serde(default)]
4668    pub enabled: bool,
4669    /// Maximum number of steps allowed in a single pipeline invocation.
4670    /// Default: `20`.
4671    #[serde(default = "default_pipeline_max_steps")]
4672    pub max_steps: usize,
4673    /// Tools allowed in pipeline steps. Steps referencing tools not on this
4674    /// list are rejected before execution.
4675    #[serde(default)]
4676    pub allowed_tools: Vec<String>,
4677}
4678
4679fn default_pipeline_max_steps() -> usize {
4680    20
4681}
4682
4683impl Default for PipelineConfig {
4684    fn default() -> Self {
4685        Self {
4686            enabled: false,
4687            max_steps: 20,
4688            allowed_tools: Vec::new(),
4689        }
4690    }
4691}
4692
4693/// Multimodal (image) handling configuration (`[multimodal]` section).
4694///
4695/// # Privacy and cost note
4696///
4697/// Tool results that print real local image paths (e.g. shell tools doing
4698/// `ls /pictures` or `find . -name '*.png'`) are canonicalized into
4699/// `[IMAGE:...]` markers and base64-inlined into the next provider request.
4700/// This means image bytes that previously stayed local will be uploaded to
4701/// the configured provider when surfaced by a tool.
4702///
4703/// `max_images` (and the `trim_old_images` LRU policy) bounds the per-request
4704/// image budget, but operators running shell-style tools over directories of
4705/// personal or sensitive images should be aware of the upload semantics. See
4706/// `docs/book/src/contributing/privacy.md` for the project's privacy stance.
4707#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4708#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4709#[prefix = "multimodal"]
4710pub struct MultimodalConfig {
4711    /// Maximum number of image attachments accepted per request.
4712    ///
4713    /// Caps the total number of `[IMAGE:...]` markers that survive into the
4714    /// provider request after multimodal preprocessing. Older images are
4715    /// dropped first when the cumulative count exceeds this limit. Acts as
4716    /// the upper bound on per-turn upload cost when tool outputs surface
4717    /// local image paths.
4718    #[serde(default = "default_multimodal_max_images")]
4719    pub max_images: usize,
4720    /// Maximum image payload size in MiB before base64 encoding.
4721    #[serde(default = "default_multimodal_max_image_size_mb")]
4722    pub max_image_size_mb: usize,
4723    /// Allow fetching remote image URLs (http/https). Disabled by default.
4724    #[serde(default)]
4725    pub allow_remote_fetch: bool,
4726    /// ModelProvider name to use for vision/image messages (e.g. `"ollama"`).
4727    /// When set, messages containing `[IMAGE:]` markers are routed to this
4728    /// model_provider instead of the default text model_provider.
4729    #[serde(default)]
4730    pub vision_model_provider: Option<String>,
4731    /// Model to use when routing to the vision model_provider (e.g. `"llava:7b"`).
4732    /// Only used when `vision_model_provider` is set.
4733    #[serde(default)]
4734    pub vision_model: Option<String>,
4735}
4736
4737fn default_multimodal_max_images() -> usize {
4738    4
4739}
4740
4741fn default_multimodal_max_image_size_mb() -> usize {
4742    5
4743}
4744
4745impl MultimodalConfig {
4746    /// Clamp configured values to safe runtime bounds.
4747    pub fn effective_limits(&self) -> (usize, usize) {
4748        let max_images = self.max_images.clamp(1, 16);
4749        let max_image_size_mb = self.max_image_size_mb.clamp(1, 20);
4750        (max_images, max_image_size_mb)
4751    }
4752}
4753
4754impl Default for MultimodalConfig {
4755    fn default() -> Self {
4756        Self {
4757            max_images: default_multimodal_max_images(),
4758            max_image_size_mb: default_multimodal_max_image_size_mb(),
4759            allow_remote_fetch: false,
4760            vision_model_provider: None,
4761            vision_model: None,
4762        }
4763    }
4764}
4765
4766// ── Media Pipeline ──────────────────────────────────────────────
4767
4768/// Automatic media understanding pipeline configuration (`[media_pipeline]`).
4769///
4770/// When enabled, inbound channel messages with media attachments are
4771/// pre-processed before reaching the agent: audio is transcribed, images are
4772/// annotated, and videos are summarised.
4773#[allow(clippy::struct_excessive_bools)]
4774#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4775#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4776#[prefix = "media-pipeline"]
4777pub struct MediaPipelineConfig {
4778    /// Master toggle for the media pipeline (default: false).
4779    #[serde(default)]
4780    pub enabled: bool,
4781
4782    /// Transcribe audio attachments using the configured transcription model_provider.
4783    #[serde(default = "default_true")]
4784    pub transcribe_audio: bool,
4785
4786    /// Add image descriptions when a vision-capable model is active.
4787    #[serde(default = "default_true")]
4788    pub describe_images: bool,
4789
4790    /// Summarize video attachments (placeholder — requires external API).
4791    #[serde(default = "default_true")]
4792    pub summarize_video: bool,
4793}
4794
4795impl Default for MediaPipelineConfig {
4796    fn default() -> Self {
4797        Self {
4798            enabled: false,
4799            transcribe_audio: true,
4800            describe_images: true,
4801            summarize_video: true,
4802        }
4803    }
4804}
4805
4806// ── Identity (AIEOS / OpenClaw format) ──────────────────────────
4807
4808/// Identity format configuration (`[identity]` section).
4809///
4810/// Supports `"openclaw"` (default) or `"aieos"` identity documents.
4811#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4812#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4813#[prefix = "identity"]
4814pub struct IdentityConfig {
4815    /// Identity format: "openclaw" (default) or "aieos"
4816    #[serde(default = "default_identity_format")]
4817    pub format: String,
4818    /// Path to AIEOS JSON file (relative to workspace)
4819    #[serde(default)]
4820    pub aieos_path: Option<String>,
4821    /// Inline AIEOS JSON (alternative to file path)
4822    #[serde(default)]
4823    pub aieos_inline: Option<String>,
4824}
4825
4826fn default_identity_format() -> String {
4827    "openclaw".into()
4828}
4829
4830impl Default for IdentityConfig {
4831    fn default() -> Self {
4832        Self {
4833            format: default_identity_format(),
4834            aieos_path: None,
4835            aieos_inline: None,
4836        }
4837    }
4838}
4839
4840// ── Cost tracking and budget enforcement ───────────────────────────
4841
4842/// Cost tracking and budget enforcement configuration (`[cost]` section).
4843#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4844#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4845#[prefix = "cost"]
4846pub struct CostConfig {
4847    /// Enable cost tracking (default: true)
4848    #[serde(default = "default_cost_enabled")]
4849    pub enabled: bool,
4850
4851    /// Daily spending limit in USD (default: 10.00)
4852    #[serde(default = "default_daily_limit")]
4853    pub daily_limit_usd: f64,
4854
4855    /// Monthly spending limit in USD (default: 100.00)
4856    #[serde(default = "default_monthly_limit")]
4857    pub monthly_limit_usd: f64,
4858
4859    /// Warn when spending reaches this percentage of limit (default: 80)
4860    #[serde(default = "default_warn_percent")]
4861    pub warn_at_percent: u8,
4862
4863    /// Allow requests to exceed budget with --override flag (default: false)
4864    #[serde(default)]
4865    pub allow_override: bool,
4866
4867    /// Cost enforcement behavior when budget limits are approached or exceeded.
4868    #[serde(default)]
4869    #[nested]
4870    pub enforcement: CostEnforcementConfig,
4871
4872    /// Stamp each recorded cost entry with the originating agent alias so
4873    /// `/api/cost?agent=<alias>` and CLI rollups can attribute spend to a
4874    /// specific agent. Disable on high-volume deployments if the extra
4875    /// HashMap aggregation shows up in profiles (default: true).
4876    #[serde(default = "default_track_per_agent")]
4877    pub track_per_agent: bool,
4878
4879    /// Operator-managed rate sheet at `[cost.rates.*]`. Sections mirror
4880    /// the `[providers.*]` dotted-path exactly with the trailing `alias`
4881    /// segment replaced by the resource the rate applies to (model id,
4882    /// tool name, …). Layout:
4883    ///
4884    /// ```toml
4885    /// [cost.rates.providers.models.anthropic."claude-opus-4-7"]
4886    /// input_per_mtok        = 15.0
4887    /// output_per_mtok       = 75.0
4888    /// cached_input_per_mtok = 1.5
4889    ///
4890    /// [cost.rates.providers.tts.openai."tts-1-hd"]
4891    /// per_mchar = 30.0
4892    ///
4893    /// [cost.rates.providers.transcription.openai.whisper-1]
4894    /// per_minute = 0.006
4895    ///
4896    /// [cost.rates.tools.web_search]
4897    /// per_call = 0.005
4898    /// ```
4899    #[serde(default)]
4900    #[nested]
4901    pub rates: CostRatesConfig,
4902}
4903
4904/// Configuration for cost enforcement behavior when budget limits are reached.
4905#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4906#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4907#[prefix = "cost.enforcement"]
4908pub struct CostEnforcementConfig {
4909    /// Enforcement mode: "warn", "block", or "route_down".
4910    #[serde(default = "default_cost_enforcement_mode")]
4911    pub mode: String,
4912    /// Model hint to route to when budget is exceeded (used with "route_down" mode).
4913    #[serde(default)]
4914    pub route_down_model: Option<String>,
4915    /// Reserve this percentage of budget for critical operations.
4916    #[serde(default = "default_reserve_percent")]
4917    pub reserve_percent: u8,
4918}
4919
4920fn default_cost_enforcement_mode() -> String {
4921    "warn".to_string()
4922}
4923
4924fn default_reserve_percent() -> u8 {
4925    10
4926}
4927
4928impl Default for CostEnforcementConfig {
4929    fn default() -> Self {
4930        Self {
4931            mode: default_cost_enforcement_mode(),
4932            route_down_model: None,
4933            reserve_percent: default_reserve_percent(),
4934        }
4935    }
4936}
4937
4938fn default_daily_limit() -> f64 {
4939    10.0
4940}
4941
4942fn default_monthly_limit() -> f64 {
4943    100.0
4944}
4945
4946fn default_warn_percent() -> u8 {
4947    80
4948}
4949
4950fn default_cost_enabled() -> bool {
4951    true
4952}
4953
4954fn default_track_per_agent() -> bool {
4955    true
4956}
4957
4958/// `[cost.rates]` — top-level rate-sheet namespace. Mirrors the
4959/// `[providers.*]` shape so each subsection here points at the same
4960/// kind of resource its `[providers.*]` counterpart configures.
4961#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4962#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4963#[prefix = "cost.rates"]
4964pub struct CostRatesConfig {
4965    /// `[cost.rates.providers.*]` — rates for everything under
4966    /// `[providers.*]` (models, TTS, transcription, …).
4967    #[serde(default)]
4968    #[nested]
4969    pub providers: ProviderCostRates,
4970
4971    /// `[cost.rates.tools.<name>]` — per-call rates for tools that
4972    /// hit paid APIs. Keyed by the tool's registered name.
4973    #[serde(default)]
4974    #[nested]
4975    #[resource_key]
4976    pub tools: std::collections::HashMap<String, ToolCostRates>,
4977}
4978
4979impl CostRatesConfig {
4980    /// Lookup model token rates by `(provider_type, model)`. Dispatch
4981    /// lives on the typed wrapper — see [`crate::providers::ModelCostRatesByProvider`].
4982    #[must_use]
4983    pub fn model_rates(&self, provider_type: &str, model: &str) -> Option<&ModelCostRates> {
4984        self.providers.models.get(provider_type, model)
4985    }
4986
4987    /// Lookup TTS rates by `(provider_type, voice)`.
4988    #[must_use]
4989    pub fn tts_rates(&self, provider_type: &str, voice: &str) -> Option<&TtsCostRates> {
4990        self.providers.tts.get(provider_type, voice)
4991    }
4992
4993    /// Lookup transcription rates by `(provider_type, model)`.
4994    #[must_use]
4995    pub fn transcription_rates(
4996        &self,
4997        provider_type: &str,
4998        model: &str,
4999    ) -> Option<&TranscriptionCostRates> {
5000        self.providers.transcription.get(provider_type, model)
5001    }
5002
5003    /// Lookup tool per-call rate by registered name.
5004    #[must_use]
5005    pub fn tool_rates(&self, tool_name: &str) -> Option<&ToolCostRates> {
5006        self.tools.get(tool_name)
5007    }
5008}
5009
5010/// `[cost.rates.providers.*]` — provider-shaped rate sheets. Each field
5011/// here mirrors a corresponding field on `[providers.*]` with the
5012/// trailing alias segment replaced by the resource the rate prices.
5013/// The inner typed wrappers carry the per-provider-type slot layout
5014/// and own dispatch (their slot list is the single source of truth,
5015/// shared with their providers counterpart via the `for_each_*_provider_slot!`
5016/// macros in [`crate::providers`]).
5017#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5018#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5019#[prefix = "cost.rates.providers"]
5020pub struct ProviderCostRates {
5021    /// `[cost.rates.providers.models.<type>.<model>]`.
5022    #[serde(default)]
5023    #[nested]
5024    pub models: crate::providers::ModelCostRatesByProvider,
5025    /// `[cost.rates.providers.tts.<type>.<voice>]`.
5026    #[serde(default)]
5027    #[nested]
5028    pub tts: crate::providers::TtsCostRatesByProvider,
5029    /// `[cost.rates.providers.transcription.<type>.<model>]`.
5030    #[serde(default)]
5031    #[nested]
5032    pub transcription: crate::providers::TranscriptionCostRatesByProvider,
5033}
5034
5035/// Token-cost rates for a single chat / completion model, in USD per
5036/// 1M tokens. Every field optional so partial sheets work without
5037/// ceremony (an operator who only knows the input rate can record it).
5038#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5039#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5040#[prefix = "cost.rates.providers.models"]
5041pub struct ModelCostRates {
5042    /// Input tokens (USD per 1M).
5043    #[serde(default, skip_serializing_if = "Option::is_none")]
5044    pub input_per_mtok: Option<f64>,
5045    /// Output tokens (USD per 1M).
5046    #[serde(default, skip_serializing_if = "Option::is_none")]
5047    pub output_per_mtok: Option<f64>,
5048    /// Cached input tokens (USD per 1M). Optional — leave unset on
5049    /// providers that don't charge separately for prompt cache hits.
5050    #[serde(default, skip_serializing_if = "Option::is_none")]
5051    pub cached_input_per_mtok: Option<f64>,
5052}
5053
5054/// Rates for a TTS model, in USD per 1M characters.
5055#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5056#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5057#[prefix = "cost.rates.providers.tts"]
5058pub struct TtsCostRates {
5059    /// Characters synthesised (USD per 1M).
5060    #[serde(default, skip_serializing_if = "Option::is_none")]
5061    pub per_mchar: Option<f64>,
5062}
5063
5064/// Rates for a transcription model, in USD per minute of audio.
5065#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5066#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5067#[prefix = "cost.rates.providers.transcription"]
5068pub struct TranscriptionCostRates {
5069    /// Audio transcribed (USD per minute).
5070    #[serde(default, skip_serializing_if = "Option::is_none")]
5071    pub per_minute: Option<f64>,
5072}
5073
5074/// Rates for a tool that hits a paid external API. Keyed in
5075/// `[cost.rates.tools.<name>]` by the tool's registered name.
5076#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5077#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5078#[prefix = "cost.rates.tools"]
5079pub struct ToolCostRates {
5080    /// Per-call cost (USD).
5081    #[serde(default, skip_serializing_if = "Option::is_none")]
5082    pub per_call: Option<f64>,
5083}
5084
5085impl Default for CostConfig {
5086    fn default() -> Self {
5087        Self {
5088            enabled: true,
5089            daily_limit_usd: default_daily_limit(),
5090            monthly_limit_usd: default_monthly_limit(),
5091            warn_at_percent: default_warn_percent(),
5092            allow_override: false,
5093            enforcement: CostEnforcementConfig::default(),
5094            track_per_agent: default_track_per_agent(),
5095            rates: CostRatesConfig::default(),
5096        }
5097    }
5098}
5099
5100// ── Peripherals (hardware: STM32, RPi GPIO, etc.) ────────────────────────
5101
5102/// Peripheral board integration configuration (`[peripherals]` section).
5103///
5104/// Boards become agent tools when enabled.
5105#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
5106#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5107#[prefix = "peripherals"]
5108pub struct PeripheralsConfig {
5109    /// Enable peripheral support (boards become agent tools)
5110    #[serde(default)]
5111    pub enabled: bool,
5112    /// Board configurations (nucleo-f401re, rpi-gpio, etc.)
5113    #[serde(default)]
5114    pub boards: Vec<PeripheralBoardConfig>,
5115    /// Path to datasheet docs (relative to workspace) for RAG retrieval.
5116    /// Place .md/.txt files named by board (e.g. nucleo-f401re.md, rpi-gpio.md).
5117    #[serde(default)]
5118    pub datasheet_dir: Option<String>,
5119}
5120
5121/// Configuration for a single peripheral board (e.g. STM32, RPi GPIO).
5122#[derive(Debug, Clone, Serialize, Deserialize)]
5123#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5124pub struct PeripheralBoardConfig {
5125    /// Board type: "nucleo-f401re", "rpi-gpio", "esp32", etc.
5126    pub board: String,
5127    /// Transport: "serial", "native", "websocket"
5128    #[serde(default = "default_peripheral_transport")]
5129    pub transport: String,
5130    /// Path for serial: "/dev/ttyACM0", "/dev/ttyUSB0"
5131    #[serde(default)]
5132    pub path: Option<String>,
5133    /// Baud rate for serial (default: 115200)
5134    #[serde(default = "default_peripheral_baud")]
5135    pub baud: u32,
5136}
5137
5138fn default_peripheral_transport() -> String {
5139    "serial".into()
5140}
5141
5142fn default_peripheral_baud() -> u32 {
5143    115_200
5144}
5145
5146impl Default for PeripheralBoardConfig {
5147    fn default() -> Self {
5148        Self {
5149            board: String::new(),
5150            transport: default_peripheral_transport(),
5151            path: None,
5152            baud: default_peripheral_baud(),
5153        }
5154    }
5155}
5156
5157// ── Gateway security ─────────────────────────────────────────────
5158
5159/// Gateway server configuration (`[gateway]` section).
5160///
5161/// Controls the HTTP gateway for webhook and pairing endpoints.
5162#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5163#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5164#[prefix = "gateway"]
5165#[allow(clippy::struct_excessive_bools)]
5166pub struct GatewayConfig {
5167    /// Gateway port (default: 42617)
5168    #[serde(default = "default_gateway_port")]
5169    pub port: u16,
5170    /// Gateway host (default: 127.0.0.1)
5171    #[serde(default = "default_gateway_host")]
5172    pub host: String,
5173    /// Require pairing before accepting requests (default: true)
5174    #[serde(default = "default_true")]
5175    pub require_pairing: bool,
5176    /// Allow binding to non-localhost without a tunnel (default: false)
5177    #[serde(default)]
5178    pub allow_public_bind: bool,
5179    /// Paired bearer tokens (managed automatically, not user-edited)
5180    #[serde(default)]
5181    #[secret]
5182    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5183    pub paired_tokens: Vec<String>,
5184
5185    /// Max `/pair` requests per minute per client key.
5186    #[serde(default = "default_pair_rate_limit")]
5187    pub pair_rate_limit_per_minute: u32,
5188
5189    /// Max `/webhook` requests per minute per client key.
5190    #[serde(default = "default_webhook_rate_limit")]
5191    pub webhook_rate_limit_per_minute: u32,
5192
5193    /// Trust proxy-forwarded client IP headers (`X-Forwarded-For`, `X-Real-IP`).
5194    /// Disabled by default; enable only behind a trusted reverse proxy.
5195    #[serde(default)]
5196    pub trust_forwarded_headers: bool,
5197
5198    /// Optional URL path prefix for reverse-proxy deployments.
5199    /// When set, all gateway routes are served under this prefix.
5200    /// Must start with `/` and must not end with `/`.
5201    #[serde(default)]
5202    pub path_prefix: Option<String>,
5203
5204    /// Maximum distinct client keys tracked by gateway rate limiter maps.
5205    #[serde(default = "default_gateway_rate_limit_max_keys")]
5206    pub rate_limit_max_keys: usize,
5207
5208    /// TTL for webhook idempotency keys.
5209    #[serde(default = "default_idempotency_ttl_secs")]
5210    pub idempotency_ttl_secs: u64,
5211
5212    /// Maximum distinct idempotency keys retained in memory.
5213    #[serde(default = "default_gateway_idempotency_max_keys")]
5214    pub idempotency_max_keys: usize,
5215
5216    /// Persist gateway WebSocket chat sessions to SQLite. Default: true.
5217    #[serde(default = "default_true")]
5218    pub session_persistence: bool,
5219
5220    /// Auto-archive stale gateway sessions older than N hours. 0 = disabled. Default: 0.
5221    #[serde(default)]
5222    pub session_ttl_hours: u32,
5223
5224    /// Pairing dashboard configuration
5225    #[serde(default)]
5226    #[nested]
5227    pub pairing_dashboard: PairingDashboardConfig,
5228
5229    /// Path to the web dashboard `dist` directory.  When set, the gateway
5230    /// serves the compiled frontend from the filesystem instead of requiring
5231    /// it to be embedded in the binary.  Accepts absolute paths or paths
5232    /// relative to the working directory.  When omitted the gateway runs in
5233    /// API-only mode (no web dashboard) unless auto-detection finds it.
5234    #[serde(default)]
5235    pub web_dist_dir: Option<String>,
5236
5237    /// TLS configuration for the gateway server (`[gateway.tls]`).
5238    #[serde(default)]
5239    #[nested]
5240    pub tls: Option<GatewayTlsConfig>,
5241
5242    /// HTTP request timeout (seconds) for gateway routes other than the
5243    /// long-running cron-trigger endpoint. Default: 30s.
5244    #[serde(default = "default_gateway_request_timeout_secs")]
5245    pub request_timeout_secs: u64,
5246
5247    /// HTTP request timeout (seconds) for `POST /api/cron/{id}/run`, which
5248    /// runs jobs synchronously and routinely exceeds the 30s default.
5249    /// Default: 600s (10 minutes).
5250    #[serde(default = "default_gateway_long_running_request_timeout_secs")]
5251    pub long_running_request_timeout_secs: u64,
5252}
5253
5254fn default_gateway_port() -> u16 {
5255    42617
5256}
5257
5258fn default_gateway_request_timeout_secs() -> u64 {
5259    30
5260}
5261
5262fn default_gateway_long_running_request_timeout_secs() -> u64 {
5263    600
5264}
5265
5266fn default_gateway_host() -> String {
5267    "127.0.0.1".into()
5268}
5269
5270fn default_pair_rate_limit() -> u32 {
5271    10
5272}
5273
5274fn default_webhook_rate_limit() -> u32 {
5275    60
5276}
5277
5278fn default_idempotency_ttl_secs() -> u64 {
5279    300
5280}
5281
5282fn default_gateway_rate_limit_max_keys() -> usize {
5283    10_000
5284}
5285
5286fn default_gateway_idempotency_max_keys() -> usize {
5287    10_000
5288}
5289
5290fn default_true() -> bool {
5291    true
5292}
5293
5294fn default_false() -> bool {
5295    false
5296}
5297
5298impl Default for GatewayConfig {
5299    fn default() -> Self {
5300        Self {
5301            port: default_gateway_port(),
5302            host: default_gateway_host(),
5303            require_pairing: true,
5304            allow_public_bind: false,
5305            paired_tokens: Vec::new(),
5306            pair_rate_limit_per_minute: default_pair_rate_limit(),
5307            webhook_rate_limit_per_minute: default_webhook_rate_limit(),
5308            trust_forwarded_headers: false,
5309            path_prefix: None,
5310            rate_limit_max_keys: default_gateway_rate_limit_max_keys(),
5311            idempotency_ttl_secs: default_idempotency_ttl_secs(),
5312            idempotency_max_keys: default_gateway_idempotency_max_keys(),
5313            session_persistence: true,
5314            session_ttl_hours: 0,
5315            pairing_dashboard: PairingDashboardConfig::default(),
5316            web_dist_dir: None,
5317            tls: None,
5318            request_timeout_secs: default_gateway_request_timeout_secs(),
5319            long_running_request_timeout_secs: default_gateway_long_running_request_timeout_secs(),
5320        }
5321    }
5322}
5323
5324/// Pairing dashboard configuration (`[gateway.pairing_dashboard]`).
5325#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5326#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5327#[prefix = "gateway.pairing-dashboard"]
5328pub struct PairingDashboardConfig {
5329    /// Length of pairing codes (default: 8)
5330    #[serde(default = "default_pairing_code_length")]
5331    pub code_length: usize,
5332    /// Time-to-live for pending pairing codes in seconds (default: 3600)
5333    #[serde(default = "default_pairing_ttl")]
5334    pub code_ttl_secs: u64,
5335    /// Maximum concurrent pending pairing codes (default: 3)
5336    #[serde(default = "default_max_pending_codes")]
5337    pub max_pending_codes: usize,
5338    /// Maximum failed pairing attempts before lockout (default: 5)
5339    #[serde(default = "default_max_failed_attempts")]
5340    pub max_failed_attempts: u32,
5341    /// Lockout duration in seconds after max attempts (default: 300)
5342    #[serde(default = "default_pairing_lockout_secs")]
5343    pub lockout_secs: u64,
5344}
5345
5346fn default_pairing_code_length() -> usize {
5347    8
5348}
5349fn default_pairing_ttl() -> u64 {
5350    3600
5351}
5352fn default_max_pending_codes() -> usize {
5353    3
5354}
5355fn default_max_failed_attempts() -> u32 {
5356    5
5357}
5358fn default_pairing_lockout_secs() -> u64 {
5359    300
5360}
5361
5362impl Default for PairingDashboardConfig {
5363    fn default() -> Self {
5364        Self {
5365            code_length: default_pairing_code_length(),
5366            code_ttl_secs: default_pairing_ttl(),
5367            max_pending_codes: default_max_pending_codes(),
5368            max_failed_attempts: default_max_failed_attempts(),
5369            lockout_secs: default_pairing_lockout_secs(),
5370        }
5371    }
5372}
5373
5374/// TLS configuration for the gateway server (`[gateway.tls]`).
5375#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5376#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5377#[prefix = "gateway.tls"]
5378pub struct GatewayTlsConfig {
5379    /// Enable TLS for the gateway (default: false).
5380    #[serde(default)]
5381    pub enabled: bool,
5382    /// Path to the PEM-encoded server certificate file.
5383    pub cert_path: String,
5384    /// Path to the PEM-encoded server private key file.
5385    pub key_path: String,
5386    /// Client certificate authentication (mutual TLS) settings.
5387    #[serde(default)]
5388    #[nested]
5389    pub client_auth: Option<GatewayClientAuthConfig>,
5390}
5391
5392/// Client certificate authentication (mTLS) configuration (`[gateway.tls.client_auth]`).
5393#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5394#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5395#[prefix = "gateway.tls.client-auth"]
5396pub struct GatewayClientAuthConfig {
5397    /// Enable client certificate verification (default: false).
5398    #[serde(default)]
5399    pub enabled: bool,
5400    /// Path to the PEM-encoded CA certificate used to verify client certs.
5401    #[serde(default)]
5402    pub ca_cert_path: String,
5403    /// Reject connections that do not present a valid client certificate (default: true).
5404    #[serde(default = "default_true")]
5405    pub require_client_cert: bool,
5406    /// Optional SHA-256 fingerprints for certificate pinning.
5407    /// When non-empty, only client certs matching one of these fingerprints are accepted.
5408    #[serde(default)]
5409    pub pinned_certs: Vec<String>,
5410}
5411
5412impl Default for GatewayClientAuthConfig {
5413    fn default() -> Self {
5414        Self {
5415            enabled: false,
5416            ca_cert_path: String::new(),
5417            require_client_cert: default_true(),
5418            pinned_certs: Vec::new(),
5419        }
5420    }
5421}
5422
5423/// Secure transport configuration for inter-node communication (`[node_transport]`).
5424#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5425#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5426#[prefix = "node-transport"]
5427pub struct NodeTransportConfig {
5428    /// Enable the secure transport layer.
5429    #[serde(default = "default_node_transport_enabled")]
5430    pub enabled: bool,
5431    /// Shared secret for HMAC authentication between nodes.
5432    #[serde(default)]
5433    pub shared_secret: String,
5434    /// Maximum age of signed requests in seconds (replay protection).
5435    #[serde(default = "default_max_request_age")]
5436    pub max_request_age_secs: i64,
5437    /// Require HTTPS for all node communication.
5438    #[serde(default = "default_require_https")]
5439    pub require_https: bool,
5440    /// Allow specific node IPs/CIDRs.
5441    #[serde(default)]
5442    pub allowed_peers: Vec<String>,
5443    /// Path to TLS certificate file.
5444    #[serde(default)]
5445    pub tls_cert_path: Option<String>,
5446    /// Path to TLS private key file.
5447    #[serde(default)]
5448    pub tls_key_path: Option<String>,
5449    /// Require client certificates (mutual TLS).
5450    #[serde(default)]
5451    pub mutual_tls: bool,
5452    /// Maximum number of connections per peer.
5453    #[serde(default = "default_connection_pool_size")]
5454    pub connection_pool_size: usize,
5455}
5456
5457fn default_node_transport_enabled() -> bool {
5458    true
5459}
5460fn default_max_request_age() -> i64 {
5461    300
5462}
5463fn default_require_https() -> bool {
5464    true
5465}
5466fn default_connection_pool_size() -> usize {
5467    4
5468}
5469
5470impl Default for NodeTransportConfig {
5471    fn default() -> Self {
5472        Self {
5473            enabled: default_node_transport_enabled(),
5474            shared_secret: String::new(),
5475            max_request_age_secs: default_max_request_age(),
5476            require_https: default_require_https(),
5477            allowed_peers: Vec::new(),
5478            tls_cert_path: None,
5479            tls_key_path: None,
5480            mutual_tls: false,
5481            connection_pool_size: default_connection_pool_size(),
5482        }
5483    }
5484}
5485
5486// ── Composio (managed tool surface) ─────────────────────────────
5487
5488/// Composio managed OAuth tools integration (`[composio]` section).
5489///
5490/// Provides access to 1000+ OAuth-connected tools via the Composio platform.
5491#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5492#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5493#[prefix = "composio"]
5494pub struct ComposioConfig {
5495    /// Enable Composio integration for 1000+ OAuth tools
5496    #[serde(default, alias = "enable")]
5497    pub enabled: bool,
5498    /// Composio API key (stored encrypted when secrets.encrypt = true)
5499    #[serde(default)]
5500    #[secret]
5501    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5502    pub api_key: Option<String>,
5503    /// Default entity ID for multi-user setups
5504    #[serde(default = "default_entity_id")]
5505    pub entity_id: String,
5506}
5507
5508fn default_entity_id() -> String {
5509    "default".into()
5510}
5511
5512impl Default for ComposioConfig {
5513    fn default() -> Self {
5514        Self {
5515            enabled: false,
5516            api_key: None,
5517            entity_id: default_entity_id(),
5518        }
5519    }
5520}
5521
5522// ── Microsoft 365 (Graph API integration) ───────────────────────
5523
5524/// Microsoft 365 integration via Microsoft Graph API (`[microsoft365]` section).
5525///
5526/// Provides access to Outlook mail, Teams messages, Calendar events,
5527/// OneDrive files, and SharePoint search.
5528#[derive(Clone, Serialize, Deserialize, Configurable)]
5529#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5530#[prefix = "ms365"]
5531pub struct Microsoft365Config {
5532    /// Enable Microsoft 365 integration
5533    #[serde(default, alias = "enable")]
5534    pub enabled: bool,
5535    /// Azure AD tenant ID
5536    #[serde(default)]
5537    pub tenant_id: Option<String>,
5538    /// Azure AD application (client) ID
5539    #[serde(default)]
5540    pub client_id: Option<String>,
5541    /// Azure AD client secret (stored encrypted when secrets.encrypt = true)
5542    #[serde(default)]
5543    #[secret]
5544    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5545    pub client_secret: Option<String>,
5546    /// Authentication flow: "client_credentials" or "device_code"
5547    #[serde(default = "default_ms365_auth_flow")]
5548    pub auth_flow: String,
5549    /// OAuth scopes to request
5550    #[serde(default = "default_ms365_scopes")]
5551    pub scopes: Vec<String>,
5552    /// Encrypt the token cache file on disk
5553    #[serde(default = "default_true")]
5554    pub token_cache_encrypted: bool,
5555    /// User principal name or "me" (for delegated flows)
5556    #[serde(default)]
5557    pub user_id: Option<String>,
5558}
5559
5560fn default_ms365_auth_flow() -> String {
5561    "client_credentials".to_string()
5562}
5563
5564fn default_ms365_scopes() -> Vec<String> {
5565    vec!["https://graph.microsoft.com/.default".to_string()]
5566}
5567
5568impl std::fmt::Debug for Microsoft365Config {
5569    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5570        f.debug_struct("Microsoft365Config")
5571            .field("enabled", &self.enabled)
5572            .field("tenant_id", &self.tenant_id)
5573            .field("client_id", &self.client_id)
5574            .field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
5575            .field("auth_flow", &self.auth_flow)
5576            .field("scopes", &self.scopes)
5577            .field("token_cache_encrypted", &self.token_cache_encrypted)
5578            .field("user_id", &self.user_id)
5579            .finish()
5580    }
5581}
5582
5583impl Default for Microsoft365Config {
5584    fn default() -> Self {
5585        Self {
5586            enabled: false,
5587            tenant_id: None,
5588            client_id: None,
5589            client_secret: None,
5590            auth_flow: default_ms365_auth_flow(),
5591            scopes: default_ms365_scopes(),
5592            token_cache_encrypted: true,
5593            user_id: None,
5594        }
5595    }
5596}
5597
5598// ── Secrets (encrypted credential store) ────────────────────────
5599
5600/// Secrets encryption configuration (`[secrets]` section).
5601#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5602#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5603#[prefix = "secrets"]
5604pub struct SecretsConfig {
5605    /// Enable encryption for API keys and tokens in config.toml
5606    #[serde(default = "default_true")]
5607    pub encrypt: bool,
5608}
5609
5610impl Default for SecretsConfig {
5611    fn default() -> Self {
5612        Self { encrypt: true }
5613    }
5614}
5615
5616// ── Browser (friendly-service browsing only) ───────────────────
5617
5618/// Computer-use sidecar configuration (`[browser.computer_use]` section).
5619///
5620/// Delegates OS-level mouse, keyboard, and screenshot actions to a local sidecar.
5621#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5622#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5623#[prefix = "browser.computer-use"]
5624pub struct BrowserComputerUseConfig {
5625    /// Sidecar endpoint for computer-use actions (OS-level mouse/keyboard/screenshot)
5626    #[serde(default = "default_browser_computer_use_endpoint")]
5627    pub endpoint: String,
5628    /// Optional bearer token for computer-use sidecar
5629    #[serde(default)]
5630    #[secret]
5631    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5632    pub api_key: Option<String>,
5633    /// Per-action request timeout in milliseconds
5634    #[serde(default = "default_browser_computer_use_timeout_ms")]
5635    pub timeout_ms: u64,
5636    /// Allow remote/public endpoint for computer-use sidecar (default: false)
5637    #[serde(default)]
5638    pub allow_remote_endpoint: bool,
5639    /// Optional window title/process allowlist forwarded to sidecar policy
5640    #[serde(default)]
5641    pub window_allowlist: Vec<String>,
5642    /// Optional X-axis boundary for coordinate-based actions
5643    #[serde(default)]
5644    pub max_coordinate_x: Option<i64>,
5645    /// Optional Y-axis boundary for coordinate-based actions
5646    #[serde(default)]
5647    pub max_coordinate_y: Option<i64>,
5648}
5649
5650fn default_browser_computer_use_endpoint() -> String {
5651    "http://127.0.0.1:8787/v1/actions".into()
5652}
5653
5654fn default_browser_computer_use_timeout_ms() -> u64 {
5655    15_000
5656}
5657
5658impl Default for BrowserComputerUseConfig {
5659    fn default() -> Self {
5660        Self {
5661            endpoint: default_browser_computer_use_endpoint(),
5662            api_key: None,
5663            timeout_ms: default_browser_computer_use_timeout_ms(),
5664            allow_remote_endpoint: false,
5665            window_allowlist: Vec::new(),
5666            max_coordinate_x: None,
5667            max_coordinate_y: None,
5668        }
5669    }
5670}
5671
5672/// Browser automation configuration (`[browser]` section).
5673///
5674/// Controls the `browser_open` tool and browser automation backends.
5675#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5676#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5677#[prefix = "browser"]
5678#[integration(
5679    category = "ToolsAutomation",
5680    display_name = "Browser",
5681    description = "Chrome/Chromium control",
5682    status_field = "enabled"
5683)]
5684pub struct BrowserConfig {
5685    /// Enable `browser_open` tool (opens URLs in the system browser without scraping)
5686    #[serde(default = "default_true")]
5687    pub enabled: bool,
5688    /// Allowed domains for `browser_open` (exact or subdomain match)
5689    #[serde(default = "default_browser_allowed_domains")]
5690    pub allowed_domains: Vec<String>,
5691    /// Browser session name (for agent-browser automation)
5692    #[serde(default)]
5693    pub session_name: Option<String>,
5694    /// Browser automation backend: "agent_browser" | "rust_native" | "computer_use" | "auto"
5695    #[serde(default = "default_browser_backend")]
5696    pub backend: String,
5697    /// Show browser window for agent_browser backend. When unset, inherits AGENT_BROWSER_HEADED.
5698    #[serde(default)]
5699    pub headed: Option<bool>,
5700    /// Headless mode for rust-native backend
5701    #[serde(default = "default_true")]
5702    pub native_headless: bool,
5703    /// WebDriver endpoint URL for rust-native backend (e.g. `http://127.0.0.1:9515`)
5704    #[serde(default = "default_browser_webdriver_url")]
5705    pub native_webdriver_url: String,
5706    /// Optional Chrome/Chromium executable path for rust-native backend
5707    #[serde(default)]
5708    pub native_chrome_path: Option<String>,
5709    /// Computer-use sidecar configuration
5710    #[serde(default)]
5711    #[nested]
5712    pub computer_use: BrowserComputerUseConfig,
5713}
5714
5715fn default_browser_allowed_domains() -> Vec<String> {
5716    vec!["*".into()]
5717}
5718
5719fn default_browser_backend() -> String {
5720    "agent_browser".into()
5721}
5722
5723fn default_browser_webdriver_url() -> String {
5724    "http://127.0.0.1:9515".into()
5725}
5726
5727impl Default for BrowserConfig {
5728    fn default() -> Self {
5729        Self {
5730            enabled: true,
5731            allowed_domains: vec!["*".into()],
5732            session_name: None,
5733            backend: default_browser_backend(),
5734            headed: None,
5735            native_headless: default_true(),
5736            native_webdriver_url: default_browser_webdriver_url(),
5737            native_chrome_path: None,
5738            computer_use: BrowserComputerUseConfig::default(),
5739        }
5740    }
5741}
5742
5743// ── HTTP request tool ───────────────────────────────────────────
5744
5745/// HTTP request tool configuration (`[http_request]` section).
5746///
5747/// Domain filtering: `allowed_domains` controls which hosts are reachable (use `["*"]`
5748/// for all public hosts, which is the default). If `allowed_domains` is empty, all
5749/// requests are rejected.
5750#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5751#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5752#[prefix = "http-request"]
5753pub struct HttpRequestConfig {
5754    /// Enable `http_request` tool for API interactions
5755    #[serde(default)]
5756    pub enabled: bool,
5757    /// Allowed domains for HTTP requests (exact or subdomain match)
5758    #[serde(default)]
5759    pub allowed_domains: Vec<String>,
5760    /// Maximum response size in bytes (default: 1MB, 0 = unlimited)
5761    #[serde(default = "default_http_max_response_size")]
5762    pub max_response_size: usize,
5763    /// Request timeout in seconds (default: 30)
5764    #[serde(default = "default_http_timeout_secs")]
5765    pub timeout_secs: u64,
5766    /// Allow requests to private/LAN hosts (RFC 1918, loopback, link-local, .local).
5767    /// Default: false (deny private hosts for SSRF protection).
5768    #[serde(default)]
5769    pub allow_private_hosts: bool,
5770}
5771
5772impl Default for HttpRequestConfig {
5773    fn default() -> Self {
5774        Self {
5775            enabled: true,
5776            allowed_domains: vec!["*".into()],
5777            max_response_size: default_http_max_response_size(),
5778            timeout_secs: default_http_timeout_secs(),
5779            allow_private_hosts: false,
5780        }
5781    }
5782}
5783
5784fn default_http_max_response_size() -> usize {
5785    1_000_000 // 1MB
5786}
5787
5788fn default_http_timeout_secs() -> u64 {
5789    30
5790}
5791
5792// ── Web fetch ────────────────────────────────────────────────────
5793
5794/// Web fetch tool configuration (`[web_fetch]` section).
5795///
5796/// Fetches web pages and converts HTML to plain text for LLM consumption.
5797/// Domain filtering: `allowed_domains` controls which hosts are reachable (use `["*"]`
5798/// for all public hosts). `blocked_domains` takes priority over `allowed_domains`.
5799/// If `allowed_domains` is empty, all requests are rejected (deny-by-default).
5800#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5801#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5802#[prefix = "web-fetch"]
5803pub struct WebFetchConfig {
5804    /// Enable `web_fetch` tool for fetching web page content
5805    #[serde(default)]
5806    pub enabled: bool,
5807    /// Allowed domains for web fetch (exact or subdomain match; `["*"]` = all public hosts)
5808    #[serde(default = "default_web_fetch_allowed_domains")]
5809    pub allowed_domains: Vec<String>,
5810    /// Blocked domains (exact or subdomain match; always takes priority over allowed_domains)
5811    #[serde(default)]
5812    pub blocked_domains: Vec<String>,
5813    /// Private/internal hosts allowed to bypass SSRF protection (e.g. `["192.168.1.10", "internal.local"]`)
5814    #[serde(default)]
5815    pub allowed_private_hosts: Vec<String>,
5816    /// Maximum response size in bytes (default: 500KB, plain text is much smaller than raw HTML)
5817    #[serde(default = "default_web_fetch_max_response_size")]
5818    pub max_response_size: usize,
5819    /// Request timeout in seconds (default: 30)
5820    #[serde(default = "default_web_fetch_timeout_secs")]
5821    pub timeout_secs: u64,
5822    /// Firecrawl fallback configuration (`[web_fetch.firecrawl]`)
5823    #[serde(default)]
5824    #[nested]
5825    pub firecrawl: FirecrawlConfig,
5826}
5827
5828/// Firecrawl fallback mode: scrape a single page or crawl linked pages.
5829#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
5830#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5831#[serde(rename_all = "lowercase")]
5832pub enum FirecrawlMode {
5833    #[default]
5834    Scrape,
5835    /// Reserved for future multi-page crawl support. Accepted in config
5836    /// deserialization to avoid breaking existing files, but not yet
5837    /// implemented — `fetch_via_firecrawl` always uses the `/scrape` endpoint.
5838    Crawl,
5839}
5840
5841/// Firecrawl fallback configuration for JS-heavy and bot-blocked sites.
5842///
5843/// When enabled, if the standard web fetch fails (HTTP error, empty body, or
5844/// body shorter than 100 characters suggesting a JS-only page), the tool
5845/// falls back to the Firecrawl API for stealth content extraction.
5846#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5847#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5848#[prefix = "web-fetch.firecrawl"]
5849pub struct FirecrawlConfig {
5850    /// Enable Firecrawl fallback
5851    #[serde(default)]
5852    pub enabled: bool,
5853    /// Environment variable name for the Firecrawl API key
5854    #[serde(default = "default_firecrawl_api_key_env")]
5855    pub api_key_env: String,
5856    /// Firecrawl API base URL
5857    #[serde(default = "default_firecrawl_api_url")]
5858    pub api_url: String,
5859    /// Firecrawl extraction mode
5860    #[serde(default)]
5861    pub mode: FirecrawlMode,
5862}
5863
5864fn default_firecrawl_api_key_env() -> String {
5865    "FIRECRAWL_API_KEY".into()
5866}
5867
5868fn default_firecrawl_api_url() -> String {
5869    "https://api.firecrawl.dev/v1".into()
5870}
5871
5872impl Default for FirecrawlConfig {
5873    fn default() -> Self {
5874        Self {
5875            enabled: false,
5876            api_key_env: default_firecrawl_api_key_env(),
5877            api_url: default_firecrawl_api_url(),
5878            mode: FirecrawlMode::default(),
5879        }
5880    }
5881}
5882
5883fn default_web_fetch_max_response_size() -> usize {
5884    500_000 // 500KB
5885}
5886
5887fn default_web_fetch_timeout_secs() -> u64 {
5888    30
5889}
5890
5891fn default_web_fetch_allowed_domains() -> Vec<String> {
5892    vec!["*".into()]
5893}
5894
5895impl Default for WebFetchConfig {
5896    fn default() -> Self {
5897        Self {
5898            enabled: true,
5899            allowed_domains: vec!["*".into()],
5900            blocked_domains: vec![],
5901            allowed_private_hosts: vec![],
5902            max_response_size: default_web_fetch_max_response_size(),
5903            timeout_secs: default_web_fetch_timeout_secs(),
5904            firecrawl: FirecrawlConfig::default(),
5905        }
5906    }
5907}
5908
5909// ── Link enricher ─────────────────────────────────────────────────
5910
5911/// Automatic link understanding for inbound channel messages (`[link_enricher]`).
5912///
5913/// When enabled, URLs in incoming messages are automatically fetched and
5914/// summarised. The summary is prepended to the message before the agent
5915/// processes it, giving the LLM context about linked pages without an
5916/// explicit tool call.
5917#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5918#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5919#[prefix = "link-enricher"]
5920pub struct LinkEnricherConfig {
5921    /// Enable the link enricher pipeline stage (default: false)
5922    #[serde(default)]
5923    pub enabled: bool,
5924    /// Maximum number of links to fetch per message (default: 3)
5925    #[serde(default = "default_link_enricher_max_links")]
5926    pub max_links: usize,
5927    /// Per-link fetch timeout in seconds (default: 10)
5928    #[serde(default = "default_link_enricher_timeout_secs")]
5929    pub timeout_secs: u64,
5930}
5931
5932fn default_link_enricher_max_links() -> usize {
5933    3
5934}
5935
5936fn default_link_enricher_timeout_secs() -> u64 {
5937    10
5938}
5939
5940impl Default for LinkEnricherConfig {
5941    fn default() -> Self {
5942        Self {
5943            enabled: false,
5944            max_links: default_link_enricher_max_links(),
5945            timeout_secs: default_link_enricher_timeout_secs(),
5946        }
5947    }
5948}
5949
5950// ── Text browser ─────────────────────────────────────────────────
5951
5952/// Text browser tool configuration (`[text_browser]` section).
5953///
5954/// Uses text-based browsers (lynx, links, w3m) to render web pages as plain
5955/// text. Designed for headless/SSH environments without graphical browsers.
5956#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5957#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5958#[prefix = "text-browser"]
5959pub struct TextBrowserConfig {
5960    /// Enable `text_browser` tool
5961    #[serde(default)]
5962    pub enabled: bool,
5963    /// Preferred text browser ("lynx", "links", or "w3m"). If unset, auto-detects.
5964    #[serde(default)]
5965    pub preferred_browser: Option<String>,
5966    /// Request timeout in seconds (default: 30)
5967    #[serde(default = "default_text_browser_timeout_secs")]
5968    pub timeout_secs: u64,
5969}
5970
5971fn default_text_browser_timeout_secs() -> u64 {
5972    30
5973}
5974
5975impl Default for TextBrowserConfig {
5976    fn default() -> Self {
5977        Self {
5978            enabled: false,
5979            preferred_browser: None,
5980            timeout_secs: default_text_browser_timeout_secs(),
5981        }
5982    }
5983}
5984
5985// ── Shell tool ───────────────────────────────────────────────────
5986
5987/// Shell tool configuration (`[shell_tool]` section).
5988///
5989/// Controls the behaviour of the `shell` execution tool. The main
5990/// tunable is `timeout_secs` — the maximum wall-clock time a single
5991/// shell command may run before it is killed.
5992#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5993#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5994#[prefix = "shell-tool"]
5995pub struct ShellToolConfig {
5996    /// Maximum shell command execution time in seconds (default: 60).
5997    #[serde(default = "default_shell_tool_timeout_secs")]
5998    pub timeout_secs: u64,
5999}
6000
6001fn default_shell_tool_timeout_secs() -> u64 {
6002    60
6003}
6004
6005impl Default for ShellToolConfig {
6006    fn default() -> Self {
6007        Self {
6008            timeout_secs: default_shell_tool_timeout_secs(),
6009        }
6010    }
6011}
6012
6013// ── Escalation routing ───────────────────────────────────────────
6014
6015/// Escalation routing configuration (`[escalation]` section).
6016///
6017/// Controls which channels receive alert notifications when
6018/// `escalate_to_human` is called with high or critical urgency.
6019/// Channels are identified by name (e.g. `"telegram"`, `"slack"`).
6020/// Alerts are sent best-effort and do not block the escalation.
6021#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
6022#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6023#[prefix = "escalation"]
6024pub struct EscalationConfig {
6025    /// Channel names to alert on high/critical escalations (default: empty).
6026    ///
6027    /// Each name must match a configured channel. Unrecognised names are
6028    /// logged at WARN level and skipped.
6029    #[serde(default)]
6030    pub alert_channels: Vec<String>,
6031}
6032
6033// ── Web search ───────────────────────────────────────────────────
6034
6035/// Web search tool configuration (`[web_search]` section).
6036#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6037#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6038#[prefix = "web-search"]
6039pub struct WebSearchConfig {
6040    /// Enable `web_search_tool` for web searches
6041    #[serde(default)]
6042    pub enabled: bool,
6043    /// Search provider: "duckduckgo" (free), "brave" (requires API key), "tavily" (requires API key), or "searxng" (self-hosted)
6044    #[serde(default = "default_web_search_provider")]
6045    pub search_provider: String,
6046    /// Brave Search API key (required if search_provider is "brave")
6047    #[serde(default)]
6048    #[secret]
6049    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6050    pub brave_api_key: Option<String>,
6051    /// Tavily Search API key (required if search_provider is "tavily")
6052    #[serde(default)]
6053    #[secret]
6054    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6055    pub tavily_api_key: Option<String>,
6056    /// SearXNG instance URL (required if search_provider is `"searxng"`), e.g. `"https://searx.example.com"`.
6057    #[serde(default)]
6058    pub searxng_instance_url: Option<String>,
6059    /// Maximum results per search (1-10)
6060    #[serde(default = "default_web_search_max_results")]
6061    pub max_results: usize,
6062    /// Request timeout in seconds
6063    #[serde(default = "default_web_search_timeout_secs")]
6064    pub timeout_secs: u64,
6065}
6066
6067fn default_web_search_provider() -> String {
6068    "duckduckgo".into()
6069}
6070
6071fn default_web_search_max_results() -> usize {
6072    5
6073}
6074
6075fn default_web_search_timeout_secs() -> u64 {
6076    15
6077}
6078
6079impl Default for WebSearchConfig {
6080    fn default() -> Self {
6081        Self {
6082            enabled: true,
6083            search_provider: default_web_search_provider(),
6084            brave_api_key: None,
6085            tavily_api_key: None,
6086            searxng_instance_url: None,
6087            max_results: default_web_search_max_results(),
6088            timeout_secs: default_web_search_timeout_secs(),
6089        }
6090    }
6091}
6092
6093// ── Project Intelligence ────────────────────────────────────────
6094
6095/// Project delivery intelligence configuration (`[project_intel]` section).
6096#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6097#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6098#[prefix = "project-intel"]
6099pub struct ProjectIntelConfig {
6100    /// Enable the project_intel tool. Default: false.
6101    #[serde(default)]
6102    pub enabled: bool,
6103    /// Default report language (en, de, fr, it). Default: "en".
6104    #[serde(default = "default_project_intel_language")]
6105    pub default_language: String,
6106    /// Output directory for generated reports.
6107    #[serde(default = "default_project_intel_report_dir")]
6108    pub report_output_dir: String,
6109    /// Optional custom templates directory.
6110    #[serde(default)]
6111    pub templates_dir: Option<String>,
6112    /// Risk detection sensitivity: low, medium, high. Default: "medium".
6113    #[serde(default = "default_project_intel_risk_sensitivity")]
6114    pub risk_sensitivity: String,
6115    /// Include git log data in reports. Default: true.
6116    #[serde(default = "default_true")]
6117    pub include_git_data: bool,
6118    /// Include Jira data in reports. Default: false.
6119    #[serde(default)]
6120    pub include_jira_data: bool,
6121    /// Jira instance base URL (required if include_jira_data is true).
6122    #[serde(default)]
6123    pub jira_base_url: Option<String>,
6124}
6125
6126fn default_project_intel_language() -> String {
6127    "en".into()
6128}
6129
6130fn default_project_intel_report_dir() -> String {
6131    default_path_under_config_dir("project-reports")
6132}
6133
6134fn default_project_intel_risk_sensitivity() -> String {
6135    "medium".into()
6136}
6137
6138impl Default for ProjectIntelConfig {
6139    fn default() -> Self {
6140        Self {
6141            enabled: false,
6142            default_language: default_project_intel_language(),
6143            report_output_dir: default_project_intel_report_dir(),
6144            templates_dir: None,
6145            risk_sensitivity: default_project_intel_risk_sensitivity(),
6146            include_git_data: true,
6147            include_jira_data: false,
6148            jira_base_url: None,
6149        }
6150    }
6151}
6152
6153// ── Backup ──────────────────────────────────────────────────────
6154
6155/// Backup tool configuration (`[backup]` section).
6156#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6157#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6158#[prefix = "backup"]
6159pub struct BackupConfig {
6160    /// Enable the `backup` tool.
6161    #[serde(default = "default_true")]
6162    pub enabled: bool,
6163    /// Maximum number of backups to keep (oldest are pruned).
6164    #[serde(default = "default_backup_max_keep")]
6165    pub max_keep: usize,
6166    /// Workspace subdirectories to include in backups.
6167    #[serde(default = "default_backup_include_dirs")]
6168    pub include_dirs: Vec<String>,
6169    /// Output directory for backup archives (relative to workspace root).
6170    #[serde(default = "default_backup_destination_dir")]
6171    pub destination_dir: String,
6172    /// Optional cron expression for scheduled automatic backups.
6173    #[serde(default)]
6174    pub schedule_cron: Option<String>,
6175    /// IANA timezone for `schedule_cron`.
6176    #[serde(default)]
6177    pub schedule_timezone: Option<String>,
6178    /// Compress backup archives.
6179    #[serde(default = "default_true")]
6180    pub compress: bool,
6181    /// Encrypt backup archives (requires a configured secret store key).
6182    #[serde(default)]
6183    pub encrypt: bool,
6184}
6185
6186fn default_backup_max_keep() -> usize {
6187    10
6188}
6189
6190fn default_backup_include_dirs() -> Vec<String> {
6191    vec![
6192        "config".into(),
6193        "memory".into(),
6194        "audit".into(),
6195        "knowledge".into(),
6196    ]
6197}
6198
6199fn default_backup_destination_dir() -> String {
6200    "state/backups".into()
6201}
6202
6203impl Default for BackupConfig {
6204    fn default() -> Self {
6205        Self {
6206            enabled: true,
6207            max_keep: default_backup_max_keep(),
6208            include_dirs: default_backup_include_dirs(),
6209            destination_dir: default_backup_destination_dir(),
6210            schedule_cron: None,
6211            schedule_timezone: None,
6212            compress: true,
6213            encrypt: false,
6214        }
6215    }
6216}
6217
6218// ── Data Retention ──────────────────────────────────────────────
6219
6220/// Data retention and purge configuration (`[data_retention]` section).
6221#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6222#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6223#[prefix = "data-retention"]
6224pub struct DataRetentionConfig {
6225    /// Enable the `data_management` tool.
6226    #[serde(default)]
6227    pub enabled: bool,
6228    /// Days of data to retain before purge eligibility.
6229    #[serde(default = "default_retention_days")]
6230    pub retention_days: u64,
6231    /// Preview what would be deleted without actually removing anything.
6232    #[serde(default)]
6233    pub dry_run: bool,
6234    /// Limit retention enforcement to specific data categories (empty = all).
6235    #[serde(default)]
6236    pub categories: Vec<String>,
6237}
6238
6239fn default_retention_days() -> u64 {
6240    90
6241}
6242
6243impl Default for DataRetentionConfig {
6244    fn default() -> Self {
6245        Self {
6246            enabled: false,
6247            retention_days: default_retention_days(),
6248            dry_run: false,
6249            categories: Vec::new(),
6250        }
6251    }
6252}
6253
6254// ── Google Workspace ─────────────────────────────────────────────
6255
6256/// Built-in default service allowlist for the `google_workspace` tool.
6257///
6258/// Applied when `allowed_services` is empty. Defined here (not in the tool layer)
6259/// so that config validation can cross-check `allowed_operations` entries against
6260/// the effective service set in all cases, including when the operator relies on
6261/// the default.
6262pub const DEFAULT_GWS_SERVICES: &[&str] = &[
6263    "drive",
6264    "sheets",
6265    "gmail",
6266    "calendar",
6267    "docs",
6268    "slides",
6269    "tasks",
6270    "people",
6271    "chat",
6272    "classroom",
6273    "forms",
6274    "keep",
6275    "meet",
6276    "events",
6277];
6278
6279/// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]` section).
6280///
6281/// ## Defaults
6282/// - `enabled`: `false` (tool is not registered unless explicitly opted-in).
6283/// - `allowed_services`: empty vector, which grants access to the full default
6284///   service set: `drive`, `sheets`, `gmail`, `calendar`, `docs`, `slides`,
6285///   `tasks`, `people`, `chat`, `classroom`, `forms`, `keep`, `meet`, `events`.
6286/// - `credentials_path`: `None` (uses default `gws` credential discovery).
6287/// - `default_account`: `None` (uses the `gws` active account).
6288/// - `rate_limit_per_minute`: `60`.
6289/// - `timeout_secs`: `30`.
6290/// - `audit_log`: `false`.
6291/// - `credentials_path`: `None` (uses default `gws` credential discovery).
6292/// - `default_account`: `None` (uses the `gws` active account).
6293/// - `rate_limit_per_minute`: `60`.
6294/// - `timeout_secs`: `30`.
6295/// - `audit_log`: `false`.
6296///
6297/// ## Compatibility
6298/// Configs that omit the `[google_workspace]` section entirely are treated as
6299/// `GoogleWorkspaceConfig::default()` (disabled, all defaults allowed). Adding
6300/// the section is purely opt-in and does not affect other config sections.
6301///
6302/// ## Rollback / Migration
6303/// To revert, remove the `[google_workspace]` section from the config file (or
6304/// set `enabled = false`). No data migration is required; the tool simply stops
6305/// being registered.
6306#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6307#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6308pub struct GoogleWorkspaceAllowedOperation {
6309    /// Google Workspace service ID (for example `gmail` or `drive`).
6310    pub service: String,
6311    /// Top-level resource name for the service (for example `users` for Gmail or `files` for Drive).
6312    pub resource: String,
6313    /// Optional sub-resource for 4-segment gws commands
6314    /// (for example `messages` or `drafts` under `gmail users`).
6315    /// When present, the entry only matches calls that include this exact sub_resource.
6316    /// When absent, the entry only matches calls with no sub_resource.
6317    #[serde(default)]
6318    pub sub_resource: Option<String>,
6319    /// Allowed methods for the service/resource/sub_resource combination.
6320    #[serde(default)]
6321    pub methods: Vec<String>,
6322}
6323
6324/// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]` section).
6325///
6326/// ## Defaults
6327/// - `enabled`: `false` (tool is not registered unless explicitly opted-in).
6328/// - `allowed_services`: empty vector, which grants access to the full default
6329///   service set: `drive`, `sheets`, `gmail`, `calendar`, `docs`, `slides`,
6330///   `tasks`, `people`, `chat`, `classroom`, `forms`, `keep`, `meet`, `events`.
6331/// - `allowed_operations`: empty vector, which preserves the legacy behavior of
6332///   allowing any resource/method under the allowed service set.
6333/// - `credentials_path`: `None` (uses default `gws` credential discovery).
6334/// - `default_account`: `None` (uses the `gws` active account).
6335/// - `rate_limit_per_minute`: `60`.
6336/// - `timeout_secs`: `30`.
6337/// - `audit_log`: `false`.
6338///
6339/// ## Compatibility
6340/// Configs that omit the `[google_workspace]` section entirely are treated as
6341/// `GoogleWorkspaceConfig::default()` (disabled, all defaults allowed). Adding
6342/// the section is purely opt-in and does not affect other config sections.
6343///
6344/// ## Rollback / Migration
6345/// To revert, remove the `[google_workspace]` section from the config file (or
6346/// set `enabled = false`). No data migration is required; the tool simply stops
6347/// being registered.
6348#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6349#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6350#[prefix = "google-workspace"]
6351#[integration(
6352    category = "ToolsAutomation",
6353    display_name = "Google Workspace",
6354    description = "Drive, Gmail, Calendar, Sheets, Docs via gws CLI",
6355    status_field = "enabled"
6356)]
6357pub struct GoogleWorkspaceConfig {
6358    /// Enable the `google_workspace` tool. Default: `false`.
6359    #[serde(default)]
6360    pub enabled: bool,
6361    /// Restrict which Google Workspace services the agent can access.
6362    ///
6363    /// When empty (the default), the full default service set is allowed (see
6364    /// struct-level docs). When non-empty, only the listed service IDs are
6365    /// permitted. Each entry must be non-empty, lowercase alphanumeric with
6366    /// optional underscores/hyphens, and unique.
6367    #[serde(default)]
6368    pub allowed_services: Vec<String>,
6369    /// Restrict which resource/method combinations the agent can access.
6370    ///
6371    /// When empty (the default), all methods under `allowed_services` remain
6372    /// available for backward compatibility. When non-empty, the runtime denies
6373    /// any `(service, resource, sub_resource, method)` combination that is not
6374    /// explicitly listed. `sub_resource` is optional per entry: an entry without
6375    /// it matches only 3-segment `gws` calls; an entry with it matches only calls
6376    /// that supply that exact sub_resource value.
6377    ///
6378    /// Each entry's `service` must appear in `allowed_services` when that list is
6379    /// non-empty; config validation rejects entries that would never match at
6380    /// runtime.
6381    #[serde(default)]
6382    pub allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
6383    /// Path to service account JSON or OAuth client credentials file.
6384    ///
6385    /// When `None`, the tool relies on the default `gws` credential discovery
6386    /// (`gws auth login`). Set this to point at a service-account key or an
6387    /// OAuth client-secrets JSON for headless / CI environments.
6388    #[serde(default)]
6389    pub credentials_path: Option<String>,
6390    /// Default Google account email to pass to `gws --account`.
6391    ///
6392    /// When `None`, the currently active `gws` account is used.
6393    #[serde(default)]
6394    pub default_account: Option<String>,
6395    /// Maximum number of `gws` API calls allowed per minute. Default: `60`.
6396    #[serde(default = "default_gws_rate_limit")]
6397    pub rate_limit_per_minute: u32,
6398    /// Command execution timeout in seconds. Default: `30`.
6399    #[serde(default = "default_gws_timeout_secs")]
6400    pub timeout_secs: u64,
6401    /// Enable audit logging of every `gws` invocation (service, resource,
6402    /// method, timestamp). Default: `false`.
6403    #[serde(default)]
6404    pub audit_log: bool,
6405}
6406
6407fn default_gws_rate_limit() -> u32 {
6408    60
6409}
6410
6411fn default_gws_timeout_secs() -> u64 {
6412    30
6413}
6414
6415impl Default for GoogleWorkspaceConfig {
6416    fn default() -> Self {
6417        Self {
6418            enabled: false,
6419            allowed_services: Vec::new(),
6420            allowed_operations: Vec::new(),
6421            credentials_path: None,
6422            default_account: None,
6423            rate_limit_per_minute: default_gws_rate_limit(),
6424            timeout_secs: default_gws_timeout_secs(),
6425            audit_log: false,
6426        }
6427    }
6428}
6429
6430// ── Knowledge ───────────────────────────────────────────────────
6431
6432/// Knowledge graph configuration for capturing and reusing expertise.
6433#[allow(clippy::struct_excessive_bools)]
6434#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6435#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6436#[prefix = "knowledge"]
6437pub struct KnowledgeConfig {
6438    /// Enable the knowledge graph tool. Default: false.
6439    #[serde(default)]
6440    pub enabled: bool,
6441    /// Path to the knowledge graph SQLite database.
6442    #[serde(default = "default_knowledge_db_path")]
6443    pub db_path: String,
6444    /// Maximum number of knowledge nodes. Default: 100000.
6445    #[serde(default = "default_knowledge_max_nodes")]
6446    pub max_nodes: usize,
6447    /// Automatically capture knowledge from conversations. Default: false.
6448    #[serde(default)]
6449    pub auto_capture: bool,
6450    /// Proactively suggest relevant knowledge on queries. Default: true.
6451    #[serde(default = "default_true")]
6452    pub suggest_on_query: bool,
6453}
6454
6455fn default_knowledge_db_path() -> String {
6456    default_path_under_config_dir("knowledge.db")
6457}
6458
6459fn default_knowledge_max_nodes() -> usize {
6460    100_000
6461}
6462
6463impl Default for KnowledgeConfig {
6464    fn default() -> Self {
6465        Self {
6466            enabled: false,
6467            db_path: default_knowledge_db_path(),
6468            max_nodes: default_knowledge_max_nodes(),
6469            auto_capture: false,
6470            suggest_on_query: true,
6471        }
6472    }
6473}
6474
6475// ── LinkedIn ────────────────────────────────────────────────────
6476
6477/// LinkedIn integration configuration (`[linkedin]` section).
6478///
6479/// When enabled, the `linkedin` tool is registered in the agent tool surface.
6480/// Requires `LINKEDIN_*` credentials in the workspace `.env` file.
6481#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6482#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6483#[prefix = "linkedin"]
6484pub struct LinkedInConfig {
6485    /// Enable the LinkedIn tool.
6486    #[serde(default)]
6487    pub enabled: bool,
6488
6489    /// LinkedIn REST API version header (YYYYMM format).
6490    #[serde(default = "default_linkedin_api_version")]
6491    pub api_version: String,
6492
6493    /// Content strategy for automated posting.
6494    #[serde(default)]
6495    #[nested]
6496    pub content: LinkedInContentConfig,
6497
6498    /// Image generation for posts (`[linkedin.image]`).
6499    #[serde(default)]
6500    #[nested]
6501    pub image: LinkedInImageConfig,
6502}
6503
6504impl Default for LinkedInConfig {
6505    fn default() -> Self {
6506        Self {
6507            enabled: false,
6508            api_version: default_linkedin_api_version(),
6509            content: LinkedInContentConfig::default(),
6510            image: LinkedInImageConfig::default(),
6511        }
6512    }
6513}
6514
6515fn default_linkedin_api_version() -> String {
6516    "202602".to_string()
6517}
6518
6519/// Plugin system configuration.
6520#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6521#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6522#[prefix = "plugins"]
6523pub struct PluginsConfig {
6524    /// Enable the plugin system (default: false)
6525    #[serde(default)]
6526    pub enabled: bool,
6527    /// Directory where plugins are stored
6528    #[serde(default = "default_plugins_dir")]
6529    pub plugins_dir: String,
6530    /// Auto-discover and load plugins on startup
6531    #[serde(default)]
6532    pub auto_discover: bool,
6533    /// Maximum number of plugins that can be loaded
6534    #[serde(default = "default_max_plugins")]
6535    pub max_plugins: usize,
6536    /// Plugin signature verification security settings
6537    #[serde(default)]
6538    #[nested]
6539    pub security: PluginSecurityConfig,
6540}
6541
6542/// Plugin signature verification configuration (`[plugins.security]`).
6543///
6544/// Controls Ed25519 signature verification for plugin manifests.
6545/// In `strict` mode, only plugins signed by a trusted publisher key are loaded.
6546/// In `permissive` mode, unsigned or untrusted plugins produce warnings but are
6547/// still loaded. In `disabled` mode (the default), no signature checking occurs.
6548#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6549#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6550#[prefix = "plugins.security"]
6551pub struct PluginSecurityConfig {
6552    /// Signature enforcement mode: "disabled", "permissive", or "strict".
6553    #[serde(default = "default_signature_mode")]
6554    pub signature_mode: String,
6555    /// Hex-encoded Ed25519 public keys of trusted plugin publishers.
6556    #[serde(default)]
6557    pub trusted_publisher_keys: Vec<String>,
6558}
6559
6560fn default_signature_mode() -> String {
6561    "disabled".to_string()
6562}
6563
6564impl Default for PluginSecurityConfig {
6565    fn default() -> Self {
6566        Self {
6567            signature_mode: default_signature_mode(),
6568            trusted_publisher_keys: Vec::new(),
6569        }
6570    }
6571}
6572
6573fn default_plugins_dir() -> String {
6574    default_path_under_config_dir("plugins")
6575}
6576
6577fn default_max_plugins() -> usize {
6578    50
6579}
6580
6581impl Default for PluginsConfig {
6582    fn default() -> Self {
6583        Self {
6584            enabled: false,
6585            plugins_dir: default_plugins_dir(),
6586            auto_discover: false,
6587            max_plugins: default_max_plugins(),
6588            security: PluginSecurityConfig::default(),
6589        }
6590    }
6591}
6592
6593/// Content strategy configuration for LinkedIn auto-posting (`[linkedin.content]`).
6594///
6595/// The agent reads this via the `linkedin get_content_strategy` action to know
6596/// what feeds to check, which repos to highlight, and how to write posts.
6597#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
6598#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6599#[prefix = "linkedin.content"]
6600pub struct LinkedInContentConfig {
6601    /// RSS feed URLs to monitor for topic inspiration (titles only).
6602    #[serde(default)]
6603    pub rss_feeds: Vec<String>,
6604
6605    /// GitHub usernames whose public activity to reference.
6606    #[serde(default)]
6607    pub github_users: Vec<String>,
6608
6609    /// GitHub repositories to highlight (format: `owner/repo`).
6610    #[serde(default)]
6611    pub github_repos: Vec<String>,
6612
6613    /// Topics of expertise and interest for post themes.
6614    #[serde(default)]
6615    pub topics: Vec<String>,
6616
6617    /// Professional persona description (name, role, expertise).
6618    #[serde(default)]
6619    pub persona: String,
6620
6621    /// Freeform posting instructions for the AI agent.
6622    #[serde(default)]
6623    pub instructions: String,
6624}
6625
6626/// Image generation configuration for LinkedIn posts (`[linkedin.image]`).
6627#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6628#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6629#[prefix = "linkedin.image"]
6630pub struct LinkedInImageConfig {
6631    /// Enable image generation for posts.
6632    #[serde(default)]
6633    pub enabled: bool,
6634
6635    /// ModelProvider priority order. Tried in sequence; first success wins.
6636    #[serde(default = "default_image_providers")]
6637    pub providers: Vec<String>,
6638
6639    /// Generate a branded SVG text card when all AI model_providers fail.
6640    #[serde(default = "default_true")]
6641    pub fallback_card: bool,
6642
6643    /// Accent color for the fallback card (CSS hex).
6644    #[serde(default = "default_card_accent_color")]
6645    pub card_accent_color: String,
6646
6647    /// Temp directory for generated images, relative to workspace.
6648    #[serde(default = "default_image_temp_dir")]
6649    pub temp_dir: String,
6650
6651    /// Stability AI model_provider settings.
6652    #[serde(default)]
6653    #[nested]
6654    pub stability: ImageProviderStabilityConfig,
6655
6656    /// Google Imagen (Vertex AI) model_provider settings.
6657    #[serde(default)]
6658    #[nested]
6659    pub imagen: ImageProviderImagenConfig,
6660
6661    /// OpenAI DALL-E model_provider settings.
6662    #[serde(default)]
6663    #[nested]
6664    pub dalle: ImageProviderDalleConfig,
6665
6666    /// Flux (fal.ai) model_provider settings.
6667    #[serde(default)]
6668    #[nested]
6669    pub flux: ImageProviderFluxConfig,
6670}
6671
6672fn default_image_providers() -> Vec<String> {
6673    vec![
6674        "stability".into(),
6675        "imagen".into(),
6676        "dalle".into(),
6677        "flux".into(),
6678    ]
6679}
6680
6681fn default_card_accent_color() -> String {
6682    "#0A66C2".into()
6683}
6684
6685fn default_image_temp_dir() -> String {
6686    "linkedin/images".into()
6687}
6688
6689impl Default for LinkedInImageConfig {
6690    fn default() -> Self {
6691        Self {
6692            enabled: false,
6693            providers: default_image_providers(),
6694            fallback_card: true,
6695            card_accent_color: default_card_accent_color(),
6696            temp_dir: default_image_temp_dir(),
6697            stability: ImageProviderStabilityConfig::default(),
6698            imagen: ImageProviderImagenConfig::default(),
6699            dalle: ImageProviderDalleConfig::default(),
6700            flux: ImageProviderFluxConfig::default(),
6701        }
6702    }
6703}
6704
6705/// Stability AI image generation settings (`[linkedin.image.stability]`).
6706#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6707#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6708#[prefix = "linkedin.image.stability"]
6709pub struct ImageProviderStabilityConfig {
6710    /// Environment variable name holding the API key.
6711    #[serde(default = "default_stability_api_key_env")]
6712    pub api_key_env: String,
6713    /// Stability model identifier.
6714    #[serde(default = "default_stability_model")]
6715    pub model: String,
6716}
6717
6718fn default_stability_api_key_env() -> String {
6719    "STABILITY_API_KEY".into()
6720}
6721fn default_stability_model() -> String {
6722    "stable-diffusion-xl-1024-v1-0".into()
6723}
6724
6725impl Default for ImageProviderStabilityConfig {
6726    fn default() -> Self {
6727        Self {
6728            api_key_env: default_stability_api_key_env(),
6729            model: default_stability_model(),
6730        }
6731    }
6732}
6733
6734/// Google Imagen (Vertex AI) settings (`[linkedin.image.imagen]`).
6735#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6736#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6737#[prefix = "linkedin.image.imagen"]
6738pub struct ImageProviderImagenConfig {
6739    /// Environment variable name holding the API key.
6740    #[serde(default = "default_imagen_api_key_env")]
6741    pub api_key_env: String,
6742    /// Environment variable for the Google Cloud project ID.
6743    #[serde(default = "default_imagen_project_id_env")]
6744    pub project_id_env: String,
6745    /// Vertex AI region.
6746    #[serde(default = "default_imagen_region")]
6747    pub region: String,
6748}
6749
6750fn default_imagen_api_key_env() -> String {
6751    "GOOGLE_VERTEX_API_KEY".into()
6752}
6753fn default_imagen_project_id_env() -> String {
6754    "GOOGLE_CLOUD_PROJECT".into()
6755}
6756fn default_imagen_region() -> String {
6757    "us-central1".into()
6758}
6759
6760impl Default for ImageProviderImagenConfig {
6761    fn default() -> Self {
6762        Self {
6763            api_key_env: default_imagen_api_key_env(),
6764            project_id_env: default_imagen_project_id_env(),
6765            region: default_imagen_region(),
6766        }
6767    }
6768}
6769
6770/// OpenAI DALL-E settings (`[linkedin.image.dalle]`).
6771#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6772#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6773#[prefix = "linkedin.image.dalle"]
6774pub struct ImageProviderDalleConfig {
6775    /// Environment variable name holding the OpenAI API key.
6776    #[serde(default = "default_dalle_api_key_env")]
6777    pub api_key_env: String,
6778    /// DALL-E model identifier.
6779    #[serde(default = "default_dalle_model")]
6780    pub model: String,
6781    /// Image dimensions.
6782    #[serde(default = "default_dalle_size")]
6783    pub size: String,
6784}
6785
6786fn default_dalle_api_key_env() -> String {
6787    "OPENAI_API_KEY".into()
6788}
6789fn default_dalle_model() -> String {
6790    "dall-e-3".into()
6791}
6792fn default_dalle_size() -> String {
6793    "1024x1024".into()
6794}
6795
6796impl Default for ImageProviderDalleConfig {
6797    fn default() -> Self {
6798        Self {
6799            api_key_env: default_dalle_api_key_env(),
6800            model: default_dalle_model(),
6801            size: default_dalle_size(),
6802        }
6803    }
6804}
6805
6806/// Flux (fal.ai) image generation settings (`[linkedin.image.flux]`).
6807#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6808#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6809#[prefix = "linkedin.image.flux"]
6810pub struct ImageProviderFluxConfig {
6811    /// Environment variable name holding the fal.ai API key.
6812    #[serde(default = "default_flux_api_key_env")]
6813    pub api_key_env: String,
6814    /// Flux model identifier.
6815    #[serde(default = "default_flux_model")]
6816    pub model: String,
6817}
6818
6819fn default_flux_api_key_env() -> String {
6820    "FAL_API_KEY".into()
6821}
6822fn default_flux_model() -> String {
6823    "fal-ai/flux/schnell".into()
6824}
6825
6826impl Default for ImageProviderFluxConfig {
6827    fn default() -> Self {
6828        Self {
6829            api_key_env: default_flux_api_key_env(),
6830            model: default_flux_model(),
6831        }
6832    }
6833}
6834
6835// ── Standalone Image Generation ─────────────────────────────────
6836
6837/// Standalone image generation tool configuration (`[image_gen]`).
6838///
6839/// When enabled, registers an `image_gen` tool that generates images via
6840/// fal.ai's synchronous API (Flux / Nano Banana models) and saves them
6841/// to the workspace `images/` directory.
6842#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6843#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6844#[prefix = "image-gen"]
6845pub struct ImageGenConfig {
6846    /// Enable the standalone image generation tool. Default: false.
6847    #[serde(default)]
6848    pub enabled: bool,
6849
6850    /// Default fal.ai model identifier.
6851    #[serde(default = "default_image_gen_model")]
6852    pub default_model: String,
6853
6854    /// Environment variable name holding the fal.ai API key.
6855    #[serde(default = "default_image_gen_api_key_env")]
6856    pub api_key_env: String,
6857}
6858
6859fn default_image_gen_model() -> String {
6860    "fal-ai/flux/schnell".into()
6861}
6862
6863fn default_image_gen_api_key_env() -> String {
6864    "FAL_API_KEY".into()
6865}
6866
6867impl Default for ImageGenConfig {
6868    fn default() -> Self {
6869        Self {
6870            enabled: false,
6871            default_model: default_image_gen_model(),
6872            api_key_env: default_image_gen_api_key_env(),
6873        }
6874    }
6875}
6876
6877// ── File Upload ─────────────────────────────────────────────────
6878
6879/// Standalone file upload tool configuration (`[file_upload]`).
6880///
6881/// When `url` is set to a non-empty value, registers a `file_upload` tool that
6882/// POSTs files from the agent's local filesystem to the configured endpoint
6883/// using `multipart/form-data`. The LLM provides only a file path; the host
6884/// reads the bytes and uploads them without ever including file content in
6885/// the model context.
6886///
6887/// When `url` is `None` or empty, the tool is not registered.
6888#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6889#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6890#[prefix = "file-upload"]
6891pub struct FileUploadConfig {
6892    /// Upload endpoint URL. Tool is disabled when this is `None` or empty.
6893    #[serde(default)]
6894    pub url: Option<String>,
6895
6896    /// HTTP method. Only `POST` (default) and `PUT` are accepted.
6897    #[serde(default = "default_file_upload_method")]
6898    pub method: String,
6899
6900    /// Multipart form-field name for the file part. Default: `file`.
6901    #[serde(default = "default_file_upload_field_name")]
6902    pub field_name: String,
6903
6904    /// Maximum file size in bytes. Larger files are rejected before any
6905    /// bytes hit the network. Default: 25 MiB.
6906    #[serde(default = "default_file_upload_max_size_bytes")]
6907    pub max_file_size_bytes: u64,
6908
6909    /// Request timeout in seconds. Default: 60.
6910    #[serde(default = "default_file_upload_timeout_secs")]
6911    pub timeout_secs: u64,
6912
6913    /// Static HTTP headers attached to every upload request. Same shape as
6914    /// `[mcp.servers.*.headers]`.
6915    #[serde(default)]
6916    pub headers: HashMap<String, String>,
6917}
6918
6919fn default_file_upload_method() -> String {
6920    "POST".into()
6921}
6922
6923fn default_file_upload_field_name() -> String {
6924    "file".into()
6925}
6926
6927fn default_file_upload_max_size_bytes() -> u64 {
6928    25 * 1024 * 1024
6929}
6930
6931fn default_file_upload_timeout_secs() -> u64 {
6932    60
6933}
6934
6935impl Default for FileUploadConfig {
6936    fn default() -> Self {
6937        Self {
6938            url: None,
6939            method: default_file_upload_method(),
6940            field_name: default_file_upload_field_name(),
6941            max_file_size_bytes: default_file_upload_max_size_bytes(),
6942            timeout_secs: default_file_upload_timeout_secs(),
6943            headers: HashMap::new(),
6944        }
6945    }
6946}
6947
6948// ── File Upload Bundle ──────────────────────────────────────────
6949
6950/// Standalone multi-file bundle upload tool configuration
6951/// (`[file_upload_bundle]`).
6952///
6953/// When `url` is set to a non-empty value, registers a `file_upload_bundle`
6954/// tool that POSTs N files from the agent's local filesystem to the
6955/// configured endpoint as a single `multipart/form-data` request. The LLM
6956/// provides only file paths; the host reads the bytes.
6957///
6958/// When `url` is `None` or empty, the tool is not registered.
6959#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6960#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6961#[prefix = "file-upload-bundle"]
6962pub struct FileUploadBundleConfig {
6963    /// Upload endpoint URL. Tool is disabled when this is `None` or empty.
6964    #[serde(default)]
6965    pub url: Option<String>,
6966
6967    /// HTTP method. Only `POST` (default) and `PUT` are accepted.
6968    #[serde(default = "default_file_upload_bundle_method")]
6969    pub method: String,
6970
6971    /// Multipart form-field name reused across every file part. Default: `file`.
6972    #[serde(default = "default_file_upload_bundle_field_name")]
6973    pub field_name: String,
6974
6975    /// Maximum per-file size in bytes. Default: 10 MiB.
6976    #[serde(default = "default_file_upload_bundle_max_file_size_bytes")]
6977    pub max_file_size_bytes: u64,
6978
6979    /// Maximum cumulative size across every file in one call. Default: 32 MiB.
6980    #[serde(default = "default_file_upload_bundle_max_total_size_bytes")]
6981    pub max_total_size_bytes: u64,
6982
6983    /// Maximum number of files per call. Default: 16.
6984    #[serde(default = "default_file_upload_bundle_max_files")]
6985    pub max_files: u32,
6986
6987    /// Request timeout in seconds. Default: 120.
6988    #[serde(default = "default_file_upload_bundle_timeout_secs")]
6989    pub timeout_secs: u64,
6990
6991    /// Maximum response body bytes to read from the upload endpoint.
6992    /// Prevents unbounded memory use from a malicious or verbose receiver.
6993    /// Default: 4096 (4 KiB).
6994    #[serde(default = "default_file_upload_bundle_max_response_body_bytes")]
6995    pub max_response_body_bytes: usize,
6996
6997    /// Static HTTP headers attached to every upload request.
6998    #[serde(default)]
6999    pub headers: HashMap<String, String>,
7000}
7001
7002fn default_file_upload_bundle_method() -> String {
7003    "POST".into()
7004}
7005
7006fn default_file_upload_bundle_field_name() -> String {
7007    "file".into()
7008}
7009
7010fn default_file_upload_bundle_max_file_size_bytes() -> u64 {
7011    10 * 1024 * 1024
7012}
7013
7014fn default_file_upload_bundle_max_total_size_bytes() -> u64 {
7015    32 * 1024 * 1024
7016}
7017
7018fn default_file_upload_bundle_max_files() -> u32 {
7019    16
7020}
7021
7022fn default_file_upload_bundle_timeout_secs() -> u64 {
7023    120
7024}
7025
7026fn default_file_upload_bundle_max_response_body_bytes() -> usize {
7027    4 * 1024
7028}
7029
7030impl Default for FileUploadBundleConfig {
7031    fn default() -> Self {
7032        Self {
7033            url: None,
7034            method: default_file_upload_bundle_method(),
7035            field_name: default_file_upload_bundle_field_name(),
7036            max_file_size_bytes: default_file_upload_bundle_max_file_size_bytes(),
7037            max_total_size_bytes: default_file_upload_bundle_max_total_size_bytes(),
7038            max_files: default_file_upload_bundle_max_files(),
7039            timeout_secs: default_file_upload_bundle_timeout_secs(),
7040            max_response_body_bytes: default_file_upload_bundle_max_response_body_bytes(),
7041            headers: HashMap::new(),
7042        }
7043    }
7044}
7045
7046// ── File Download ───────────────────────────────────────────────
7047
7048/// Standalone file download tool configuration (`[file_download]`).
7049///
7050/// When `url` is set to a non-empty value, registers a `file_download` tool
7051/// that GETs a file from the configured endpoint and writes it to the agent's
7052/// workspace filesystem. The LLM supplies only a document identifier and a
7053/// workspace-relative destination path; the endpoint URL comes solely from this
7054/// config and is never model-controlled. Response bytes are streamed to disk
7055/// and never loaded into model context.
7056///
7057/// When `url` is `None` or empty, the tool is not registered.
7058#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7059#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7060#[prefix = "file-download"]
7061pub struct FileDownloadConfig {
7062    /// Download endpoint URL. Tool is disabled when this is `None` or empty.
7063    /// The file to fetch is selected by the `document_id` query parameter.
7064    #[serde(default)]
7065    pub url: Option<String>,
7066
7067    /// Maximum download size in bytes. Enforced while streaming: the transfer
7068    /// is aborted and the partial file removed once this ceiling is exceeded,
7069    /// so an oversized or unbounded body never fully buffers in memory or lands
7070    /// on disk. Default: 25 MiB.
7071    #[serde(default = "default_file_download_max_size_bytes")]
7072    pub max_file_size_bytes: u64,
7073
7074    /// Request timeout in seconds. Default: 120.
7075    #[serde(default = "default_file_download_timeout_secs")]
7076    pub timeout_secs: u64,
7077
7078    /// Static HTTP headers attached to every download request — typically an
7079    /// `Authorization: Bearer …` token for the upstream endpoint. Same shape as
7080    /// `[mcp.servers.*.headers]`.
7081    #[serde(default)]
7082    pub headers: HashMap<String, String>,
7083}
7084
7085fn default_file_download_max_size_bytes() -> u64 {
7086    25 * 1024 * 1024
7087}
7088
7089fn default_file_download_timeout_secs() -> u64 {
7090    120
7091}
7092
7093impl Default for FileDownloadConfig {
7094    fn default() -> Self {
7095        Self {
7096            url: None,
7097            max_file_size_bytes: default_file_download_max_size_bytes(),
7098            timeout_secs: default_file_download_timeout_secs(),
7099            headers: HashMap::new(),
7100        }
7101    }
7102}
7103
7104// ── Claude Code ─────────────────────────────────────────────────
7105
7106/// Claude Code CLI tool configuration (`[claude_code]` section).
7107///
7108/// Delegates coding tasks to the `claude -p` CLI. Authentication uses the
7109/// binary's own OAuth session (Max subscription) by default — no API key
7110/// needed unless `env_passthrough` includes `ANTHROPIC_API_KEY`.
7111#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7112#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7113#[prefix = "claude-code"]
7114pub struct ClaudeCodeConfig {
7115    /// Enable the `claude_code` tool
7116    #[serde(default)]
7117    pub enabled: bool,
7118    /// Maximum execution time in seconds (coding tasks can be long)
7119    #[serde(default = "default_claude_code_timeout_secs")]
7120    pub timeout_secs: u64,
7121    /// Claude Code tools the subprocess is allowed to use
7122    #[serde(default = "default_claude_code_allowed_tools")]
7123    pub allowed_tools: Vec<String>,
7124    /// Optional system prompt appended to Claude Code invocations
7125    #[serde(default)]
7126    pub system_prompt: Option<String>,
7127    /// Maximum output size in bytes (2MB default)
7128    #[serde(default = "default_claude_code_max_output_bytes")]
7129    pub max_output_bytes: usize,
7130    /// Extra env vars passed to the claude subprocess (e.g. ANTHROPIC_API_KEY for API-key billing)
7131    #[serde(default)]
7132    pub env_passthrough: Vec<String>,
7133}
7134
7135fn default_claude_code_timeout_secs() -> u64 {
7136    600
7137}
7138
7139fn default_claude_code_allowed_tools() -> Vec<String> {
7140    vec!["Read".into(), "Edit".into(), "Bash".into(), "Write".into()]
7141}
7142
7143fn default_claude_code_max_output_bytes() -> usize {
7144    2_097_152
7145}
7146
7147impl Default for ClaudeCodeConfig {
7148    fn default() -> Self {
7149        Self {
7150            enabled: false,
7151            timeout_secs: default_claude_code_timeout_secs(),
7152            allowed_tools: default_claude_code_allowed_tools(),
7153            system_prompt: None,
7154            max_output_bytes: default_claude_code_max_output_bytes(),
7155            env_passthrough: Vec::new(),
7156        }
7157    }
7158}
7159
7160// ── Claude Code Runner ──────────────────────────────────────────
7161
7162/// Claude Code task runner configuration (`[claude_code_runner]` section).
7163///
7164/// Spawns Claude Code in a tmux session with HTTP hooks that POST tool
7165/// execution events back to ZeroClaw's gateway, updating a Slack message
7166/// in-place with progress plus an SSH handoff link.
7167#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7168#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7169#[prefix = "claude-code-runner"]
7170pub struct ClaudeCodeRunnerConfig {
7171    /// Enable the `claude_code_runner` tool
7172    #[serde(default)]
7173    pub enabled: bool,
7174    /// SSH host for session handoff links (e.g. "myhost.example.com")
7175    #[serde(default)]
7176    pub ssh_host: Option<String>,
7177    /// Prefix for tmux session names (default: "zc-claude-")
7178    #[serde(default = "default_claude_code_runner_tmux_prefix")]
7179    pub tmux_prefix: String,
7180    /// Session time-to-live in seconds before auto-cleanup (default: 3600)
7181    #[serde(default = "default_claude_code_runner_session_ttl")]
7182    pub session_ttl: u64,
7183}
7184
7185fn default_claude_code_runner_tmux_prefix() -> String {
7186    "zc-claude-".into()
7187}
7188
7189fn default_claude_code_runner_session_ttl() -> u64 {
7190    3600
7191}
7192
7193impl Default for ClaudeCodeRunnerConfig {
7194    fn default() -> Self {
7195        Self {
7196            enabled: false,
7197            ssh_host: None,
7198            tmux_prefix: default_claude_code_runner_tmux_prefix(),
7199            session_ttl: default_claude_code_runner_session_ttl(),
7200        }
7201    }
7202}
7203
7204// ── Codex CLI ───────────────────────────────────────────────────
7205
7206/// Codex CLI tool configuration (`[codex_cli]` section).
7207///
7208/// Delegates coding tasks to the `codex -q` CLI. Authentication uses the
7209/// binary's own session by default — no API key needed unless
7210/// `env_passthrough` includes `OPENAI_API_KEY`.
7211#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7212#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7213#[prefix = "codex-cli"]
7214pub struct CodexCliConfig {
7215    /// Enable the `codex_cli` tool
7216    #[serde(default)]
7217    pub enabled: bool,
7218    /// Maximum execution time in seconds (coding tasks can be long)
7219    #[serde(default = "default_codex_cli_timeout_secs")]
7220    pub timeout_secs: u64,
7221    /// Maximum output size in bytes (2MB default)
7222    #[serde(default = "default_codex_cli_max_output_bytes")]
7223    pub max_output_bytes: usize,
7224    /// Extra env vars passed to the codex subprocess (e.g. OPENAI_API_KEY)
7225    #[serde(default)]
7226    pub env_passthrough: Vec<String>,
7227}
7228
7229fn default_codex_cli_timeout_secs() -> u64 {
7230    600
7231}
7232
7233fn default_codex_cli_max_output_bytes() -> usize {
7234    2_097_152
7235}
7236
7237impl Default for CodexCliConfig {
7238    fn default() -> Self {
7239        Self {
7240            enabled: false,
7241            timeout_secs: default_codex_cli_timeout_secs(),
7242            max_output_bytes: default_codex_cli_max_output_bytes(),
7243            env_passthrough: Vec::new(),
7244        }
7245    }
7246}
7247
7248// ── Gemini CLI ──────────────────────────────────────────────────
7249
7250/// Gemini CLI tool configuration (`[gemini_cli]` section).
7251///
7252/// Delegates coding tasks to the `gemini -p` CLI. Authentication uses the
7253/// binary's own session by default — no API key needed unless
7254/// `env_passthrough` includes `GOOGLE_API_KEY`.
7255#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7256#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7257#[prefix = "gemini-cli"]
7258pub struct GeminiCliConfig {
7259    /// Enable the `gemini_cli` tool
7260    #[serde(default)]
7261    pub enabled: bool,
7262    /// Maximum execution time in seconds (coding tasks can be long)
7263    #[serde(default = "default_gemini_cli_timeout_secs")]
7264    pub timeout_secs: u64,
7265    /// Maximum output size in bytes (2MB default)
7266    #[serde(default = "default_gemini_cli_max_output_bytes")]
7267    pub max_output_bytes: usize,
7268    /// Extra env vars passed to the gemini subprocess (e.g. GOOGLE_API_KEY)
7269    #[serde(default)]
7270    pub env_passthrough: Vec<String>,
7271}
7272
7273fn default_gemini_cli_timeout_secs() -> u64 {
7274    600
7275}
7276
7277fn default_gemini_cli_max_output_bytes() -> usize {
7278    2_097_152
7279}
7280
7281impl Default for GeminiCliConfig {
7282    fn default() -> Self {
7283        Self {
7284            enabled: false,
7285            timeout_secs: default_gemini_cli_timeout_secs(),
7286            max_output_bytes: default_gemini_cli_max_output_bytes(),
7287            env_passthrough: Vec::new(),
7288        }
7289    }
7290}
7291
7292// ── OpenCode CLI ───────────────────────────────────────────────
7293
7294/// OpenCode CLI tool configuration (`[opencode_cli]` section).
7295///
7296/// Delegates coding tasks to the `opencode run` CLI. Authentication uses the
7297/// binary's own session by default — no API key needed unless
7298/// `env_passthrough` includes provider-specific keys.
7299#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7300#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7301#[prefix = "opencode-cli"]
7302pub struct OpenCodeCliConfig {
7303    /// Enable the `opencode_cli` tool
7304    #[serde(default)]
7305    pub enabled: bool,
7306    /// Maximum execution time in seconds (coding tasks can be long)
7307    #[serde(default = "default_opencode_cli_timeout_secs")]
7308    pub timeout_secs: u64,
7309    /// Maximum output size in bytes (2MB default)
7310    #[serde(default = "default_opencode_cli_max_output_bytes")]
7311    pub max_output_bytes: usize,
7312    /// Extra env vars passed to the opencode subprocess
7313    #[serde(default)]
7314    pub env_passthrough: Vec<String>,
7315}
7316
7317fn default_opencode_cli_timeout_secs() -> u64 {
7318    600
7319}
7320
7321fn default_opencode_cli_max_output_bytes() -> usize {
7322    2_097_152
7323}
7324
7325impl Default for OpenCodeCliConfig {
7326    fn default() -> Self {
7327        Self {
7328            enabled: false,
7329            timeout_secs: default_opencode_cli_timeout_secs(),
7330            max_output_bytes: default_opencode_cli_max_output_bytes(),
7331            env_passthrough: Vec::new(),
7332        }
7333    }
7334}
7335
7336// ── Proxy ───────────────────────────────────────────────────────
7337
7338/// Proxy application scope — determines which outbound traffic uses the proxy.
7339#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
7340#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7341#[serde(rename_all = "snake_case")]
7342pub enum ProxyScope {
7343    /// Use system environment proxy variables only.
7344    Environment,
7345    /// Apply proxy to all ZeroClaw-managed HTTP traffic (default).
7346    #[default]
7347    Zeroclaw,
7348    /// Apply proxy only to explicitly listed service selectors.
7349    Services,
7350}
7351
7352/// Proxy configuration for outbound HTTP/HTTPS/SOCKS5 traffic (`[proxy]` section).
7353#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7354#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7355#[prefix = "proxy"]
7356pub struct ProxyConfig {
7357    /// Enable proxy support for selected scope.
7358    #[serde(default)]
7359    pub enabled: bool,
7360    /// Proxy URL for HTTP requests (supports http, https, socks5, socks5h).
7361    #[serde(default)]
7362    pub http_proxy: Option<String>,
7363    /// Proxy URL for HTTPS requests (supports http, https, socks5, socks5h).
7364    #[serde(default)]
7365    pub https_proxy: Option<String>,
7366    /// Fallback proxy URL for all schemes.
7367    #[serde(default)]
7368    pub all_proxy: Option<String>,
7369    /// No-proxy bypass list. Same format as NO_PROXY.
7370    #[serde(default)]
7371    pub no_proxy: Vec<String>,
7372    /// Proxy application scope.
7373    #[serde(default)]
7374    pub scope: ProxyScope,
7375    /// Service selectors used when scope = "services".
7376    #[serde(default)]
7377    pub services: Vec<String>,
7378}
7379
7380impl Default for ProxyConfig {
7381    fn default() -> Self {
7382        Self {
7383            enabled: false,
7384            http_proxy: None,
7385            https_proxy: None,
7386            all_proxy: None,
7387            no_proxy: Vec::new(),
7388            scope: ProxyScope::Zeroclaw,
7389            services: Vec::new(),
7390        }
7391    }
7392}
7393
7394impl ProxyConfig {
7395    pub fn supported_service_keys() -> &'static [&'static str] {
7396        SUPPORTED_PROXY_SERVICE_KEYS
7397    }
7398
7399    pub fn supported_service_selectors() -> &'static [&'static str] {
7400        SUPPORTED_PROXY_SERVICE_SELECTORS
7401    }
7402
7403    pub fn has_any_proxy_url(&self) -> bool {
7404        normalize_proxy_url_option(self.http_proxy.as_deref()).is_some()
7405            || normalize_proxy_url_option(self.https_proxy.as_deref()).is_some()
7406            || normalize_proxy_url_option(self.all_proxy.as_deref()).is_some()
7407    }
7408
7409    pub fn normalized_services(&self) -> Vec<String> {
7410        normalize_service_list(self.services.clone())
7411    }
7412
7413    pub fn normalized_no_proxy(&self) -> Vec<String> {
7414        normalize_no_proxy_list(self.no_proxy.clone())
7415    }
7416
7417    pub fn validate(&self) -> Result<()> {
7418        for (field, value) in [
7419            ("http_proxy", self.http_proxy.as_deref()),
7420            ("https_proxy", self.https_proxy.as_deref()),
7421            ("all_proxy", self.all_proxy.as_deref()),
7422        ] {
7423            if let Some(url) = normalize_proxy_url_option(value) {
7424                validate_proxy_url(field, &url)?;
7425            }
7426        }
7427
7428        for selector in self.normalized_services() {
7429            if !is_supported_proxy_service_selector(&selector) {
7430                anyhow::bail!(
7431                    "Unsupported proxy service selector '{selector}'. Use tool `proxy_config` action `list_services` for valid values"
7432                );
7433            }
7434        }
7435
7436        if self.enabled && !self.has_any_proxy_url() {
7437            anyhow::bail!(
7438                "Proxy is enabled but no proxy URL is configured. Set at least one of http_proxy, https_proxy, or all_proxy"
7439            );
7440        }
7441
7442        if self.enabled
7443            && self.scope == ProxyScope::Services
7444            && self.normalized_services().is_empty()
7445        {
7446            anyhow::bail!(
7447                "proxy.scope='services' requires a non-empty proxy.services list when proxy is enabled"
7448            );
7449        }
7450
7451        Ok(())
7452    }
7453
7454    pub fn should_apply_to_service(&self, service_key: &str) -> bool {
7455        if !self.enabled {
7456            return false;
7457        }
7458
7459        match self.scope {
7460            ProxyScope::Environment => false,
7461            ProxyScope::Zeroclaw => true,
7462            ProxyScope::Services => {
7463                let service_key = service_key.trim().to_ascii_lowercase();
7464                if service_key.is_empty() {
7465                    return false;
7466                }
7467
7468                self.normalized_services()
7469                    .iter()
7470                    .any(|selector| service_selector_matches(selector, &service_key))
7471            }
7472        }
7473    }
7474
7475    pub fn apply_to_reqwest_builder(
7476        &self,
7477        mut builder: reqwest::ClientBuilder,
7478        service_key: &str,
7479    ) -> reqwest::ClientBuilder {
7480        if !self.should_apply_to_service(service_key) {
7481            return builder;
7482        }
7483
7484        let no_proxy = self.no_proxy_value();
7485
7486        if let Some(url) = normalize_proxy_url_option(self.all_proxy.as_deref()) {
7487            match reqwest::Proxy::all(&url) {
7488                Ok(proxy) => {
7489                    builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
7490                }
7491                Err(error) => {
7492                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid all_proxy URL: ");
7493                }
7494            }
7495        }
7496
7497        if let Some(url) = normalize_proxy_url_option(self.http_proxy.as_deref()) {
7498            match reqwest::Proxy::http(&url) {
7499                Ok(proxy) => {
7500                    builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
7501                }
7502                Err(error) => {
7503                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid http_proxy URL: ");
7504                }
7505            }
7506        }
7507
7508        if let Some(url) = normalize_proxy_url_option(self.https_proxy.as_deref()) {
7509            match reqwest::Proxy::https(&url) {
7510                Ok(proxy) => {
7511                    builder = builder.proxy(apply_no_proxy(proxy, no_proxy));
7512                }
7513                Err(error) => {
7514                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid https_proxy URL: ");
7515                }
7516            }
7517        }
7518
7519        builder
7520    }
7521
7522    pub fn apply_to_process_env(&self) {
7523        set_proxy_env_pair("HTTP_PROXY", self.http_proxy.as_deref());
7524        set_proxy_env_pair("HTTPS_PROXY", self.https_proxy.as_deref());
7525        set_proxy_env_pair("ALL_PROXY", self.all_proxy.as_deref());
7526
7527        let no_proxy_joined = {
7528            let list = self.normalized_no_proxy();
7529            (!list.is_empty()).then(|| list.join(","))
7530        };
7531        set_proxy_env_pair("NO_PROXY", no_proxy_joined.as_deref());
7532    }
7533
7534    pub fn clear_process_env() {
7535        clear_proxy_env_pair("HTTP_PROXY");
7536        clear_proxy_env_pair("HTTPS_PROXY");
7537        clear_proxy_env_pair("ALL_PROXY");
7538        clear_proxy_env_pair("NO_PROXY");
7539    }
7540
7541    fn no_proxy_value(&self) -> Option<reqwest::NoProxy> {
7542        let joined = {
7543            let list = self.normalized_no_proxy();
7544            (!list.is_empty()).then(|| list.join(","))
7545        };
7546        joined.as_deref().and_then(reqwest::NoProxy::from_string)
7547    }
7548}
7549
7550fn apply_no_proxy(proxy: reqwest::Proxy, no_proxy: Option<reqwest::NoProxy>) -> reqwest::Proxy {
7551    proxy.no_proxy(no_proxy)
7552}
7553
7554fn normalize_proxy_url_option(raw: Option<&str>) -> Option<String> {
7555    let value = raw?.trim();
7556    (!value.is_empty()).then(|| value.to_string())
7557}
7558
7559fn normalize_no_proxy_list(values: Vec<String>) -> Vec<String> {
7560    normalize_comma_values(values)
7561}
7562
7563fn normalize_service_list(values: Vec<String>) -> Vec<String> {
7564    let mut normalized = normalize_comma_values(values)
7565        .into_iter()
7566        .map(|value| value.to_ascii_lowercase())
7567        .collect::<Vec<_>>();
7568    normalized.sort_unstable();
7569    normalized.dedup();
7570    normalized
7571}
7572
7573fn normalize_comma_values(values: Vec<String>) -> Vec<String> {
7574    let mut output = Vec::new();
7575    for value in values {
7576        for part in value.split(',') {
7577            let normalized = part.trim();
7578            if normalized.is_empty() {
7579                continue;
7580            }
7581            output.push(normalized.to_string());
7582        }
7583    }
7584    output.sort_unstable();
7585    output.dedup();
7586    output
7587}
7588
7589fn is_supported_proxy_service_selector(selector: &str) -> bool {
7590    if SUPPORTED_PROXY_SERVICE_KEYS
7591        .iter()
7592        .any(|known| known.eq_ignore_ascii_case(selector))
7593    {
7594        return true;
7595    }
7596
7597    SUPPORTED_PROXY_SERVICE_SELECTORS
7598        .iter()
7599        .any(|known| known.eq_ignore_ascii_case(selector))
7600}
7601
7602fn service_selector_matches(selector: &str, service_key: &str) -> bool {
7603    if selector == service_key {
7604        return true;
7605    }
7606
7607    if let Some(prefix) = selector.strip_suffix(".*") {
7608        return service_key.starts_with(prefix)
7609            && service_key
7610                .strip_prefix(prefix)
7611                .is_some_and(|suffix| suffix.starts_with('.'));
7612    }
7613
7614    false
7615}
7616
7617const MCP_MAX_TOOL_TIMEOUT_SECS: u64 = 600;
7618
7619fn validate_mcp_config(config: &McpConfig) -> Result<()> {
7620    let mut seen_names = std::collections::HashSet::new();
7621    for (i, server) in config.servers.iter().enumerate() {
7622        let name = server.name.trim();
7623        if name.is_empty() {
7624            validation_bail!(
7625                RequiredFieldEmpty,
7626                format!("mcp.servers[{i}].name"),
7627                "mcp.servers[{i}].name must not be empty"
7628            );
7629        }
7630        if !seen_names.insert(name.to_ascii_lowercase()) {
7631            anyhow::bail!("mcp.servers contains duplicate name: {name}");
7632        }
7633
7634        if let Some(timeout) = server.tool_timeout_secs {
7635            if timeout == 0 {
7636                validation_bail!(
7637                    InvalidNumericRange,
7638                    format!("mcp.servers[{i}].tool_timeout_secs"),
7639                    "mcp.servers[{i}].tool_timeout_secs must be greater than 0"
7640                );
7641            }
7642            if timeout > MCP_MAX_TOOL_TIMEOUT_SECS {
7643                anyhow::bail!(
7644                    "mcp.servers[{i}].tool_timeout_secs exceeds max {MCP_MAX_TOOL_TIMEOUT_SECS}"
7645                );
7646            }
7647        }
7648
7649        match server.transport {
7650            McpTransport::Stdio => {
7651                if server.command.trim().is_empty() {
7652                    anyhow::bail!(
7653                        "mcp.servers[{i}] with transport=stdio requires non-empty command"
7654                    );
7655                }
7656            }
7657            McpTransport::Http | McpTransport::Sse => {
7658                let url = server
7659                    .url
7660                    .as_deref()
7661                    .map(str::trim)
7662                    .filter(|value| !value.is_empty())
7663                    .ok_or_else(|| {
7664                        let transport_str = match server.transport {
7665                            McpTransport::Http => "http",
7666                            McpTransport::Sse => "sse",
7667                            McpTransport::Stdio => "stdio",
7668                        };
7669                        ::zeroclaw_log::record!(
7670                            WARN,
7671                            ::zeroclaw_log::Event::new(
7672                                module_path!(),
7673                                ::zeroclaw_log::Action::Reject
7674                            )
7675                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
7676                            .with_attrs(::serde_json::json!({
7677                                "index": i,
7678                                "transport": transport_str,
7679                            })),
7680                            "mcp.servers entry rejected: transport requires url"
7681                        );
7682                        anyhow::Error::msg(format!(
7683                            "mcp.servers[{i}] with transport={transport_str} requires url"
7684                        ))
7685                    })?;
7686                let parsed = reqwest::Url::parse(url)
7687                    .with_context(|| format!("mcp.servers[{i}].url is not a valid URL"))?;
7688                if !matches!(parsed.scheme(), "http" | "https") {
7689                    anyhow::bail!("mcp.servers[{i}].url must use http/https");
7690                }
7691            }
7692        }
7693    }
7694    Ok(())
7695}
7696
7697fn validate_proxy_url(field: &str, url: &str) -> Result<()> {
7698    let parsed = reqwest::Url::parse(url)
7699        .with_context(|| format!("Invalid {field} URL: '{url}' is not a valid URL"))?;
7700
7701    match parsed.scheme() {
7702        "http" | "https" | "socks5" | "socks5h" | "socks" => {}
7703        scheme => {
7704            anyhow::bail!(
7705                "Invalid {field} URL scheme '{scheme}'. Allowed: http, https, socks5, socks5h, socks"
7706            );
7707        }
7708    }
7709
7710    if parsed.host_str().is_none() {
7711        anyhow::bail!("Invalid {field} URL: host is required");
7712    }
7713
7714    Ok(())
7715}
7716
7717fn set_proxy_env_pair(key: &str, value: Option<&str>) {
7718    let lowercase_key = key.to_ascii_lowercase();
7719    if let Some(value) = value.and_then(|candidate| normalize_proxy_url_option(Some(candidate))) {
7720        // SAFETY: called during single-threaded config init before async runtime starts.
7721        unsafe {
7722            std::env::set_var(key, &value);
7723            std::env::set_var(lowercase_key, value);
7724        }
7725    } else {
7726        // SAFETY: called during single-threaded config init before async runtime starts.
7727        unsafe {
7728            std::env::remove_var(key);
7729            std::env::remove_var(lowercase_key);
7730        }
7731    }
7732}
7733
7734fn clear_proxy_env_pair(key: &str) {
7735    // SAFETY: called during single-threaded config init before async runtime starts.
7736    unsafe {
7737        std::env::remove_var(key);
7738        std::env::remove_var(key.to_ascii_lowercase());
7739    }
7740}
7741
7742fn runtime_proxy_state() -> &'static RwLock<ProxyConfig> {
7743    RUNTIME_PROXY_CONFIG.get_or_init(|| RwLock::new(ProxyConfig::default()))
7744}
7745
7746fn runtime_proxy_client_cache() -> &'static RwLock<HashMap<String, reqwest::Client>> {
7747    RUNTIME_PROXY_CLIENT_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
7748}
7749
7750fn clear_runtime_proxy_client_cache() {
7751    match runtime_proxy_client_cache().write() {
7752        Ok(mut guard) => {
7753            guard.clear();
7754        }
7755        Err(poisoned) => {
7756            poisoned.into_inner().clear();
7757        }
7758    }
7759}
7760
7761fn runtime_proxy_cache_key(
7762    service_key: &str,
7763    timeout_secs: Option<u64>,
7764    connect_timeout_secs: Option<u64>,
7765) -> String {
7766    format!(
7767        "{}|timeout={}|connect_timeout={}",
7768        service_key.trim().to_ascii_lowercase(),
7769        timeout_secs
7770            .map(|value| value.to_string())
7771            .unwrap_or_else(|| "none".to_string()),
7772        connect_timeout_secs
7773            .map(|value| value.to_string())
7774            .unwrap_or_else(|| "none".to_string())
7775    )
7776}
7777
7778fn runtime_proxy_cached_client(cache_key: &str) -> Option<reqwest::Client> {
7779    match runtime_proxy_client_cache().read() {
7780        Ok(guard) => guard.get(cache_key).cloned(),
7781        Err(poisoned) => poisoned.into_inner().get(cache_key).cloned(),
7782    }
7783}
7784
7785fn set_runtime_proxy_cached_client(cache_key: String, client: reqwest::Client) {
7786    match runtime_proxy_client_cache().write() {
7787        Ok(mut guard) => {
7788            guard.insert(cache_key, client);
7789        }
7790        Err(poisoned) => {
7791            poisoned.into_inner().insert(cache_key, client);
7792        }
7793    }
7794}
7795
7796pub fn set_runtime_proxy_config(config: ProxyConfig) {
7797    match runtime_proxy_state().write() {
7798        Ok(mut guard) => {
7799            *guard = config;
7800        }
7801        Err(poisoned) => {
7802            *poisoned.into_inner() = config;
7803        }
7804    }
7805
7806    clear_runtime_proxy_client_cache();
7807}
7808
7809pub fn runtime_proxy_config() -> ProxyConfig {
7810    match runtime_proxy_state().read() {
7811        Ok(guard) => guard.clone(),
7812        Err(poisoned) => poisoned.into_inner().clone(),
7813    }
7814}
7815
7816pub fn apply_runtime_proxy_to_builder(
7817    builder: reqwest::ClientBuilder,
7818    service_key: &str,
7819) -> reqwest::ClientBuilder {
7820    runtime_proxy_config().apply_to_reqwest_builder(builder, service_key)
7821}
7822
7823pub fn build_runtime_proxy_client(service_key: &str) -> reqwest::Client {
7824    let cache_key = runtime_proxy_cache_key(service_key, None, None);
7825    if let Some(client) = runtime_proxy_cached_client(&cache_key) {
7826        return client;
7827    }
7828
7829    let builder = apply_runtime_proxy_to_builder(reqwest::Client::builder(), service_key);
7830    let client = builder.build().unwrap_or_else(|error| {
7831        ::zeroclaw_log::record!(
7832            WARN,
7833            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7834                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
7835                .with_attrs(
7836                    ::serde_json::json!({"service_key": service_key, "error": format!("{}", error)})
7837                ),
7838            "Failed to build proxied client: "
7839        );
7840        reqwest::Client::new()
7841    });
7842    set_runtime_proxy_cached_client(cache_key, client.clone());
7843    client
7844}
7845
7846pub fn build_runtime_proxy_client_with_timeouts(
7847    service_key: &str,
7848    timeout_secs: u64,
7849    connect_timeout_secs: u64,
7850) -> reqwest::Client {
7851    let cache_key =
7852        runtime_proxy_cache_key(service_key, Some(timeout_secs), Some(connect_timeout_secs));
7853    if let Some(client) = runtime_proxy_cached_client(&cache_key) {
7854        return client;
7855    }
7856
7857    let builder = reqwest::Client::builder()
7858        .timeout(std::time::Duration::from_secs(timeout_secs))
7859        .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs));
7860    let builder = apply_runtime_proxy_to_builder(builder, service_key);
7861    let client = builder.build().unwrap_or_else(|error| {
7862        ::zeroclaw_log::record!(
7863            WARN,
7864            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
7865                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
7866                .with_attrs(
7867                    ::serde_json::json!({"service_key": service_key, "error": format!("{}", error)})
7868                ),
7869            "Failed to build proxied timeout client: "
7870        );
7871        reqwest::Client::new()
7872    });
7873    set_runtime_proxy_cached_client(cache_key, client.clone());
7874    client
7875}
7876
7877/// Build an HTTP client for a channel, using an explicit per-channel proxy URL
7878/// when configured.  Falls back to the global runtime proxy when `proxy_url` is
7879/// `None` or empty.
7880pub fn build_channel_proxy_client(service_key: &str, proxy_url: Option<&str>) -> reqwest::Client {
7881    match normalize_proxy_url_option(proxy_url) {
7882        Some(url) => build_explicit_proxy_client(service_key, &url, None, None),
7883        None => build_runtime_proxy_client(service_key),
7884    }
7885}
7886
7887/// Build an HTTP client for a channel with custom timeouts, using an explicit
7888/// per-channel proxy URL when configured.  Falls back to the global runtime
7889/// proxy when `proxy_url` is `None` or empty.
7890pub fn build_channel_proxy_client_with_timeouts(
7891    service_key: &str,
7892    proxy_url: Option<&str>,
7893    timeout_secs: u64,
7894    connect_timeout_secs: u64,
7895) -> reqwest::Client {
7896    match normalize_proxy_url_option(proxy_url) {
7897        Some(url) => build_explicit_proxy_client(
7898            service_key,
7899            &url,
7900            Some(timeout_secs),
7901            Some(connect_timeout_secs),
7902        ),
7903        None => build_runtime_proxy_client_with_timeouts(
7904            service_key,
7905            timeout_secs,
7906            connect_timeout_secs,
7907        ),
7908    }
7909}
7910
7911/// Apply an explicit proxy URL to a `reqwest::ClientBuilder`, returning the
7912/// modified builder.  Used by channels that specify a per-channel `proxy_url`.
7913pub fn apply_channel_proxy_to_builder(
7914    builder: reqwest::ClientBuilder,
7915    service_key: &str,
7916    proxy_url: Option<&str>,
7917) -> reqwest::ClientBuilder {
7918    match normalize_proxy_url_option(proxy_url) {
7919        Some(url) => apply_explicit_proxy_to_builder(builder, service_key, &url),
7920        None => apply_runtime_proxy_to_builder(builder, service_key),
7921    }
7922}
7923
7924/// Build a client with a single explicit proxy URL (http+https via `Proxy::all`).
7925fn build_explicit_proxy_client(
7926    service_key: &str,
7927    proxy_url: &str,
7928    timeout_secs: Option<u64>,
7929    connect_timeout_secs: Option<u64>,
7930) -> reqwest::Client {
7931    let cache_key = format!(
7932        "explicit|{}|{}|timeout={}|connect_timeout={}",
7933        service_key.trim().to_ascii_lowercase(),
7934        proxy_url,
7935        timeout_secs
7936            .map(|v| v.to_string())
7937            .unwrap_or_else(|| "none".to_string()),
7938        connect_timeout_secs
7939            .map(|v| v.to_string())
7940            .unwrap_or_else(|| "none".to_string()),
7941    );
7942    if let Some(client) = runtime_proxy_cached_client(&cache_key) {
7943        return client;
7944    }
7945
7946    let mut builder = reqwest::Client::builder();
7947    if let Some(t) = timeout_secs {
7948        builder = builder.timeout(std::time::Duration::from_secs(t));
7949    }
7950    if let Some(ct) = connect_timeout_secs {
7951        builder = builder.connect_timeout(std::time::Duration::from_secs(ct));
7952    }
7953    builder = apply_explicit_proxy_to_builder(builder, service_key, proxy_url);
7954    let client = builder.build().unwrap_or_else(|error| {
7955        ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"service_key": service_key, "proxy_url": proxy_url, "error": format!("{}", error)})), "Failed to build channel proxy client: ");
7956        reqwest::Client::new()
7957    });
7958    set_runtime_proxy_cached_client(cache_key, client.clone());
7959    client
7960}
7961
7962/// Apply a single explicit proxy URL to a builder via `Proxy::all`.
7963fn apply_explicit_proxy_to_builder(
7964    mut builder: reqwest::ClientBuilder,
7965    service_key: &str,
7966    proxy_url: &str,
7967) -> reqwest::ClientBuilder {
7968    match reqwest::Proxy::all(proxy_url) {
7969        Ok(proxy) => {
7970            builder = builder.proxy(proxy);
7971        }
7972        Err(error) => {
7973            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": proxy_url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid channel proxy_url: ");
7974        }
7975    }
7976    builder
7977}
7978
7979// ── Proxy-aware WebSocket connect ────────────────────────────────
7980//
7981// `tokio_tungstenite::connect_async` does not honour proxy settings.
7982// The helpers below resolve the effective proxy URL for a given service
7983// key and, when a proxy is active, establish a tunnelled TCP connection
7984// (HTTP CONNECT for http/https proxies, SOCKS5 for socks5/socks5h)
7985// before handing the stream to `tokio_tungstenite` for the WebSocket
7986// handshake.
7987
7988/// Combined async IO trait for boxed WebSocket transport streams.
7989trait AsyncReadWrite: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
7990impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send> AsyncReadWrite for T {}
7991
7992/// A boxed async IO stream used when a WebSocket connection is tunnelled
7993/// through a proxy.  The concrete type varies depending on the proxy
7994/// kind (HTTP CONNECT vs SOCKS5) and the target scheme (ws vs wss).
7995///
7996/// We wrap in a newtype so we can implement `AsyncRead` and `AsyncWrite`
7997/// via delegation, since Rust trait objects cannot combine multiple
7998/// non-auto traits.
7999pub struct BoxedIo(Box<dyn AsyncReadWrite>);
8000
8001impl tokio::io::AsyncRead for BoxedIo {
8002    fn poll_read(
8003        mut self: std::pin::Pin<&mut Self>,
8004        cx: &mut std::task::Context<'_>,
8005        buf: &mut tokio::io::ReadBuf<'_>,
8006    ) -> std::task::Poll<std::io::Result<()>> {
8007        std::pin::Pin::new(&mut *self.0).poll_read(cx, buf)
8008    }
8009}
8010
8011impl tokio::io::AsyncWrite for BoxedIo {
8012    fn poll_write(
8013        mut self: std::pin::Pin<&mut Self>,
8014        cx: &mut std::task::Context<'_>,
8015        buf: &[u8],
8016    ) -> std::task::Poll<std::io::Result<usize>> {
8017        std::pin::Pin::new(&mut *self.0).poll_write(cx, buf)
8018    }
8019
8020    fn poll_flush(
8021        mut self: std::pin::Pin<&mut Self>,
8022        cx: &mut std::task::Context<'_>,
8023    ) -> std::task::Poll<std::io::Result<()>> {
8024        std::pin::Pin::new(&mut *self.0).poll_flush(cx)
8025    }
8026
8027    fn poll_shutdown(
8028        mut self: std::pin::Pin<&mut Self>,
8029        cx: &mut std::task::Context<'_>,
8030    ) -> std::task::Poll<std::io::Result<()>> {
8031        std::pin::Pin::new(&mut *self.0).poll_shutdown(cx)
8032    }
8033}
8034
8035impl Unpin for BoxedIo {}
8036
8037/// Convenience alias for the WebSocket stream returned by the proxy-aware
8038/// connect helpers.
8039pub type ProxiedWsStream = tokio_tungstenite::WebSocketStream<BoxedIo>;
8040
8041/// Resolve the effective proxy URL for a WebSocket connection to the
8042/// given `ws_url`, taking into account the per-channel `proxy_url`
8043/// override, the runtime proxy config, scope and no_proxy list.
8044fn resolve_ws_proxy_url(
8045    service_key: &str,
8046    ws_url: &str,
8047    channel_proxy_url: Option<&str>,
8048) -> Option<String> {
8049    // 1. Explicit per-channel proxy always wins.
8050    if let Some(url) = normalize_proxy_url_option(channel_proxy_url) {
8051        return Some(url);
8052    }
8053
8054    // 2. Consult the runtime proxy config.
8055    let cfg = runtime_proxy_config();
8056    if !cfg.should_apply_to_service(service_key) {
8057        return None;
8058    }
8059
8060    // Check the no_proxy list against the WebSocket target host.
8061    if let Ok(parsed) = reqwest::Url::parse(ws_url)
8062        && let Some(host) = parsed.host_str()
8063    {
8064        let no_proxy_entries = cfg.normalized_no_proxy();
8065        if !no_proxy_entries.is_empty() {
8066            let host_lower = host.to_ascii_lowercase();
8067            let matches_no_proxy = no_proxy_entries.iter().any(|entry| {
8068                let entry = entry.trim().to_ascii_lowercase();
8069                if entry == "*" {
8070                    return true;
8071                }
8072                if host_lower == entry {
8073                    return true;
8074                }
8075                // Support ".example.com" matching "foo.example.com"
8076                if let Some(suffix) = entry.strip_prefix('.') {
8077                    return host_lower.ends_with(suffix) || host_lower == suffix;
8078                }
8079                // Support "example.com" also matching "foo.example.com"
8080                host_lower.ends_with(&format!(".{entry}"))
8081            });
8082            if matches_no_proxy {
8083                return None;
8084            }
8085        }
8086    }
8087
8088    // For wss:// prefer https_proxy, for ws:// prefer http_proxy, fall
8089    // back to all_proxy in both cases.
8090    let is_secure = ws_url.starts_with("wss://") || ws_url.starts_with("wss:");
8091    let preferred = if is_secure {
8092        normalize_proxy_url_option(cfg.https_proxy.as_deref())
8093    } else {
8094        normalize_proxy_url_option(cfg.http_proxy.as_deref())
8095    };
8096    preferred.or_else(|| normalize_proxy_url_option(cfg.all_proxy.as_deref()))
8097}
8098
8099/// Connect a WebSocket through the configured proxy (if any).
8100///
8101/// When no proxy applies, this is a thin wrapper around
8102/// `tokio_tungstenite::connect_async`.  When a proxy is active the
8103/// function tunnels the TCP connection through the proxy before
8104/// performing the WebSocket upgrade.
8105///
8106/// `service_key` is the proxy-service selector (e.g. `"channel.discord"`).
8107/// `channel_proxy_url` is the optional per-channel proxy override.
8108pub async fn ws_connect_with_proxy(
8109    ws_url: &str,
8110    service_key: &str,
8111    channel_proxy_url: Option<&str>,
8112) -> anyhow::Result<(
8113    ProxiedWsStream,
8114    tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
8115)> {
8116    let proxy_url = resolve_ws_proxy_url(service_key, ws_url, channel_proxy_url);
8117
8118    match proxy_url {
8119        None => {
8120            // No proxy — establish TCP+TLS manually, wrap in BoxedIo, then
8121            // perform the WebSocket handshake over the wrapped stream.
8122            //
8123            // Previous implementation used `connect_async` followed by
8124            // `into_inner()` + `from_raw_socket` to normalize the return
8125            // type.  That pattern discards data already buffered by the
8126            // tungstenite frame codec, causing channels (Slack Socket Mode,
8127            // Discord, etc.) to silently miss the first frames sent by the
8128            // server and all subsequent events.
8129            use tokio::net::TcpStream;
8130
8131            let target = reqwest::Url::parse(ws_url)
8132                .with_context(|| format!("Invalid WebSocket URL: {ws_url}"))?;
8133            let target_host = target
8134                .host_str()
8135                .ok_or_else(|| {
8136                    ::zeroclaw_log::record!(
8137                        WARN,
8138                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
8139                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
8140                            .with_attrs(::serde_json::json!({"ws_url": ws_url})),
8141                        "WebSocket URL has no host"
8142                    );
8143                    anyhow::Error::msg(format!("WebSocket URL has no host: {ws_url}"))
8144                })?
8145                .to_string();
8146            let target_port = target
8147                .port_or_known_default()
8148                .unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });
8149
8150            let tcp = TcpStream::connect(format!("{target_host}:{target_port}"))
8151                .await
8152                .with_context(|| format!("TCP connect to {target_host}:{target_port}"))?;
8153
8154            let is_secure = target.scheme() == "wss";
8155            let stream: BoxedIo = if is_secure {
8156                let mut root_store = rustls::RootCertStore::empty();
8157                root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
8158                let tls_config = std::sync::Arc::new(
8159                    rustls::ClientConfig::builder()
8160                        .with_root_certificates(root_store)
8161                        .with_no_client_auth(),
8162                );
8163                let connector = tokio_rustls::TlsConnector::from(tls_config);
8164                let server_name = rustls_pki_types::ServerName::try_from(target_host.clone())
8165                    .with_context(|| format!("Invalid TLS server name: {target_host}"))?;
8166                let tls_stream = connector
8167                    .connect(server_name, tcp)
8168                    .await
8169                    .with_context(|| format!("TLS handshake with {target_host}"))?;
8170                BoxedIo(Box::new(tls_stream))
8171            } else {
8172                BoxedIo(Box::new(tcp))
8173            };
8174
8175            let default_port = if is_secure { 443 } else { 80 };
8176            let host_header = if target_port == default_port {
8177                target_host.clone()
8178            } else {
8179                format!("{target_host}:{target_port}")
8180            };
8181
8182            let ws_request = tokio_tungstenite::tungstenite::http::Request::builder()
8183                .uri(ws_url)
8184                .header("Host", host_header)
8185                .header("Connection", "Upgrade")
8186                .header("Upgrade", "websocket")
8187                .header(
8188                    "Sec-WebSocket-Key",
8189                    tokio_tungstenite::tungstenite::handshake::client::generate_key(),
8190                )
8191                .header("Sec-WebSocket-Version", "13")
8192                .body(())
8193                .with_context(|| "Failed to build WebSocket upgrade request")?;
8194
8195            let (ws_stream, response) =
8196                tokio_tungstenite::client_async(ws_request, stream)
8197                    .await
8198                    .with_context(|| format!("WebSocket handshake failed for {ws_url}"))?;
8199
8200            Ok((ws_stream, response))
8201        }
8202        Some(proxy) => ws_connect_via_proxy(ws_url, &proxy).await,
8203    }
8204}
8205
8206/// Establish a WebSocket connection tunnelled through the given proxy URL.
8207async fn ws_connect_via_proxy(
8208    ws_url: &str,
8209    proxy_url: &str,
8210) -> anyhow::Result<(
8211    ProxiedWsStream,
8212    tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
8213)> {
8214    use tokio::io::{AsyncReadExt, AsyncWriteExt as _};
8215    use tokio::net::TcpStream;
8216
8217    let target =
8218        reqwest::Url::parse(ws_url).with_context(|| format!("Invalid WebSocket URL: {ws_url}"))?;
8219    let target_host = target
8220        .host_str()
8221        .ok_or_else(|| {
8222            ::zeroclaw_log::record!(
8223                WARN,
8224                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
8225                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
8226                    .with_attrs(::serde_json::json!({"ws_url": ws_url})),
8227                "WebSocket URL has no host"
8228            );
8229            anyhow::Error::msg(format!("WebSocket URL has no host: {ws_url}"))
8230        })?
8231        .to_string();
8232    let target_port = target
8233        .port_or_known_default()
8234        .unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });
8235
8236    let proxy = reqwest::Url::parse(proxy_url)
8237        .with_context(|| format!("Invalid proxy URL: {proxy_url}"))?;
8238
8239    let stream: BoxedIo = match proxy.scheme() {
8240        "socks5" | "socks5h" | "socks" => {
8241            let proxy_addr = format!(
8242                "{}:{}",
8243                proxy.host_str().unwrap_or("127.0.0.1"),
8244                proxy.port_or_known_default().unwrap_or(1080)
8245            );
8246            let target_addr = format!("{target_host}:{target_port}");
8247            let socks_stream = if proxy.username().is_empty() {
8248                tokio_socks::tcp::Socks5Stream::connect(proxy_addr.as_str(), target_addr.as_str())
8249                    .await
8250                    .with_context(|| format!("SOCKS5 connect to {target_addr} via {proxy_addr}"))?
8251            } else {
8252                let password = proxy.password().unwrap_or("");
8253                tokio_socks::tcp::Socks5Stream::connect_with_password(
8254                    proxy_addr.as_str(),
8255                    target_addr.as_str(),
8256                    proxy.username(),
8257                    password,
8258                )
8259                .await
8260                .with_context(|| format!("SOCKS5 auth connect to {target_addr} via {proxy_addr}"))?
8261            };
8262            let tcp: TcpStream = socks_stream.into_inner();
8263            BoxedIo(Box::new(tcp))
8264        }
8265        "http" | "https" => {
8266            let proxy_host = proxy.host_str().unwrap_or("127.0.0.1");
8267            let proxy_port = proxy.port_or_known_default().unwrap_or(8080);
8268            let proxy_addr = format!("{proxy_host}:{proxy_port}");
8269
8270            let mut tcp = TcpStream::connect(&proxy_addr)
8271                .await
8272                .with_context(|| format!("TCP connect to HTTP proxy {proxy_addr}"))?;
8273
8274            // Send HTTP CONNECT request.
8275            let connect_req = format!(
8276                "CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n\r\n"
8277            );
8278            tcp.write_all(connect_req.as_bytes()).await?;
8279
8280            // Read the response (we only need the status line).
8281            let mut buf = vec![0u8; 4096];
8282            let mut total = 0usize;
8283            loop {
8284                let n = tcp.read(&mut buf[total..]).await?;
8285                if n == 0 {
8286                    anyhow::bail!("HTTP CONNECT proxy closed connection before response");
8287                }
8288                total += n;
8289                // Look for end of HTTP headers.
8290                if let Some(pos) = find_header_end(&buf[..total]) {
8291                    let status_line = std::str::from_utf8(&buf[..pos])
8292                        .unwrap_or("")
8293                        .lines()
8294                        .next()
8295                        .unwrap_or("");
8296                    if !status_line.contains("200") {
8297                        anyhow::bail!(
8298                            "HTTP CONNECT proxy returned non-200 response: {status_line}"
8299                        );
8300                    }
8301                    break;
8302                }
8303                if total >= buf.len() {
8304                    anyhow::bail!("HTTP CONNECT proxy response too large");
8305                }
8306            }
8307
8308            BoxedIo(Box::new(tcp))
8309        }
8310        scheme => {
8311            anyhow::bail!("Unsupported proxy scheme '{scheme}' for WebSocket connections");
8312        }
8313    };
8314
8315    // If the target is wss://, wrap in TLS.
8316    let is_secure = target.scheme() == "wss";
8317    let stream: BoxedIo = if is_secure {
8318        let mut root_store = rustls::RootCertStore::empty();
8319        root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
8320        let tls_config = std::sync::Arc::new(
8321            rustls::ClientConfig::builder()
8322                .with_root_certificates(root_store)
8323                .with_no_client_auth(),
8324        );
8325        let connector = tokio_rustls::TlsConnector::from(tls_config);
8326        let server_name = rustls_pki_types::ServerName::try_from(target_host.clone())
8327            .with_context(|| format!("Invalid TLS server name: {target_host}"))?;
8328
8329        // `stream` is `BoxedIo` — we need a concrete `AsyncRead + AsyncWrite`
8330        // for `TlsConnector::connect`.  Since `BoxedIo` already satisfies
8331        // those bounds we can pass it directly.
8332        let tls_stream = connector
8333            .connect(server_name, stream)
8334            .await
8335            .with_context(|| format!("TLS handshake with {target_host}"))?;
8336        BoxedIo(Box::new(tls_stream))
8337    } else {
8338        stream
8339    };
8340
8341    // Perform the WebSocket client handshake over the tunnelled stream.
8342    let ws_request = tokio_tungstenite::tungstenite::http::Request::builder()
8343        .uri(ws_url)
8344        .header("Host", format!("{target_host}:{target_port}"))
8345        .header("Connection", "Upgrade")
8346        .header("Upgrade", "websocket")
8347        .header(
8348            "Sec-WebSocket-Key",
8349            tokio_tungstenite::tungstenite::handshake::client::generate_key(),
8350        )
8351        .header("Sec-WebSocket-Version", "13")
8352        .body(())
8353        .with_context(|| "Failed to build WebSocket upgrade request")?;
8354
8355    let (ws_stream, response) = tokio_tungstenite::client_async(ws_request, stream)
8356        .await
8357        .with_context(|| format!("WebSocket handshake failed for {ws_url}"))?;
8358
8359    Ok((ws_stream, response))
8360}
8361
8362/// Find the `\r\n\r\n` boundary marking the end of HTTP headers.
8363fn find_header_end(buf: &[u8]) -> Option<usize> {
8364    buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
8365}
8366
8367// ── Memory ───────────────────────────────────────────────────
8368
8369/// Persistent storage configuration (`[storage]` section).
8370///
8371/// Storage is a two-tier alias-keyed map: `[storage.<backend>.<alias>]`,
8372/// parallel to `[model_providers.<type>.<alias>]`. Each backend has its own typed
8373/// config struct. `MemoryConfig.backend` carries a dotted reference (`"sqlite.default"`,
8374/// `"postgres.work"`) that resolves to one of these entries via
8375/// [`Config::resolve_active_storage`].
8376#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
8377#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8378#[prefix = "storage"]
8379pub struct StorageConfig {
8380    /// SQLite storage instances (`[storage.sqlite.<alias>]`).
8381    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8382    #[nested]
8383    pub sqlite: HashMap<String, SqliteStorageConfig>,
8384    /// PostgreSQL storage instances (`[storage.postgres.<alias>]`).
8385    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8386    #[nested]
8387    pub postgres: HashMap<String, PostgresStorageConfig>,
8388    /// Qdrant storage instances (`[storage.qdrant.<alias>]`).
8389    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8390    #[nested]
8391    pub qdrant: HashMap<String, QdrantStorageConfig>,
8392    /// Markdown storage instances (`[storage.markdown.<alias>]`).
8393    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8394    #[nested]
8395    pub markdown: HashMap<String, MarkdownStorageConfig>,
8396    /// Lucid CLI sync instances (`[storage.lucid.<alias>]`).
8397    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8398    #[nested]
8399    pub lucid: HashMap<String, LucidStorageConfig>,
8400}
8401
8402/// SQLite storage backend (`[storage.sqlite.<alias>]`).
8403#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
8404#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8405#[prefix = "storage-sqlite"]
8406#[serde(default)]
8407pub struct SqliteStorageConfig {
8408    /// Optional override for the SQLite database path.
8409    /// When unset, defaults to `<workspace_dir>/brain.db`.
8410    pub path: Option<String>,
8411    /// Maximum seconds to wait when opening the DB if it's locked.
8412    /// `None` waits indefinitely. Recommended max: 300.
8413    pub open_timeout_secs: Option<u64>,
8414}
8415
8416/// PostgreSQL storage backend (`[storage.postgres.<alias>]`).
8417///
8418/// Holds connection parameters AND pgvector settings on one alias-keyed
8419/// entry; previously these lived in two separate sections.
8420#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8421#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8422#[prefix = "storage-postgres"]
8423#[serde(default)]
8424pub struct PostgresStorageConfig {
8425    /// Connection URL (e.g. `"postgres://user:pass@host/db"`).
8426    /// Accepts legacy aliases: dbURL, database_url, databaseUrl.
8427    #[serde(alias = "dbURL", alias = "database_url", alias = "databaseUrl")]
8428    #[secret]
8429    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
8430    pub db_url: Option<String>,
8431    /// Database schema for the memory table.
8432    pub schema: String,
8433    /// Table name for memory entries.
8434    pub table: String,
8435    /// Optional connection timeout in seconds.
8436    pub connect_timeout_secs: Option<u64>,
8437    /// Enable pgvector extension for hybrid vector+keyword recall.
8438    pub vector_enabled: bool,
8439    /// Vector dimensions for pgvector embeddings.
8440    pub vector_dimensions: usize,
8441}
8442
8443impl Default for PostgresStorageConfig {
8444    fn default() -> Self {
8445        Self {
8446            db_url: None,
8447            schema: default_storage_schema(),
8448            table: default_storage_table(),
8449            connect_timeout_secs: None,
8450            vector_enabled: false,
8451            vector_dimensions: default_pgvector_dimensions(),
8452        }
8453    }
8454}
8455
8456/// Qdrant vector database backend (`[storage.qdrant.<alias>]`).
8457///
8458/// URL, collection, and API key all fall back to environment variables
8459/// (`QDRANT_URL`, `QDRANT_COLLECTION`, `QDRANT_API_KEY`) when unset.
8460#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8461#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8462#[prefix = "storage-qdrant"]
8463#[serde(default)]
8464pub struct QdrantStorageConfig {
8465    /// Qdrant server URL (e.g. `"http://localhost:6333"`).
8466    /// Falls back to `QDRANT_URL` env var if unset.
8467    pub url: Option<String>,
8468    /// Collection name for storing memories.
8469    /// Falls back to `QDRANT_COLLECTION` env var, or `"zeroclaw_memories"`.
8470    pub collection: String,
8471    /// API key for Qdrant Cloud or secured instances.
8472    /// Falls back to `QDRANT_API_KEY` env var if unset.
8473    #[secret]
8474    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
8475    pub api_key: Option<String>,
8476}
8477
8478impl Default for QdrantStorageConfig {
8479    fn default() -> Self {
8480        Self {
8481            url: None,
8482            collection: default_qdrant_collection(),
8483            api_key: None,
8484        }
8485    }
8486}
8487
8488/// Markdown directory storage (`[storage.markdown.<alias>]`).
8489#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
8490#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8491#[prefix = "storage-markdown"]
8492#[serde(default)]
8493pub struct MarkdownStorageConfig {
8494    /// Optional override for the markdown root directory.
8495    /// When unset, defaults to `<workspace_dir>/memory/`.
8496    pub directory: Option<String>,
8497}
8498
8499/// Lucid CLI sync backend (`[storage.lucid.<alias>]`).
8500#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
8501#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8502#[prefix = "storage-lucid"]
8503#[serde(default)]
8504pub struct LucidStorageConfig {
8505    /// Optional path to the lucid-memory binary.
8506    pub binary_path: Option<String>,
8507}
8508
8509fn default_storage_schema() -> String {
8510    "public".into()
8511}
8512
8513fn default_storage_table() -> String {
8514    "memories".into()
8515}
8516
8517fn default_qdrant_collection() -> String {
8518    "zeroclaw_memories".into()
8519}
8520
8521/// Search strategy for memory recall.
8522#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
8523#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8524#[serde(rename_all = "snake_case")]
8525pub enum SearchMode {
8526    /// Pure keyword search (FTS5 BM25)
8527    Bm25,
8528    /// Pure vector/semantic search
8529    Embedding,
8530    /// Weighted combination of keyword + vector (default)
8531    #[default]
8532    Hybrid,
8533}
8534
8535/// Memory backend configuration (`[memory]` section).
8536///
8537/// Controls conversation memory storage, embeddings, hybrid search, response
8538/// caching, and memory snapshot/hydration. Backend-specific connection settings
8539/// live under `[storage.<backend>.<alias>]`; this section selects which storage
8540/// instance to use via the `backend` dotted reference.
8541#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8542#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8543#[prefix = "memory"]
8544#[allow(clippy::struct_excessive_bools)]
8545pub struct MemoryConfig {
8546    /// Dotted reference to the active storage instance: `<backend>.<alias>`
8547    /// (e.g. `"sqlite.default"`, `"postgres.work"`). Resolves through
8548    /// `Config.storage.<backend>.<alias>` at runtime. Bare backend names
8549    /// (`"sqlite"`) are treated as `"<backend>.default"`. Set to `"none"` to
8550    /// disable persistence entirely.
8551    pub backend: String,
8552    /// Auto-save what *you* tell ZeroClaw into memory as conversation history — the agent's own replies are not saved. Turn off if you want memory to only hold things you explicitly record via the memory tool.
8553    #[serde(default = "default_auto_save")]
8554    pub auto_save: bool,
8555    /// Run the periodic hygiene pass that archives stale daily/session files and enforces retention windows. Leave on unless you want to manage cleanup yourself.
8556    #[serde(default = "default_hygiene_enabled")]
8557    pub hygiene_enabled: bool,
8558    /// Move daily/session files to the archive directory after this many days. Keeps the hot working set small without deleting history.
8559    #[serde(default = "default_archive_after_days")]
8560    pub archive_after_days: u32,
8561    /// Delete archived files permanently after this many days. Set high if you need long-term history; set low for privacy / disk-space reasons.
8562    #[serde(default = "default_purge_after_days")]
8563    pub purge_after_days: u32,
8564    /// For the sqlite backend only — drop conversation rows older than this many days to keep the DB lean. Doesn't touch core memories or notes.
8565    #[serde(default = "default_conversation_retention_days")]
8566    pub conversation_retention_days: u32,
8567    /// Source of embedding vectors for semantic search. `none` = keyword-only retrieval (no API calls, no vector cost); `openai` = OpenAI's embedding API; `custom:URL` = any OpenAI-compatible embedding endpoint (LiteLLM, local gateway, etc.).
8568    #[serde(default = "default_embedding_provider")]
8569    pub embedding_provider: String,
8570    /// Embedding model identifier — must match a model your chosen embedding model_provider serves (e.g. `text-embedding-3-small` for OpenAI). Changing this invalidates existing embeddings; you'll need to re-index.
8571    #[serde(default = "default_embedding_model")]
8572    pub embedding_model: String,
8573    /// Vector width produced by the embedding model — must match the model's native dimension or vectors won't store correctly. Look up the number on the model_provider's model page.
8574    #[serde(default = "default_embedding_dims")]
8575    pub embedding_dimensions: usize,
8576    /// How heavily vector (semantic) similarity counts when `search_mode = hybrid`. Raise toward 1.0 to favor meaning-based matches; lower it to lean on keyword overlap instead.
8577    #[serde(default = "default_vector_weight")]
8578    pub vector_weight: f64,
8579    /// How heavily BM25 (keyword) overlap counts when `search_mode = hybrid`. Raise toward 1.0 for exact-term matching; lower it when paraphrases should still score well.
8580    #[serde(default = "default_keyword_weight")]
8581    pub keyword_weight: f64,
8582    /// How memories are retrieved: `bm25` = keyword-only (no embeddings, cheapest); `embedding` = vector similarity only (needs an embedding model_provider); `hybrid` = blended keyword + vector score using the weights above (most robust).
8583    #[serde(default)]
8584    pub search_mode: SearchMode,
8585    /// Minimum hybrid score (0.0–1.0) for a memory to be included in context.
8586    /// Memories scoring below this threshold are dropped to prevent irrelevant
8587    /// context from bleeding into conversations. Default: 0.4
8588    #[serde(default = "default_min_relevance_score")]
8589    pub min_relevance_score: f64,
8590    /// Max embedding cache entries before LRU eviction
8591    #[serde(default = "default_cache_size")]
8592    pub embedding_cache_size: usize,
8593    /// Max tokens per chunk for document splitting
8594    #[serde(default = "default_chunk_size")]
8595    pub chunk_max_tokens: usize,
8596
8597    // ── Response Cache (saves tokens on repeated prompts) ──────
8598    /// Enable LLM response caching to avoid paying for duplicate prompts
8599    #[serde(default)]
8600    pub response_cache_enabled: bool,
8601    /// TTL in minutes for cached responses (default: 60)
8602    #[serde(default = "default_response_cache_ttl")]
8603    pub response_cache_ttl_minutes: u32,
8604    /// Max number of cached responses before LRU eviction (default: 5000)
8605    #[serde(default = "default_response_cache_max")]
8606    pub response_cache_max_entries: usize,
8607    /// Max in-memory hot cache entries for the two-tier response cache (default: 256)
8608    #[serde(default = "default_response_cache_hot_entries")]
8609    pub response_cache_hot_entries: usize,
8610
8611    // ── Memory Snapshot (soul backup to Markdown) ─────────────
8612    /// Enable periodic export of core memories to MEMORY_SNAPSHOT.md
8613    #[serde(default)]
8614    pub snapshot_enabled: bool,
8615    /// Run snapshot during hygiene passes (heartbeat-driven)
8616    #[serde(default)]
8617    pub snapshot_on_hygiene: bool,
8618    /// Auto-hydrate from MEMORY_SNAPSHOT.md when brain.db is missing
8619    #[serde(default = "default_true")]
8620    pub auto_hydrate: bool,
8621
8622    // ── Retrieval Pipeline ─────────────────────────────────────
8623    /// Retrieval stages to execute in order. Valid: "cache", "fts", "vector".
8624    #[serde(default = "default_retrieval_stages")]
8625    pub retrieval_stages: Vec<String>,
8626    /// Enable LLM reranking when candidate count exceeds threshold.
8627    #[serde(default)]
8628    pub rerank_enabled: bool,
8629    /// Minimum candidate count to trigger reranking.
8630    #[serde(default = "default_rerank_threshold")]
8631    pub rerank_threshold: usize,
8632    /// FTS score above which to early-return without vector search (0.0–1.0).
8633    #[serde(default = "default_fts_early_return_score")]
8634    pub fts_early_return_score: f64,
8635
8636    // ── Namespace Isolation ─────────────────────────────────────
8637    /// Default namespace for memory entries.
8638    #[serde(default = "default_namespace")]
8639    pub default_namespace: String,
8640
8641    // ── Conflict Resolution ─────────────────────────────────────
8642    /// Cosine similarity threshold for conflict detection (0.0–1.0).
8643    #[serde(default = "default_conflict_threshold")]
8644    pub conflict_threshold: f64,
8645
8646    // ── Audit Trail ─────────────────────────────────────────────
8647    /// Enable audit logging of memory operations.
8648    #[serde(default)]
8649    pub audit_enabled: bool,
8650    /// Retention period for audit entries in days (default: 30).
8651    #[serde(default = "default_audit_retention_days")]
8652    pub audit_retention_days: u32,
8653
8654    // ── Policy Engine ───────────────────────────────────────────
8655    /// Memory policy configuration.
8656    #[serde(default)]
8657    #[nested]
8658    pub policy: MemoryPolicyConfig,
8659    // Backend-specific config fields (sqlite_open_timeout_secs, qdrant.*,
8660    // postgres.*) live on `[storage.<backend>.<alias>]`. The `backend` field
8661    // carries a dotted alias reference and the runtime looks up the typed
8662    // config via `Config::resolve_active_storage`.
8663}
8664
8665/// Memory policy configuration (`[memory.policy]` section).
8666#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
8667#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8668#[prefix = "memory.policy"]
8669pub struct MemoryPolicyConfig {
8670    /// Maximum entries per namespace (0 = unlimited).
8671    #[serde(default)]
8672    pub max_entries_per_namespace: usize,
8673    /// Maximum entries per category (0 = unlimited).
8674    #[serde(default)]
8675    pub max_entries_per_category: usize,
8676    /// Retention days by category (overrides global). Keys: "core", "daily", "conversation".
8677    #[serde(default)]
8678    pub retention_days_by_category: std::collections::HashMap<String, u32>,
8679    /// Namespaces that are read-only (writes are rejected).
8680    #[serde(default)]
8681    pub read_only_namespaces: Vec<String>,
8682}
8683
8684fn default_retrieval_stages() -> Vec<String> {
8685    vec!["cache".into(), "fts".into(), "vector".into()]
8686}
8687fn default_rerank_threshold() -> usize {
8688    5
8689}
8690fn default_fts_early_return_score() -> f64 {
8691    0.85
8692}
8693fn default_namespace() -> String {
8694    "default".into()
8695}
8696fn default_conflict_threshold() -> f64 {
8697    0.85
8698}
8699fn default_audit_retention_days() -> u32 {
8700    30
8701}
8702
8703fn default_pgvector_dimensions() -> usize {
8704    1536
8705}
8706
8707fn default_embedding_provider() -> String {
8708    "none".into()
8709}
8710fn default_auto_save() -> bool {
8711    true
8712}
8713fn default_hygiene_enabled() -> bool {
8714    true
8715}
8716fn default_archive_after_days() -> u32 {
8717    7
8718}
8719fn default_purge_after_days() -> u32 {
8720    30
8721}
8722fn default_conversation_retention_days() -> u32 {
8723    30
8724}
8725fn default_embedding_model() -> String {
8726    "text-embedding-3-small".into()
8727}
8728fn default_embedding_dims() -> usize {
8729    1536
8730}
8731fn default_vector_weight() -> f64 {
8732    0.7
8733}
8734fn default_keyword_weight() -> f64 {
8735    0.3
8736}
8737fn default_min_relevance_score() -> f64 {
8738    0.4
8739}
8740fn default_cache_size() -> usize {
8741    10_000
8742}
8743fn default_chunk_size() -> usize {
8744    512
8745}
8746fn default_response_cache_ttl() -> u32 {
8747    60
8748}
8749fn default_response_cache_max() -> usize {
8750    5_000
8751}
8752
8753fn default_response_cache_hot_entries() -> usize {
8754    256
8755}
8756
8757impl Default for MemoryConfig {
8758    fn default() -> Self {
8759        Self {
8760            backend: "sqlite".into(),
8761            auto_save: true,
8762            hygiene_enabled: default_hygiene_enabled(),
8763            archive_after_days: default_archive_after_days(),
8764            purge_after_days: default_purge_after_days(),
8765            conversation_retention_days: default_conversation_retention_days(),
8766            embedding_provider: default_embedding_provider(),
8767            embedding_model: default_embedding_model(),
8768            embedding_dimensions: default_embedding_dims(),
8769            vector_weight: default_vector_weight(),
8770            keyword_weight: default_keyword_weight(),
8771            search_mode: SearchMode::default(),
8772            min_relevance_score: default_min_relevance_score(),
8773            embedding_cache_size: default_cache_size(),
8774            chunk_max_tokens: default_chunk_size(),
8775            response_cache_enabled: false,
8776            response_cache_ttl_minutes: default_response_cache_ttl(),
8777            response_cache_max_entries: default_response_cache_max(),
8778            response_cache_hot_entries: default_response_cache_hot_entries(),
8779            snapshot_enabled: false,
8780            snapshot_on_hygiene: false,
8781            auto_hydrate: true,
8782            retrieval_stages: default_retrieval_stages(),
8783            rerank_enabled: false,
8784            rerank_threshold: default_rerank_threshold(),
8785            fts_early_return_score: default_fts_early_return_score(),
8786            default_namespace: default_namespace(),
8787            conflict_threshold: default_conflict_threshold(),
8788            audit_enabled: false,
8789            audit_retention_days: default_audit_retention_days(),
8790            policy: MemoryPolicyConfig::default(),
8791        }
8792    }
8793}
8794
8795// ── Observability ─────────────────────────────────────────────────
8796
8797/// Observability backend configuration (`[observability]` section).
8798#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8799#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8800#[prefix = "observability"]
8801pub struct ObservabilityConfig {
8802    /// "none" | "log" | "verbose" | "prometheus" | "otel"
8803    pub backend: String,
8804
8805    /// OTLP endpoint (e.g. `"http://localhost:4318"`). Only used when backend = `"otel"`.
8806    #[serde(default)]
8807    pub otel_endpoint: Option<String>,
8808
8809    /// Service name reported to the OTel collector. Defaults to "zeroclaw".
8810    #[serde(default)]
8811    pub otel_service_name: Option<String>,
8812
8813    /// Optional HTTP headers sent with every OTLP export request (e.g. authorization).
8814    /// Specified as key-value pairs in TOML:
8815    /// ```toml
8816    /// [observability.otel_headers]
8817    /// Authorization = "Bearer sk-..."
8818    /// ```
8819    #[serde(default)]
8820    pub otel_headers: Option<std::collections::HashMap<String, String>>,
8821
8822    /// Log persistence mode: "none" | "rolling" | "full".
8823    /// Controls whether every event passing through `zeroclaw_log::record!`
8824    /// is appended to the on-disk JSONL log.
8825    #[serde(default = "default_log_persistence", alias = "runtime_trace_mode")]
8826    pub log_persistence: String,
8827
8828    /// Log persistence file path. Relative paths resolve under workspace_dir.
8829    #[serde(default = "default_log_persistence_path", alias = "runtime_trace_path")]
8830    pub log_persistence_path: String,
8831
8832    /// Maximum entries retained when `log_persistence = "rolling"`.
8833    #[serde(
8834        default = "default_log_persistence_max_entries",
8835        alias = "runtime_trace_max_entries"
8836    )]
8837    pub log_persistence_max_entries: usize,
8838
8839    /// Tool I/O capture policy: "off" | "redacted" | "full".
8840    /// - `off`: only tool name + outcome + duration land in the log.
8841    /// - `redacted` (default): tool input + output are leak-scanned and
8842    ///   truncated at `log_tool_io_truncate_bytes` before persisting.
8843    /// - `full`: full input + output, still leak-scanned. For operators
8844    ///   who need replay fidelity and accept the disk cost.
8845    #[serde(default = "default_log_tool_io")]
8846    pub log_tool_io: String,
8847
8848    /// Truncate the captured tool input and output at this many bytes when
8849    /// `log_tool_io = "redacted"`. Truncated events carry an explicit
8850    /// `tool_output_truncated: true` flag plus `tool_output_original_bytes`.
8851    #[serde(default = "default_log_tool_io_truncate_bytes")]
8852    pub log_tool_io_truncate_bytes: usize,
8853
8854    /// Tool names whose I/O is never logged beyond name + outcome + duration
8855    /// regardless of `log_tool_io`. Use for tools whose I/O is intrinsically
8856    /// sensitive (e.g. memory recall against personal namespaces, agent
8857    /// secret reads). Empty by default.
8858    #[serde(default)]
8859    pub log_tool_io_denylist: Vec<String>,
8860}
8861
8862impl Default for ObservabilityConfig {
8863    fn default() -> Self {
8864        Self {
8865            backend: "none".into(),
8866            otel_endpoint: None,
8867            otel_service_name: None,
8868            otel_headers: None,
8869            log_persistence: default_log_persistence(),
8870            log_persistence_path: default_log_persistence_path(),
8871            log_persistence_max_entries: default_log_persistence_max_entries(),
8872            log_tool_io: default_log_tool_io(),
8873            log_tool_io_truncate_bytes: default_log_tool_io_truncate_bytes(),
8874            log_tool_io_denylist: Vec::new(),
8875        }
8876    }
8877}
8878
8879fn default_log_persistence() -> String {
8880    "rolling".to_string()
8881}
8882
8883fn default_log_persistence_path() -> String {
8884    "state/runtime-trace.jsonl".to_string()
8885}
8886
8887fn default_log_persistence_max_entries() -> usize {
8888    200
8889}
8890
8891fn default_log_tool_io() -> String {
8892    "redacted".to_string()
8893}
8894
8895fn default_log_tool_io_truncate_bytes() -> usize {
8896    8192
8897}
8898
8899// ── Hooks ────────────────────────────────────────────────────────
8900
8901#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8902#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8903#[prefix = "hooks"]
8904pub struct HooksConfig {
8905    /// Enable lifecycle hook execution.
8906    ///
8907    /// Hooks run in-process with the same privileges as the main runtime.
8908    /// Keep enabled hook handlers narrowly scoped and auditable.
8909    pub enabled: bool,
8910    #[serde(default)]
8911    #[nested]
8912    pub builtin: BuiltinHooksConfig,
8913}
8914
8915impl Default for HooksConfig {
8916    fn default() -> Self {
8917        Self {
8918            enabled: true,
8919            builtin: BuiltinHooksConfig::default(),
8920        }
8921    }
8922}
8923
8924#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
8925#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8926#[prefix = "hooks.builtin"]
8927pub struct BuiltinHooksConfig {
8928    /// Enable the command-logger hook (logs tool calls for auditing).
8929    pub command_logger: bool,
8930    /// Configuration for the webhook-audit hook.
8931    ///
8932    /// When enabled, POSTs a JSON payload to `url` for every tool invocation
8933    /// that matches one of `tool_patterns`.
8934    #[serde(default)]
8935    #[nested]
8936    pub webhook_audit: WebhookAuditConfig,
8937}
8938
8939/// Configuration for the webhook-audit builtin hook.
8940///
8941/// Sends an HTTP POST with a JSON body to an external endpoint each time
8942/// a tool call matches one of the configured patterns. Useful for
8943/// centralised audit logging, SIEM ingestion, or compliance pipelines.
8944#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8945#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8946#[prefix = "hooks.builtin.webhook-audit"]
8947pub struct WebhookAuditConfig {
8948    /// Enable the webhook-audit hook. Default: `false`.
8949    #[serde(default)]
8950    pub enabled: bool,
8951    /// Target URL that will receive the audit POST requests.
8952    #[serde(default)]
8953    pub url: String,
8954    /// Glob patterns for tool names to audit (e.g. `["Bash", "Write"]`).
8955    /// An empty list means **no** tools are audited.
8956    #[serde(default)]
8957    pub tool_patterns: Vec<String>,
8958    /// Include tool call arguments in the audit payload. Default: `false`.
8959    ///
8960    /// Be mindful of sensitive data — arguments may contain secrets or PII.
8961    #[serde(default)]
8962    pub include_args: bool,
8963    /// Maximum size (in bytes) of serialised arguments included in a single
8964    /// audit payload. Arguments exceeding this limit are truncated.
8965    /// Default: `4096`.
8966    #[serde(default = "default_max_args_bytes")]
8967    pub max_args_bytes: u64,
8968}
8969
8970fn default_max_args_bytes() -> u64 {
8971    4096
8972}
8973
8974impl Default for WebhookAuditConfig {
8975    fn default() -> Self {
8976        Self {
8977            enabled: false,
8978            url: String::new(),
8979            tool_patterns: Vec::new(),
8980            include_args: false,
8981            max_args_bytes: default_max_args_bytes(),
8982        }
8983    }
8984}
8985
8986// ── Autonomy / Security ──────────────────────────────────────────
8987//
8988// All policy fields live on per-agent `[risk_profiles.<alias>]` entries
8989// (see `RiskProfileConfig` below). `Config::active_risk_profile(agent_alias)`
8990// resolves the active profile for any callsite (agent-driven or non-agent
8991// contexts). Configs from older schema versions are folded into
8992// `risk_profiles.default` by the migration in `schema/v2.rs`.
8993
8994fn default_auto_approve() -> Vec<String> {
8995    vec![
8996        "file_read".into(),
8997        "memory_recall".into(),
8998        "web_search_tool".into(),
8999        "web_fetch".into(),
9000        "calculator".into(),
9001        "glob_search".into(),
9002        "content_search".into(),
9003        "image_info".into(),
9004        "weather".into(),
9005        "browser".into(),
9006        "browser_open".into(),
9007    ]
9008}
9009
9010fn default_always_ask() -> Vec<String> {
9011    vec![]
9012}
9013
9014impl RiskProfileConfig {
9015    /// Merge the built-in default `auto_approve` entries into the current
9016    /// list, preserving any user-supplied additions.
9017    pub fn ensure_default_auto_approve(&mut self) {
9018        let defaults = default_auto_approve();
9019        for entry in defaults {
9020            if !self.auto_approve.iter().any(|existing| existing == &entry) {
9021                self.auto_approve.push(entry);
9022            }
9023        }
9024    }
9025
9026    /// Synthesize a [`SandboxConfig`] from this profile's flattened sandbox
9027    /// fields. Sandbox config is stored flat on the profile; callsites that
9028    /// still want a `SandboxConfig` instance (sandbox detection in
9029    /// `zeroclaw-runtime::security::detect`) can call this helper.
9030    #[must_use]
9031    pub fn sandbox_config(&self) -> SandboxConfig {
9032        let backend = self
9033            .sandbox_backend
9034            .as_deref()
9035            .map(str::trim)
9036            .filter(|s| !s.is_empty())
9037            .map(parse_sandbox_backend)
9038            .unwrap_or_default();
9039        SandboxConfig {
9040            enabled: self.sandbox_enabled,
9041            backend,
9042            firejail_args: self.firejail_args.clone(),
9043        }
9044    }
9045}
9046
9047fn parse_sandbox_backend(name: &str) -> SandboxBackend {
9048    match name.to_ascii_lowercase().as_str() {
9049        "auto" => SandboxBackend::Auto,
9050        "landlock" => SandboxBackend::Landlock,
9051        "firejail" => SandboxBackend::Firejail,
9052        "bubblewrap" => SandboxBackend::Bubblewrap,
9053        "docker" => SandboxBackend::Docker,
9054        "sandbox-exec" | "sandboxexec" | "seatbelt" => SandboxBackend::SandboxExec,
9055        "none" => SandboxBackend::None,
9056        _ => SandboxBackend::default(),
9057    }
9058}
9059
9060fn is_valid_env_var_name(name: &str) -> bool {
9061    let mut chars = name.chars();
9062    match chars.next() {
9063        Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
9064        _ => return false,
9065    }
9066    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
9067}
9068
9069// ── Profiles & Bundles ───────────────────────────────────────────
9070
9071/// Named risk/autonomy profile (`[risk_profiles.<alias>]`).
9072///
9073/// Unified policy surface. Agents reference a profile by alias and the
9074/// runtime resolves through it for shell command allowlists, approval gates,
9075/// sandbox/resource limits, and delegation guardrails. The conventional
9076/// `risk_profiles["default"]` is the resolution target for non-agent
9077/// contexts (orchestrator init, cron worker startup); the `Default` impl
9078/// below mirrors the legacy safety-first defaults so a fresh install
9079/// behaves the same as a config from before the per-profile split.
9080#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9081#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9082#[prefix = "risk-profile"]
9083#[serde(default)]
9084pub struct RiskProfileConfig {
9085    /// Autonomy level applied to this profile. Default: `supervised`.
9086    pub level: AutonomyLevel,
9087    /// Restrict filesystem access to workspace-relative paths. Default: `false`.
9088    pub workspace_only: bool,
9089    /// Allowlist of executable names for shell execution.
9090    pub allowed_commands: Vec<String>,
9091    /// Explicit path denylist.
9092    pub forbidden_paths: Vec<String>,
9093    /// Require approval for medium-risk operations.
9094    pub require_approval_for_medium_risk: bool,
9095    /// Block high-risk commands even when allowlisted.
9096    pub block_high_risk_commands: bool,
9097    /// Environment variable names passed through to shell subprocesses.
9098    pub shell_env_passthrough: Vec<String>,
9099    /// Tools that never require approval in this profile.
9100    pub auto_approve: Vec<String>,
9101    /// Tools that always require approval in this profile.
9102    pub always_ask: Vec<String>,
9103    /// Extra directory roots the agent may access.
9104    #[serde(alias = "allowed_path", alias = "allowed_paths")]
9105    pub allowed_roots: Vec<String>,
9106    /// Tools the agent may call in agentic mode. Empty = inherit / no
9107    /// authorization constraint. Authorization decision: which tools is
9108    /// the agent permitted to invoke at all. See `excluded_tools` for
9109    /// the inverse denylist scoped to non-CLI channels.
9110    pub allowed_tools: Vec<String>,
9111    /// Tools excluded from non-CLI channels under this profile.
9112    pub excluded_tools: Vec<String>,
9113    // ── Sandbox (from security.sandbox) ─────────────────────────────
9114    /// Whether the sandbox is enabled for this profile. `None` inherits global.
9115    pub sandbox_enabled: Option<bool>,
9116    /// Sandbox backend identifier (e.g. `"firejail"`, `"landlock"`). `None` inherits.
9117    pub sandbox_backend: Option<String>,
9118    /// Extra arguments forwarded to firejail when sandbox_backend = "firejail".
9119    pub firejail_args: Vec<String>,
9120}
9121
9122impl Default for RiskProfileConfig {
9123    fn default() -> Self {
9124        Self {
9125            level: AutonomyLevel::Supervised,
9126            workspace_only: true,
9127            allowed_commands: crate::policy::default_allowed_commands(),
9128            forbidden_paths: crate::policy::default_forbidden_paths(),
9129            require_approval_for_medium_risk: true,
9130            block_high_risk_commands: true,
9131            shell_env_passthrough: vec![],
9132            auto_approve: default_auto_approve(),
9133            always_ask: default_always_ask(),
9134            allowed_roots: Vec::new(),
9135            allowed_tools: Vec::new(),
9136            excluded_tools: Vec::new(),
9137            sandbox_enabled: None,
9138            sandbox_backend: None,
9139            firejail_args: Vec::new(),
9140        }
9141    }
9142}
9143
9144/// Named runtime/LLM execution profile (`[runtime_profiles.<alias>]`).
9145///
9146/// Reusable operational tuning: agentic mode, iteration caps, context
9147/// budget, parallel dispatch, resource ceilings, recursion depth, and
9148/// the budget knobs that `SecurityPolicy` enforces with subagent
9149/// parent-subset discipline. Anything authorization-shaped (allowed
9150/// commands/tools/paths, approval gates, sandbox) lives on
9151/// `[risk_profiles.<alias>]`. Anything model-provider shaped (model,
9152/// temperature, max_tokens, timeout_secs) lives on
9153/// `[providers.models.<type>.<alias>]`.
9154#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9155#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9156#[prefix = "runtime-profile"]
9157#[serde(default)]
9158pub struct RuntimeProfileConfig {
9159    /// Enable agentic (multi-turn tool-call loop) mode.
9160    pub agentic: bool,
9161    /// Maximum tool-call iterations in agentic mode. `0` inherits the global default.
9162    pub max_tool_iterations: usize,
9163    // ── Budget caps (enforced with subagent parent-subset discipline) ──
9164    /// Maximum actions allowed per hour. `0` inherits the global limit.
9165    /// `SecurityPolicy::ensure_no_escalation_beyond` rejects subagents
9166    /// that try to raise this above the parent's value.
9167    pub max_actions_per_hour: u32,
9168    /// Maximum cost per day in cents. `0` inherits the global limit.
9169    /// Parent-subset enforced for subagents.
9170    pub max_cost_per_day_cents: u32,
9171    /// Shell subprocess timeout in seconds. `0` inherits the global timeout.
9172    /// Parent-subset enforced for subagents.
9173    pub shell_timeout_secs: u64,
9174    // ── Delegation tuning ──
9175    /// Maximum delegation recursion depth. `0` inherits the default.
9176    pub max_delegation_depth: u32,
9177    /// Delegate call timeout in seconds. `None` inherits global delegate timeout.
9178    pub delegation_timeout_secs: Option<u64>,
9179    /// Agentic delegate run timeout in seconds. `None` inherits global.
9180    pub agentic_timeout_secs: Option<u64>,
9181    // ── Per-agent runtime tunables (also live on AliasedAgentConfig) ─
9182    /// Maximum conversation history messages retained per session. `None` inherits.
9183    pub max_history_messages: Option<usize>,
9184    /// Maximum estimated tokens for context before compaction. `None` inherits.
9185    pub max_context_tokens: Option<usize>,
9186    /// Use compact bootstrap (6000 chars / 2 RAG chunks). `None` inherits.
9187    pub compact_context: Option<bool>,
9188    /// Enable parallel tool execution per iteration. `None` inherits.
9189    pub parallel_tools: Option<bool>,
9190    /// Tool dispatch strategy (e.g. `"auto"`). `None` inherits.
9191    pub tool_dispatcher: Option<String>,
9192    /// Tools exempt from within-turn dedup check.
9193    pub tool_call_dedup_exempt: Vec<String>,
9194    /// Maximum characters for the assembled system prompt. `None` inherits.
9195    pub max_system_prompt_chars: Option<usize>,
9196    /// Enable context-aware tool filtering per iteration. `None` inherits.
9197    pub context_aware_tools: Option<bool>,
9198    /// Maximum characters for a single tool result. `None` inherits.
9199    pub max_tool_result_chars: Option<usize>,
9200    /// Number of recent turns whose full tool context is preserved. `None` inherits.
9201    pub keep_tool_context_turns: Option<usize>,
9202}
9203
9204impl Default for RuntimeProfileConfig {
9205    fn default() -> Self {
9206        Self {
9207            agentic: false,
9208            max_tool_iterations: 0,
9209            max_actions_per_hour: 20,
9210            max_cost_per_day_cents: 500,
9211            shell_timeout_secs: 60,
9212            max_delegation_depth: 0,
9213            delegation_timeout_secs: None,
9214            agentic_timeout_secs: None,
9215            max_history_messages: None,
9216            max_context_tokens: None,
9217            compact_context: None,
9218            parallel_tools: None,
9219            tool_dispatcher: None,
9220            tool_call_dedup_exempt: Vec::new(),
9221            max_system_prompt_chars: None,
9222            context_aware_tools: None,
9223            max_tool_result_chars: None,
9224            keep_tool_context_turns: None,
9225        }
9226    }
9227}
9228
9229/// Named skill bundle (`[skill_bundles.<alias>]`).
9230///
9231/// A reusable group of skills that can be attached to an agent or channel
9232/// by alias, controlling which skills are loaded and from where.
9233#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9234#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9235#[prefix = "skill-bundle"]
9236#[serde(default)]
9237pub struct SkillBundleConfig {
9238    /// Directory path (relative to workspace root) to load skills from.
9239    pub directory: Option<String>,
9240    /// Skill names to include. Empty means include all skills in `directory`.
9241    pub include: Vec<String>,
9242    /// Skill names to exclude from this bundle.
9243    pub exclude: Vec<String>,
9244}
9245
9246/// Named knowledge bundle (`[knowledge_bundles.<alias>]`).
9247///
9248/// A reusable set of knowledge sources (documents, URLs, or RAG corpus paths)
9249/// that can be attached to an agent by alias.
9250#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9251#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9252#[prefix = "knowledge-bundle"]
9253#[serde(default)]
9254pub struct KnowledgeBundleConfig {
9255    /// Paths or URLs to include in this knowledge bundle.
9256    pub sources: Vec<String>,
9257    /// Tags for filtering or categorising sources within the bundle.
9258    pub tags: Vec<String>,
9259}
9260
9261/// Named MCP server bundle (`[mcp_bundles.<alias>]`).
9262///
9263/// A reusable group of MCP servers that can be activated together by alias.
9264#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9265#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9266#[prefix = "mcp-bundle"]
9267#[serde(default)]
9268pub struct McpBundleConfig {
9269    /// MCP server IDs to include in this bundle.
9270    pub servers: Vec<String>,
9271    /// MCP server IDs to exclude from this bundle.
9272    pub exclude: Vec<String>,
9273}
9274
9275// ── Runtime ──────────────────────────────────────────────────────
9276
9277/// Runtime adapter configuration (`[runtime]` section).
9278#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9279#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9280#[prefix = "runtime"]
9281pub struct RuntimeConfig {
9282    /// Runtime kind (`native` | `docker`).
9283    #[serde(default = "default_runtime_kind")]
9284    pub kind: String,
9285
9286    /// Docker runtime settings (used when `kind = "docker"`).
9287    #[serde(default)]
9288    #[nested]
9289    pub docker: DockerRuntimeConfig,
9290
9291    /// Global reasoning override for model_providers that expose explicit controls.
9292    /// - `None`: model_provider default behavior
9293    /// - `Some(true)`: request reasoning/thinking when supported
9294    /// - `Some(false)`: disable reasoning/thinking when supported
9295    #[serde(default)]
9296    pub reasoning_enabled: Option<bool>,
9297    /// Optional reasoning effort for model_providers that expose a level control.
9298    #[serde(default, deserialize_with = "deserialize_reasoning_effort_opt")]
9299    pub reasoning_effort: Option<String>,
9300}
9301
9302/// Docker runtime configuration (`[runtime.docker]` section).
9303#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9304#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9305#[prefix = "runtime.docker"]
9306pub struct DockerRuntimeConfig {
9307    /// Runtime image used to execute shell commands.
9308    #[serde(default = "default_docker_image")]
9309    pub image: String,
9310
9311    /// Docker network mode (`none`, `bridge`, etc.).
9312    #[serde(default = "default_docker_network")]
9313    pub network: String,
9314
9315    /// Optional memory limit in MB (`None` = no explicit limit).
9316    #[serde(default = "default_docker_memory_limit_mb")]
9317    pub memory_limit_mb: Option<u64>,
9318
9319    /// Optional CPU limit (`None` = no explicit limit).
9320    #[serde(default = "default_docker_cpu_limit")]
9321    pub cpu_limit: Option<f64>,
9322
9323    /// Mount root filesystem as read-only.
9324    #[serde(default = "default_true")]
9325    pub read_only_rootfs: bool,
9326
9327    /// Mount configured workspace into `/workspace`.
9328    #[serde(default = "default_true")]
9329    pub mount_workspace: bool,
9330
9331    /// Optional workspace root allowlist for Docker mount validation.
9332    #[serde(default)]
9333    pub allowed_workspace_roots: Vec<String>,
9334}
9335
9336fn default_runtime_kind() -> String {
9337    "native".into()
9338}
9339
9340fn default_docker_image() -> String {
9341    "alpine:3.20".into()
9342}
9343
9344fn default_docker_network() -> String {
9345    "none".into()
9346}
9347
9348fn default_docker_memory_limit_mb() -> Option<u64> {
9349    Some(512)
9350}
9351
9352fn default_docker_cpu_limit() -> Option<f64> {
9353    Some(1.0)
9354}
9355
9356impl Default for DockerRuntimeConfig {
9357    fn default() -> Self {
9358        Self {
9359            image: default_docker_image(),
9360            network: default_docker_network(),
9361            memory_limit_mb: default_docker_memory_limit_mb(),
9362            cpu_limit: default_docker_cpu_limit(),
9363            read_only_rootfs: true,
9364            mount_workspace: true,
9365            allowed_workspace_roots: Vec::new(),
9366        }
9367    }
9368}
9369
9370impl Default for RuntimeConfig {
9371    fn default() -> Self {
9372        Self {
9373            kind: default_runtime_kind(),
9374            docker: DockerRuntimeConfig::default(),
9375            reasoning_enabled: None,
9376            reasoning_effort: None,
9377        }
9378    }
9379}
9380
9381// ── Reliability / supervision ────────────────────────────────────
9382
9383/// Reliability and supervision configuration (`[reliability]` section).
9384///
9385/// Controls model_provider retries, API key rotation, and channel restart backoff.
9386#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9387#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9388#[prefix = "reliability"]
9389pub struct ReliabilityConfig {
9390    /// Retries per model_provider before bailing.
9391    #[serde(default = "default_provider_retries")]
9392    pub provider_retries: u32,
9393    /// Base backoff (ms) for model_provider retry delay.
9394    #[serde(default = "default_provider_backoff_ms")]
9395    pub provider_backoff_ms: u64,
9396    /// Additional API keys for round-robin rotation on rate-limit (429) errors.
9397    /// The primary `api_key` is always tried first; these are extras.
9398    #[serde(default)]
9399    pub api_keys: Vec<String>,
9400    /// Initial backoff for channel/daemon restarts.
9401    #[serde(default = "default_channel_backoff_secs")]
9402    pub channel_initial_backoff_secs: u64,
9403    /// Max backoff for channel/daemon restarts.
9404    #[serde(default = "default_channel_backoff_max_secs")]
9405    pub channel_max_backoff_secs: u64,
9406    /// Scheduler polling cadence in seconds.
9407    #[serde(default = "default_scheduler_poll_secs")]
9408    pub scheduler_poll_secs: u64,
9409    /// Max retries for cron job execution attempts.
9410    #[serde(default = "default_scheduler_retries")]
9411    pub scheduler_retries: u32,
9412}
9413
9414fn default_provider_retries() -> u32 {
9415    2
9416}
9417
9418fn default_provider_backoff_ms() -> u64 {
9419    500
9420}
9421
9422fn default_channel_backoff_secs() -> u64 {
9423    2
9424}
9425
9426fn default_channel_backoff_max_secs() -> u64 {
9427    60
9428}
9429
9430fn default_scheduler_poll_secs() -> u64 {
9431    15
9432}
9433
9434fn default_scheduler_retries() -> u32 {
9435    2
9436}
9437
9438impl Default for ReliabilityConfig {
9439    fn default() -> Self {
9440        Self {
9441            provider_retries: default_provider_retries(),
9442            provider_backoff_ms: default_provider_backoff_ms(),
9443            api_keys: Vec::new(),
9444            channel_initial_backoff_secs: default_channel_backoff_secs(),
9445            channel_max_backoff_secs: default_channel_backoff_max_secs(),
9446            scheduler_poll_secs: default_scheduler_poll_secs(),
9447            scheduler_retries: default_scheduler_retries(),
9448        }
9449    }
9450}
9451
9452// ── Scheduler ────────────────────────────────────────────────────
9453
9454/// Scheduler configuration for periodic task execution (`[scheduler]` section).
9455///
9456/// Owns the cron-runtime knobs: per-job declarations live on
9457/// `Config.cron: HashMap<String, CronJobDecl>` (alias-keyed), while the
9458/// scheduler loop's runtime behavior (`enabled`, polling cap, catch-up) lives here.
9459#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9460#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9461#[prefix = "scheduler"]
9462pub struct SchedulerConfig {
9463    /// Enable the built-in scheduler loop. When false, no cron jobs run.
9464    #[serde(default = "default_scheduler_enabled")]
9465    pub enabled: bool,
9466    /// Maximum number of persisted scheduled tasks per polling cycle.
9467    #[serde(default = "default_scheduler_max_tasks")]
9468    pub max_tasks: usize,
9469    /// Maximum tasks executed in parallel within a single polling cycle.
9470    #[serde(default = "default_scheduler_max_concurrent")]
9471    pub max_concurrent: usize,
9472    /// Run all overdue jobs at scheduler startup. Default: `true`.
9473    ///
9474    /// When the daemon restarts late, jobs whose `next_run` is in the past
9475    /// fire once before normal polling resumes. Disable to wait for the
9476    /// next scheduled occurrence instead.
9477    #[serde(default = "default_true")]
9478    pub catch_up_on_startup: bool,
9479    /// Maximum number of historical cron run records to retain. Default: `50`.
9480    #[serde(default = "default_max_run_history")]
9481    pub max_run_history: u32,
9482}
9483
9484fn default_scheduler_enabled() -> bool {
9485    true
9486}
9487
9488fn default_scheduler_max_tasks() -> usize {
9489    64
9490}
9491
9492fn default_scheduler_max_concurrent() -> usize {
9493    4
9494}
9495
9496impl Default for SchedulerConfig {
9497    fn default() -> Self {
9498        Self {
9499            enabled: default_scheduler_enabled(),
9500            max_tasks: default_scheduler_max_tasks(),
9501            max_concurrent: default_scheduler_max_concurrent(),
9502            catch_up_on_startup: true,
9503            max_run_history: default_max_run_history(),
9504        }
9505    }
9506}
9507
9508// ── Model routing ────────────────────────────────────────────────
9509
9510/// Route a task hint to a specific model_provider + model.
9511///
9512/// ```toml
9513/// [[model_routes]]
9514/// hint = "reasoning"
9515/// model_provider = "openrouter"
9516/// model = "anthropic/claude-opus-4-20250514"
9517///
9518/// [[model_routes]]
9519/// hint = "fast"
9520/// model_provider = "groq"
9521/// model = "llama-3.3-70b-versatile"
9522/// ```
9523///
9524/// Usage: pass `hint:reasoning` as the model parameter to route the request.
9525#[derive(Debug, Clone, Serialize, Deserialize)]
9526#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9527pub struct ModelRouteConfig {
9528    /// Task hint name (e.g. "reasoning", "fast", "code", "summarize")
9529    pub hint: String,
9530    /// Model provider to route to (must match a known model-provider name)
9531    pub model_provider: String,
9532    /// Model to use with that model provider
9533    pub model: String,
9534    /// Optional API key override for this route's model provider
9535    #[serde(default)]
9536    pub api_key: Option<String>,
9537}
9538
9539// ── Embedding routing ───────────────────────────────────────────
9540
9541/// Route an embedding hint to a specific model_provider + model.
9542///
9543/// ```toml
9544/// [[embedding_routes]]
9545/// hint = "semantic"
9546/// model_provider = "openai"
9547/// model = "text-embedding-3-small"
9548/// dimensions = 1536
9549///
9550/// [memory]
9551/// embedding_model = "hint:semantic"
9552/// ```
9553#[derive(Debug, Clone, Serialize, Deserialize)]
9554#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9555pub struct EmbeddingRouteConfig {
9556    /// Route hint name (e.g. "semantic", "archive", "faq")
9557    pub hint: String,
9558    /// Embedding-capable model provider (`none`, `openai`, or `custom:<url>`)
9559    pub model_provider: String,
9560    /// Embedding model to use with that model provider
9561    pub model: String,
9562    /// Optional embedding dimension override for this route
9563    #[serde(default)]
9564    pub dimensions: Option<usize>,
9565    /// Optional API key override for this route's model_provider
9566    #[serde(default)]
9567    pub api_key: Option<String>,
9568}
9569
9570// ── Query Classification ─────────────────────────────────────────
9571
9572/// Automatic query classification — classifies user messages by keyword/pattern
9573/// and routes to the appropriate model hint. Disabled by default.
9574#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
9575#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9576#[prefix = "query-classification"]
9577pub struct QueryClassificationConfig {
9578    /// Enable automatic query classification. Default: `false`.
9579    #[serde(default)]
9580    pub enabled: bool,
9581    /// Classification rules evaluated in priority order.
9582    #[serde(default)]
9583    pub rules: Vec<ClassificationRule>,
9584}
9585
9586/// A single classification rule mapping message patterns to a model hint.
9587#[derive(Debug, Clone, Serialize, Deserialize, Default)]
9588#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9589pub struct ClassificationRule {
9590    /// Must match a `[[model_routes]]` hint value.
9591    pub hint: String,
9592    /// Case-insensitive substring matches.
9593    #[serde(default)]
9594    pub keywords: Vec<String>,
9595    /// Case-sensitive literal matches (for "```", "fn ", etc.).
9596    #[serde(default)]
9597    pub patterns: Vec<String>,
9598    /// Only match if message length >= N chars.
9599    #[serde(default)]
9600    pub min_length: Option<usize>,
9601    /// Only match if message length <= N chars.
9602    #[serde(default)]
9603    pub max_length: Option<usize>,
9604    /// Higher priority rules are checked first.
9605    #[serde(default)]
9606    pub priority: i32,
9607}
9608
9609// ── Heartbeat ────────────────────────────────────────────────────
9610
9611/// Heartbeat configuration for periodic health pings (`[heartbeat]` section).
9612#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9613#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9614#[prefix = "heartbeat"]
9615#[allow(clippy::struct_excessive_bools)]
9616pub struct HeartbeatConfig {
9617    /// Enable periodic heartbeat pings. Default: `false`. When enabled,
9618    /// `agent` must name a configured agent — there is no default agent
9619    /// for heartbeat to fall through to.
9620    #[serde(default)]
9621    pub enabled: bool,
9622    /// Configured agent alias the heartbeat worker runs as. Required
9623    /// when `enabled = true`; refers to a `[agents.<alias>]` entry.
9624    #[serde(default)]
9625    pub agent: String,
9626    /// Interval in minutes between heartbeat pings. Minimum: `1`. Default: `30`.
9627    #[serde(default = "default_heartbeat_interval")]
9628    pub interval_minutes: u32,
9629    /// Enable two-phase heartbeat: Phase 1 asks LLM whether to run, Phase 2
9630    /// executes only when the LLM decides there is work to do. Saves API cost
9631    /// during quiet periods. Default: `true`.
9632    #[serde(default = "default_two_phase")]
9633    pub two_phase: bool,
9634    /// Optional fallback task text when `HEARTBEAT.md` has no task entries.
9635    #[serde(default)]
9636    pub message: Option<String>,
9637    /// Optional delivery channel for heartbeat output (for example: `telegram`).
9638    /// When omitted, auto-selects the first configured channel.
9639    #[serde(default, alias = "channel")]
9640    pub target: Option<String>,
9641    /// Optional delivery recipient/chat identifier (required when `target` is
9642    /// explicitly set).
9643    #[serde(default, alias = "recipient")]
9644    pub to: Option<String>,
9645    /// Enable adaptive intervals that back off on failures and speed up for
9646    /// high-priority tasks. Default: `false`.
9647    #[serde(default)]
9648    pub adaptive: bool,
9649    /// Minimum interval in minutes when adaptive mode is enabled. Default: `5`.
9650    #[serde(default = "default_heartbeat_min_interval")]
9651    pub min_interval_minutes: u32,
9652    /// Maximum interval in minutes when adaptive mode backs off. Default: `120`.
9653    #[serde(default = "default_heartbeat_max_interval")]
9654    pub max_interval_minutes: u32,
9655    /// Dead-man's switch timeout in minutes. If the heartbeat has not ticked
9656    /// within this window, an alert is sent. `0` disables. Default: `0`.
9657    #[serde(default)]
9658    pub deadman_timeout_minutes: u32,
9659    /// Channel for dead-man's switch alerts (e.g. `telegram`). Falls back to
9660    /// the heartbeat delivery channel.
9661    #[serde(default)]
9662    pub deadman_channel: Option<String>,
9663    /// Recipient for dead-man's switch alerts. Falls back to `to`.
9664    #[serde(default)]
9665    pub deadman_to: Option<String>,
9666    /// Maximum number of heartbeat run history records to retain. Default: `100`.
9667    #[serde(default = "default_heartbeat_max_run_history")]
9668    pub max_run_history: u32,
9669    /// Load the channel session history before each heartbeat task execution so
9670    /// the LLM has conversational context. Default: `false`.
9671    ///
9672    /// When `true`, the session file for the configured `target`/`to` is passed
9673    /// to the agent as `session_state_file`, giving it access to the recent
9674    /// conversation history — just as if the user had sent a message.
9675    #[serde(default)]
9676    pub load_session_context: bool,
9677    /// Maximum wall-clock seconds allowed for a single agent invocation
9678    /// (Phase 1 decision or Phase 2 task execution). `0` disables.
9679    /// Default: `600` (10 minutes).
9680    #[serde(default = "default_heartbeat_task_timeout")]
9681    pub task_timeout_secs: u64,
9682}
9683
9684fn default_heartbeat_interval() -> u32 {
9685    30
9686}
9687
9688fn default_two_phase() -> bool {
9689    true
9690}
9691
9692fn default_heartbeat_min_interval() -> u32 {
9693    5
9694}
9695
9696fn default_heartbeat_max_interval() -> u32 {
9697    120
9698}
9699
9700fn default_heartbeat_max_run_history() -> u32 {
9701    100
9702}
9703
9704fn default_heartbeat_task_timeout() -> u64 {
9705    600
9706}
9707
9708impl Default for HeartbeatConfig {
9709    fn default() -> Self {
9710        Self {
9711            enabled: false,
9712            agent: String::new(),
9713            interval_minutes: default_heartbeat_interval(),
9714            two_phase: true,
9715            message: None,
9716            target: None,
9717            to: None,
9718            adaptive: false,
9719            min_interval_minutes: default_heartbeat_min_interval(),
9720            max_interval_minutes: default_heartbeat_max_interval(),
9721            deadman_timeout_minutes: 0,
9722            deadman_channel: None,
9723            deadman_to: None,
9724            max_run_history: default_heartbeat_max_run_history(),
9725            load_session_context: false,
9726            task_timeout_secs: default_heartbeat_task_timeout(),
9727        }
9728    }
9729}
9730
9731// ── Cron ────────────────────────────────────────────────────────
9732
9733/// A declarative cron job definition (`[cron.<alias>]`).
9734///
9735/// Stored alias-keyed on `Config.cron`. The map key serves as the stable job id.
9736/// Synced into the database at scheduler startup with `source = "declarative"`,
9737/// distinguishing them from jobs created imperatively via CLI or API.
9738/// Declarative config takes precedence on each sync: if the config changes,
9739/// the DB is updated to match. Imperative jobs are never deleted by sync.
9740#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9741#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9742#[prefix = "cron"]
9743pub struct CronJobDecl {
9744    /// Human-readable name.
9745    #[serde(default)]
9746    pub name: Option<String>,
9747    /// Job type: `"shell"` (default) or `"agent"`.
9748    #[serde(default = "default_job_type_decl")]
9749    pub job_type: String,
9750    /// Schedule for the job.
9751    #[serde(default)]
9752    pub schedule: CronScheduleDecl,
9753    /// Shell command to run (required when `job_type = "shell"`).
9754    #[serde(default)]
9755    pub command: Option<String>,
9756    /// Agent prompt (required when `job_type = "agent"`).
9757    #[serde(default)]
9758    pub prompt: Option<String>,
9759    /// Whether the job is enabled. Default: `true`.
9760    #[serde(default = "default_true")]
9761    pub enabled: bool,
9762    /// Model override for agent jobs.
9763    #[serde(default)]
9764    pub model: Option<String>,
9765    /// Allowlist of tool names for agent jobs.
9766    #[serde(default)]
9767    pub allowed_tools: Option<Vec<String>>,
9768    /// Whether to recall and inject memory context before this agent job runs.
9769    /// Defaults to `true`; set to `false` for stateless digest jobs.
9770    #[serde(default = "default_true")]
9771    pub uses_memory: bool,
9772    /// Session target: `"isolated"` (default) or `"main"`.
9773    #[serde(default)]
9774    pub session_target: Option<String>,
9775    /// Delivery configuration.
9776    #[serde(default)]
9777    #[nested]
9778    pub delivery: Option<DeliveryConfigDecl>,
9779}
9780
9781impl Default for CronJobDecl {
9782    fn default() -> Self {
9783        Self {
9784            name: None,
9785            job_type: default_job_type_decl(),
9786            schedule: CronScheduleDecl::default(),
9787            command: None,
9788            prompt: None,
9789            enabled: true,
9790            model: None,
9791            allowed_tools: None,
9792            uses_memory: true,
9793            session_target: None,
9794            delivery: None,
9795        }
9796    }
9797}
9798
9799/// Schedule variant for declarative cron jobs.
9800#[derive(Debug, Clone, Serialize, Deserialize)]
9801#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9802#[serde(tag = "kind", rename_all = "lowercase")]
9803pub enum CronScheduleDecl {
9804    /// Classic cron expression.
9805    Cron {
9806        expr: String,
9807        #[serde(default)]
9808        tz: Option<String>,
9809    },
9810    /// Interval in milliseconds.
9811    Every { every_ms: u64 },
9812    /// One-shot at an RFC 3339 timestamp.
9813    At { at: String },
9814}
9815
9816impl Default for CronScheduleDecl {
9817    fn default() -> Self {
9818        // Empty cron expression — `validate_decl` rejects it. Used only as
9819        // a placeholder when a fresh map entry is auto-created via the
9820        // schema's `create_map_key` path; the user fills it in immediately.
9821        Self::Cron {
9822            expr: String::new(),
9823            tz: None,
9824        }
9825    }
9826}
9827
9828/// Delivery configuration for declarative cron jobs.
9829#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9830#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9831#[prefix = "cron-delivery"]
9832pub struct DeliveryConfigDecl {
9833    /// Delivery mode: `"none"` or `"announce"`.
9834    #[serde(default = "default_delivery_mode")]
9835    pub mode: String,
9836    /// Channel name (e.g. `"telegram"`, `"discord"`).
9837    #[serde(default)]
9838    pub channel: Option<String>,
9839    /// Target/recipient identifier.
9840    #[serde(default)]
9841    pub to: Option<String>,
9842    /// Optional thread/conversation identifier carried into the outbound send.
9843    /// Required by channels that route on a separate `thread_id` field (e.g.
9844    /// webhook callbacks bridging into agent-chat platforms).
9845    #[serde(default, skip_serializing_if = "Option::is_none")]
9846    pub thread_id: Option<String>,
9847    /// Best-effort delivery. Default: `true`.
9848    #[serde(default = "default_true")]
9849    pub best_effort: bool,
9850}
9851
9852impl Default for DeliveryConfigDecl {
9853    fn default() -> Self {
9854        Self {
9855            mode: default_delivery_mode(),
9856            channel: None,
9857            to: None,
9858            thread_id: None,
9859            best_effort: true,
9860        }
9861    }
9862}
9863
9864fn default_job_type_decl() -> String {
9865    "shell".to_string()
9866}
9867
9868fn default_delivery_mode() -> String {
9869    "none".to_string()
9870}
9871
9872fn default_max_run_history() -> u32 {
9873    50
9874}
9875
9876// ── ACP ──────────────────────────────────────────────────────────
9877
9878/// ACP (Agent Client Protocol) server configuration (`[acp]` section).
9879#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9880#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9881#[prefix = "acp"]
9882pub struct AcpConfig {
9883    /// Agent alias to use when `session/new` omits `agentAlias` and more than
9884    /// one agent is configured. When exactly one agent exists it is
9885    /// auto-selected regardless of this field.
9886    #[serde(default, skip_serializing_if = "Option::is_none")]
9887    pub default_agent: Option<String>,
9888    /// Maximum number of concurrent ACP sessions. Default: `10`.
9889    #[serde(default = "default_acp_max_sessions")]
9890    pub max_sessions: usize,
9891    /// Idle session timeout in seconds. Sessions with no activity for this
9892    /// duration are eligible for eviction. Default: `3600` (1 hour).
9893    #[serde(default = "default_acp_session_timeout_secs")]
9894    pub session_timeout_secs: u64,
9895}
9896
9897fn default_acp_max_sessions() -> usize {
9898    10
9899}
9900
9901fn default_acp_session_timeout_secs() -> u64 {
9902    3600
9903}
9904
9905impl Default for AcpConfig {
9906    fn default() -> Self {
9907        Self {
9908            default_agent: None,
9909            max_sessions: default_acp_max_sessions(),
9910            session_timeout_secs: default_acp_session_timeout_secs(),
9911        }
9912    }
9913}
9914
9915// ── Tunnel ──────────────────────────────────────────────────────
9916
9917/// Tunnel configuration for exposing the gateway publicly (`[tunnel]` section).
9918///
9919/// Supported model_providers: `"none"` (default), `"cloudflare"`, `"tailscale"`, `"ngrok"`, `"openvpn"`, `"pinggy"`, `"custom"`.
9920#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9921#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9922#[prefix = "tunnel"]
9923pub struct TunnelConfig {
9924    /// How the gateway gets exposed to the public internet so webhooks (Telegram, Slack, etc.) can reach it. `none` = keep it local, no tunnel; `cloudflare` = Cloudflare Tunnel via cloudflared (needs a Zero Trust account and token); `tailscale` = Tailscale Funnel/Serve (tailnet-only or public, no account beyond tailscale); `ngrok` = ngrok agent with auth token; `openvpn` = bring-your-own OpenVPN egress; `pinggy` = Pinggy SSH tunnels (quick one-shot URLs); `custom` = run an arbitrary command you define under `[tunnel.custom]`.
9925    pub tunnel_provider: String,
9926
9927    /// Cloudflare Tunnel configuration (used when `tunnel_provider = "cloudflare"`).
9928    #[serde(default)]
9929    #[nested]
9930    pub cloudflare: Option<CloudflareTunnelConfig>,
9931
9932    /// Tailscale Funnel/Serve configuration (used when `tunnel_provider = "tailscale"`).
9933    #[serde(default)]
9934    #[nested]
9935    pub tailscale: Option<TailscaleTunnelConfig>,
9936
9937    /// ngrok tunnel configuration (used when `tunnel_provider = "ngrok"`).
9938    #[serde(default)]
9939    #[nested]
9940    pub ngrok: Option<NgrokTunnelConfig>,
9941
9942    /// OpenVPN tunnel configuration (used when `tunnel_provider = "openvpn"`).
9943    #[serde(default)]
9944    #[nested]
9945    pub openvpn: Option<OpenVpnTunnelConfig>,
9946
9947    /// Custom tunnel command configuration (used when `tunnel_provider = "custom"`).
9948    #[serde(default)]
9949    #[nested]
9950    pub custom: Option<CustomTunnelConfig>,
9951
9952    /// Pinggy tunnel configuration (used when `tunnel_provider = "pinggy"`).
9953    #[serde(default)]
9954    #[nested]
9955    pub pinggy: Option<PinggyTunnelConfig>,
9956}
9957
9958impl Default for TunnelConfig {
9959    fn default() -> Self {
9960        Self {
9961            tunnel_provider: "none".into(),
9962            cloudflare: None,
9963            tailscale: None,
9964            ngrok: None,
9965            openvpn: None,
9966            custom: None,
9967            pinggy: None,
9968        }
9969    }
9970}
9971
9972#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9973#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9974#[prefix = "tunnel.cloudflare"]
9975pub struct CloudflareTunnelConfig {
9976    /// Cloudflare Tunnel token (from Zero Trust dashboard)
9977    #[serde(default)]
9978    #[secret]
9979    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
9980    pub token: String,
9981}
9982
9983#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9984#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9985#[prefix = "tunnel.tailscale"]
9986pub struct TailscaleTunnelConfig {
9987    /// Use Tailscale Funnel (public internet) vs Serve (tailnet only)
9988    #[serde(default)]
9989    pub funnel: bool,
9990    /// Optional hostname override
9991    #[serde(default)]
9992    pub hostname: Option<String>,
9993}
9994
9995#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9996#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9997#[prefix = "tunnel.ngrok"]
9998pub struct NgrokTunnelConfig {
9999    /// ngrok auth token
10000    #[serde(default)]
10001    #[secret]
10002    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10003    pub auth_token: String,
10004    /// Optional custom domain
10005    #[serde(default)]
10006    pub domain: Option<String>,
10007}
10008
10009/// OpenVPN tunnel configuration (`[tunnel.openvpn]`).
10010///
10011/// Required when `tunnel.tunnel_provider = "openvpn"`. Omitting this section entirely
10012/// preserves previous behavior. Setting `tunnel.tunnel_provider = "none"` (or removing
10013/// the `[tunnel.openvpn]` block) cleanly reverts to no-tunnel mode.
10014///
10015/// Defaults: `connect_timeout_secs = 30`.
10016#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10017#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10018#[prefix = "tunnel.openvpn"]
10019pub struct OpenVpnTunnelConfig {
10020    /// Path to `.ovpn` configuration file (must not be empty).
10021    pub config_file: String,
10022    /// Optional path to auth credentials file (`--auth-user-pass`).
10023    #[serde(default)]
10024    pub auth_file: Option<String>,
10025    /// Advertised address once VPN is connected (e.g., `"10.8.0.2:42617"`).
10026    /// When omitted the tunnel falls back to `http://{local_host}:{local_port}`.
10027    #[serde(default)]
10028    pub advertise_address: Option<String>,
10029    /// Connection timeout in seconds (default: 30, must be > 0).
10030    #[serde(default = "default_openvpn_timeout")]
10031    pub connect_timeout_secs: u64,
10032    /// Extra openvpn CLI arguments forwarded verbatim.
10033    #[serde(default)]
10034    pub extra_args: Vec<String>,
10035}
10036
10037fn default_openvpn_timeout() -> u64 {
10038    30
10039}
10040
10041impl Default for OpenVpnTunnelConfig {
10042    fn default() -> Self {
10043        Self {
10044            config_file: String::new(),
10045            auth_file: None,
10046            advertise_address: None,
10047            connect_timeout_secs: default_openvpn_timeout(),
10048            extra_args: Vec::new(),
10049        }
10050    }
10051}
10052
10053#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10054#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10055#[prefix = "tunnel.pinggy"]
10056pub struct PinggyTunnelConfig {
10057    /// Pinggy access token (optional — free tier works without one).
10058    #[serde(default)]
10059    #[secret]
10060    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10061    pub token: Option<String>,
10062    /// Server region: `"us"` (USA), `"eu"` (Europe), `"ap"` (Asia), `"br"` (South America), `"au"` (Australia), or omit for auto.
10063    #[serde(default)]
10064    pub region: Option<String>,
10065}
10066
10067#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10068#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10069#[prefix = "tunnel.custom"]
10070pub struct CustomTunnelConfig {
10071    /// Command template to start the tunnel. Use {port} and {host} placeholders.
10072    /// Example: "bore local {port} --to bore.pub"
10073    #[serde(default)]
10074    pub start_command: String,
10075    /// Optional URL to check tunnel health
10076    #[serde(default)]
10077    pub health_url: Option<String>,
10078    /// Optional regex to extract public URL from command stdout
10079    #[serde(default)]
10080    pub url_pattern: Option<String>,
10081}
10082
10083// ── Channels ─────────────────────────────────────────────────────
10084
10085/// Top-level channel configurations (`[channels]` section).
10086///
10087/// each channel type is a keyed table of named instances (aliases).
10088/// `[channels.telegram.default]` is the conventional single-instance key.
10089/// Access via `config.channels.telegram.get("default")`.
10090#[allow(clippy::struct_excessive_bools)]
10091#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10092#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10093#[prefix = "channels"]
10094pub struct ChannelsConfig {
10095    /// Enable the CLI interactive channel. Default: `true`.
10096    #[serde(default = "default_true")]
10097    pub cli: bool,
10098    /// Telegram bot channel instances (`[channels.telegram.<alias>]`).
10099    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10100    #[nested]
10101    pub telegram: HashMap<String, TelegramConfig>,
10102    /// Discord bot channel instances (`[channels.discord.<alias>]`).
10103    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10104    #[nested]
10105    pub discord: HashMap<String, DiscordConfig>,
10106    /// Slack bot channel instances (`[channels.slack.<alias>]`).
10107    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10108    #[nested]
10109    pub slack: HashMap<String, SlackConfig>,
10110    /// Mattermost bot channel instances (`[channels.mattermost.<alias>]`).
10111    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10112    #[nested]
10113    pub mattermost: HashMap<String, MattermostConfig>,
10114    /// Webhook channel instances (`[channels.webhook.<alias>]`).
10115    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10116    #[nested]
10117    pub webhook: HashMap<String, WebhookConfig>,
10118    /// iMessage channel instances (`[channels.imessage.<alias>]`, macOS only).
10119    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10120    #[nested]
10121    pub imessage: HashMap<String, IMessageConfig>,
10122    /// Matrix channel instances (`[channels.matrix.<alias>]`).
10123    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10124    #[nested]
10125    pub matrix: HashMap<String, MatrixConfig>,
10126    /// Signal channel instances (`[channels.signal.<alias>]`).
10127    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10128    #[nested]
10129    pub signal: HashMap<String, SignalConfig>,
10130    /// WhatsApp channel instances (`[channels.whatsapp.<alias>]`).
10131    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10132    #[nested]
10133    pub whatsapp: HashMap<String, WhatsAppConfig>,
10134    /// Linq Partner API channel instances (`[channels.linq.<alias>]`).
10135    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10136    #[nested]
10137    pub linq: HashMap<String, LinqConfig>,
10138    /// WATI WhatsApp Business API channel instances (`[channels.wati.<alias>]`).
10139    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10140    #[nested]
10141    pub wati: HashMap<String, WatiConfig>,
10142    /// Nextcloud Talk bot channel instances (`[channels.nextcloud_talk.<alias>]`).
10143    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10144    #[nested]
10145    pub nextcloud_talk: HashMap<String, NextcloudTalkConfig>,
10146    /// Email channel instances (`[channels.email.<alias>]`).
10147    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10148    #[nested]
10149    pub email: HashMap<String, crate::scattered_types::EmailConfig>,
10150    /// Gmail Pub/Sub push notification channel instances (`[channels.gmail_push.<alias>]`).
10151    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10152    #[nested]
10153    pub gmail_push: HashMap<String, crate::scattered_types::GmailPushConfig>,
10154    /// IRC channel instances (`[channels.irc.<alias>]`).
10155    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10156    #[nested]
10157    pub irc: HashMap<String, IrcConfig>,
10158    /// Lark channel instances (`[channels.lark.<alias>]`).
10159    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10160    #[nested]
10161    pub lark: HashMap<String, LarkConfig>,
10162    /// LINE Messaging API channel instances (`[channels.line.<alias>]`).
10163    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10164    #[nested]
10165    pub line: HashMap<String, LineConfig>,
10166    /// DingTalk channel instances (`[channels.dingtalk.<alias>]`).
10167    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10168    #[nested]
10169    pub dingtalk: HashMap<String, DingTalkConfig>,
10170    /// WeCom (WeChat Enterprise) Bot Webhook channel instances (`[channels.wecom.<alias>]`).
10171    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10172    #[nested]
10173    pub wecom: HashMap<String, WeComConfig>,
10174    /// WeCom AI Bot WebSocket channel instances (`[channels.wecom_ws.<alias>]`).
10175    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10176    #[nested]
10177    pub wecom_ws: HashMap<String, WeComWsConfig>,
10178    /// WeChat personal iLink Bot channel instances (`[channels.wechat.<alias>]`).
10179    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10180    #[nested]
10181    pub wechat: HashMap<String, WeChatConfig>,
10182    /// QQ Official Bot channel instances (`[channels.qq.<alias>]`).
10183    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10184    #[nested]
10185    pub qq: HashMap<String, QQConfig>,
10186    /// X/Twitter channel instances (`[channels.twitter.<alias>]`).
10187    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10188    #[nested]
10189    pub twitter: HashMap<String, TwitterConfig>,
10190    /// Mochat customer service channel instances (`[channels.mochat.<alias>]`).
10191    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10192    #[nested]
10193    pub mochat: HashMap<String, MochatConfig>,
10194    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10195    #[nested]
10196    pub nostr: HashMap<String, NostrConfig>,
10197    /// ClawdTalk voice channel instances (`[channels.clawdtalk.<alias>]`).
10198    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10199    #[nested]
10200    pub clawdtalk: HashMap<String, crate::scattered_types::ClawdTalkConfig>,
10201    /// Reddit channel instances (`[channels.reddit.<alias>]`).
10202    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10203    #[nested]
10204    pub reddit: HashMap<String, RedditConfig>,
10205    /// Bluesky channel instances (`[channels.bluesky.<alias>]`).
10206    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10207    #[nested]
10208    pub bluesky: HashMap<String, BlueskyConfig>,
10209    /// Voice call channel instances (`[channels.voice_call.<alias>]`).
10210    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10211    #[nested]
10212    pub voice_call: HashMap<String, crate::scattered_types::VoiceCallConfig>,
10213    /// Voice wake word detection channel instances (`[channels.voice_wake.<alias>]`).
10214    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10215    #[nested]
10216    pub voice_wake: HashMap<String, VoiceWakeConfig>,
10217    /// Voice duplex instances (`[channels.voice_duplex.<alias>]`).
10218    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10219    #[nested]
10220    pub voice_duplex: HashMap<String, VoiceDuplexConfig>,
10221    /// MQTT channel instances (`[channels.mqtt.<alias>]`).
10222    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10223    #[nested]
10224    pub mqtt: HashMap<String, MqttConfig>,
10225    /// Base timeout in seconds for processing a single channel message (LLM + tools).
10226    /// Runtime uses this as a per-turn budget that scales with tool-loop depth
10227    /// (up to 4x, capped) so one slow/retried model call does not consume the
10228    /// entire conversation budget.
10229    /// Default: 300s for on-device LLMs (Ollama) which are slower than cloud APIs.
10230    #[serde(default = "default_channel_message_timeout_secs")]
10231    pub message_timeout_secs: u64,
10232    /// Whether to add acknowledgement reactions (👀 on receipt, ✅/⚠️ on
10233    /// completion) to incoming channel messages. Default: `true`.
10234    #[serde(default = "default_true")]
10235    pub ack_reactions: bool,
10236    /// Whether to send tool-call notification messages (e.g. `🔧 web_search_tool: …`)
10237    /// to channel users. When `false`, tool calls are still logged server-side but
10238    /// not forwarded as individual channel messages. Default: `false`.
10239    #[serde(default = "default_false")]
10240    pub show_tool_calls: bool,
10241    /// Persist channel conversation history to JSONL files so sessions survive
10242    /// daemon restarts. Files are stored in `{workspace}/sessions/`. Default: `true`.
10243    #[serde(default = "default_true")]
10244    pub session_persistence: bool,
10245    /// Session persistence backend: `"jsonl"` (legacy) or `"sqlite"` (new default).
10246    /// SQLite provides FTS5 search, metadata tracking, and TTL cleanup.
10247    #[serde(default = "default_session_backend")]
10248    pub session_backend: String,
10249    /// Auto-archive stale sessions older than this many hours. `0` disables. Default: `0`.
10250    #[serde(default)]
10251    pub session_ttl_hours: u32,
10252    /// Inbound message debounce window in milliseconds. When a sender fires
10253    /// multiple messages within this window, they are accumulated and dispatched
10254    /// as a single concatenated message. `0` disables debouncing. Default: `0`.
10255    #[serde(default)]
10256    pub debounce_ms: u64,
10257}
10258
10259impl ChannelsConfig {
10260    /// Returns metadata and configuration status for every known channel type.
10261    ///
10262    /// Always returns the full set of channel types regardless of compile-time
10263    /// feature flags — the `configured` flag reflects whether the operator has
10264    /// populated that channel's config section.  For a list restricted to only
10265    /// the channels compiled into this binary use
10266    /// `zeroclaw_channels::listing::compiled_channels` instead.
10267    pub fn channels(&self) -> Vec<super::traits::ChannelInfo> {
10268        use super::traits::ChannelInfo;
10269        vec![
10270            ChannelInfo {
10271                name: "Telegram",
10272                desc: "connect your bot",
10273                configured: !self.telegram.is_empty(),
10274            },
10275            ChannelInfo {
10276                name: "Discord",
10277                desc: "connect your bot",
10278                configured: !self.discord.is_empty(),
10279            },
10280            ChannelInfo {
10281                name: "Slack",
10282                desc: "connect your bot",
10283                configured: !self.slack.is_empty(),
10284            },
10285            ChannelInfo {
10286                name: "Mattermost",
10287                desc: "connect to your bot",
10288                configured: !self.mattermost.is_empty(),
10289            },
10290            ChannelInfo {
10291                name: "iMessage",
10292                desc: "macOS only",
10293                configured: !self.imessage.is_empty(),
10294            },
10295            ChannelInfo {
10296                name: "Matrix",
10297                desc: "self-hosted chat",
10298                configured: !self.matrix.is_empty(),
10299            },
10300            ChannelInfo {
10301                name: "Signal",
10302                desc: "An open-source, encrypted messaging service",
10303                configured: !self.signal.is_empty(),
10304            },
10305            ChannelInfo {
10306                name: "WhatsApp",
10307                desc: "Business Cloud API",
10308                configured: !self.whatsapp.is_empty(),
10309            },
10310            ChannelInfo {
10311                name: "WhatsApp Web",
10312                desc: "native WhatsApp Web (wa-rs)",
10313                configured: self.whatsapp.values().any(|c| c.is_web_config()),
10314            },
10315            ChannelInfo {
10316                name: "Linq",
10317                desc: "iMessage/RCS/SMS via Linq API",
10318                configured: !self.linq.is_empty(),
10319            },
10320            ChannelInfo {
10321                name: "WATI",
10322                desc: "WhatsApp via WATI Business API",
10323                configured: !self.wati.is_empty(),
10324            },
10325            ChannelInfo {
10326                name: "NextCloud Talk",
10327                desc: "NextCloud Talk platform",
10328                configured: !self.nextcloud_talk.is_empty(),
10329            },
10330            ChannelInfo {
10331                name: "Email",
10332                desc: "Email over IMAP/SMTP",
10333                configured: !self.email.is_empty(),
10334            },
10335            ChannelInfo {
10336                name: "Gmail Push",
10337                desc: "Gmail Pub/Sub push notifications",
10338                configured: !self.gmail_push.is_empty(),
10339            },
10340            ChannelInfo {
10341                name: "IRC",
10342                desc: "IRC over TLS",
10343                configured: !self.irc.is_empty(),
10344            },
10345            ChannelInfo {
10346                name: "Lark",
10347                desc: "Lark Bot",
10348                configured: !self.lark.is_empty(),
10349            },
10350            ChannelInfo {
10351                name: "DingTalk",
10352                desc: "DingTalk Stream Mode",
10353                configured: !self.dingtalk.is_empty(),
10354            },
10355            ChannelInfo {
10356                name: "WeCom",
10357                desc: "WeCom Bot Webhook",
10358                configured: !self.wecom.is_empty(),
10359            },
10360            ChannelInfo {
10361                name: "WeCom WebSocket",
10362                desc: "WeCom AI Bot long connection",
10363                configured: !self.wecom_ws.is_empty(),
10364            },
10365            ChannelInfo {
10366                name: "WeChat",
10367                desc: "WeChat iLink Bot",
10368                configured: !self.wechat.is_empty(),
10369            },
10370            ChannelInfo {
10371                name: "QQ Official",
10372                desc: "Tencent QQ Bot",
10373                configured: !self.qq.is_empty(),
10374            },
10375            ChannelInfo {
10376                name: "Nostr",
10377                desc: "Nostr DMs",
10378                configured: !self.nostr.is_empty(),
10379            },
10380            ChannelInfo {
10381                name: "ClawdTalk",
10382                desc: "ClawdTalk Channel",
10383                configured: !self.clawdtalk.is_empty(),
10384            },
10385            ChannelInfo {
10386                name: "Reddit",
10387                desc: "Reddit bot (OAuth2)",
10388                configured: !self.reddit.is_empty(),
10389            },
10390            ChannelInfo {
10391                name: "Bluesky",
10392                desc: "AT Protocol",
10393                configured: !self.bluesky.is_empty(),
10394            },
10395            ChannelInfo {
10396                name: "X/Twitter",
10397                desc: "X/Twitter Bot via API v2",
10398                configured: !self.twitter.is_empty(),
10399            },
10400            ChannelInfo {
10401                name: "Mochat",
10402                desc: "Mochat Customer Service",
10403                configured: !self.mochat.is_empty(),
10404            },
10405            ChannelInfo {
10406                name: "LINE",
10407                desc: "connect your LINE bot",
10408                configured: !self.line.is_empty(),
10409            },
10410            ChannelInfo {
10411                name: "Voice Call",
10412                desc: "outbound voice call channel",
10413                configured: !self.voice_call.is_empty(),
10414            },
10415            ChannelInfo {
10416                name: "VoiceWake",
10417                desc: "voice wake word detection",
10418                configured: !self.voice_wake.is_empty(),
10419            },
10420            ChannelInfo {
10421                name: "MQTT",
10422                desc: "MQTT SOP Listener",
10423                configured: !self.mqtt.is_empty(),
10424            },
10425            ChannelInfo {
10426                name: "Webhook",
10427                desc: "HTTP endpoint",
10428                configured: !self.webhook.is_empty(),
10429            },
10430        ]
10431    }
10432}
10433
10434fn default_channel_message_timeout_secs() -> u64 {
10435    300
10436}
10437
10438fn default_session_backend() -> String {
10439    "sqlite".into()
10440}
10441
10442impl Default for ChannelsConfig {
10443    fn default() -> Self {
10444        Self {
10445            cli: true,
10446            telegram: HashMap::new(),
10447            discord: HashMap::new(),
10448            slack: HashMap::new(),
10449            mattermost: HashMap::new(),
10450            webhook: HashMap::new(),
10451            imessage: HashMap::new(),
10452            matrix: HashMap::new(),
10453            signal: HashMap::new(),
10454            whatsapp: HashMap::new(),
10455            linq: HashMap::new(),
10456            wati: HashMap::new(),
10457            nextcloud_talk: HashMap::new(),
10458            email: HashMap::new(),
10459            gmail_push: HashMap::new(),
10460            irc: HashMap::new(),
10461            lark: HashMap::new(),
10462            line: HashMap::new(),
10463            dingtalk: HashMap::new(),
10464            wecom: HashMap::new(),
10465            wecom_ws: HashMap::new(),
10466            wechat: HashMap::new(),
10467            qq: HashMap::new(),
10468            twitter: HashMap::new(),
10469            mochat: HashMap::new(),
10470            nostr: HashMap::new(),
10471            clawdtalk: HashMap::new(),
10472            reddit: HashMap::new(),
10473            bluesky: HashMap::new(),
10474            voice_call: HashMap::new(),
10475            voice_wake: HashMap::new(),
10476            voice_duplex: HashMap::new(),
10477            mqtt: HashMap::new(),
10478            message_timeout_secs: default_channel_message_timeout_secs(),
10479            ack_reactions: true,
10480            show_tool_calls: false,
10481            session_persistence: true,
10482            session_backend: default_session_backend(),
10483            session_ttl_hours: 0,
10484            debounce_ms: 0,
10485        }
10486    }
10487}
10488
10489/// Streaming mode for channels that support progressive message updates.
10490#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
10491#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10492#[serde(rename_all = "lowercase")]
10493pub enum StreamMode {
10494    /// No streaming -- send the complete response as a single message (default).
10495    #[default]
10496    Off,
10497    /// Update a draft message with every flush interval.
10498    Partial,
10499    /// Send the response as multiple separate messages at paragraph boundaries.
10500    #[serde(rename = "multi_message")]
10501    MultiMessage,
10502}
10503
10504fn default_draft_update_interval_ms() -> u64 {
10505    1000
10506}
10507
10508fn default_multi_message_delay_ms() -> u64 {
10509    800
10510}
10511
10512fn default_telegram_approval_timeout_secs() -> u64 {
10513    120
10514}
10515
10516fn default_channel_approval_timeout_secs() -> u64 {
10517    300
10518}
10519
10520fn default_matrix_draft_update_interval_ms() -> u64 {
10521    1500
10522}
10523
10524/// Telegram bot channel configuration.
10525#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10526#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10527#[prefix = "channels.telegram"]
10528pub struct TelegramConfig {
10529    /// Whether this channel is active. The runtime only loads channels whose
10530    /// `enabled = true`. Default: `false` so an operator who pastes a partial
10531    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
10532    /// live before the rest of its config is filled in.
10533    #[serde(default)]
10534    pub enabled: bool,
10535    /// Telegram Bot API token (from @BotFather).
10536    #[secret]
10537    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10538    pub bot_token: String,
10539    /// Streaming mode for progressive response delivery via message edits.
10540    #[serde(default)]
10541    pub stream_mode: StreamMode,
10542    /// Minimum interval (ms) between draft message edits to avoid rate limits.
10543    #[serde(default = "default_draft_update_interval_ms")]
10544    pub draft_update_interval_ms: u64,
10545    /// When true, a newer Telegram message from the same sender in the same chat
10546    /// cancels the in-flight request and starts a fresh response with preserved history.
10547    #[serde(default)]
10548    pub interrupt_on_new_message: bool,
10549    /// When true, only respond to messages that @-mention the bot in groups.
10550    /// Direct messages are always processed.
10551    #[serde(default)]
10552    pub mention_only: bool,
10553    /// Override for the top-level `ack_reactions` setting. When `None`, the
10554    /// channel falls back to `[channels].ack_reactions`. When set
10555    /// explicitly, it takes precedence.
10556    #[serde(default)]
10557    pub ack_reactions: Option<bool>,
10558    /// Per-channel proxy URL (http, https, socks5, socks5h).
10559    /// Overrides the global `[proxy]` setting for this channel only.
10560    #[serde(default)]
10561    pub proxy_url: Option<String>,
10562    /// How long (seconds) to wait for the operator to tap an inline-keyboard
10563    /// button on a tool approval prompt before auto-denying. Default: 120.
10564    #[serde(default = "default_telegram_approval_timeout_secs")]
10565    pub approval_timeout_secs: u64,
10566
10567    /// Tools excluded from this channel's tool spec. When set, these tools
10568    /// are not exposed to the model when responding via this channel.
10569    #[serde(default)]
10570    pub excluded_tools: Vec<String>,
10571
10572    /// Default recipient for daemon/CLI `channel_send` calls.
10573    /// Injected into the agent system prompt so it knows where to deliver
10574    /// outbound messages without asking the user for a target ID.
10575    #[serde(default, skip_serializing_if = "Option::is_none")]
10576    pub default_target: Option<String>,
10577}
10578
10579impl ChannelConfig for TelegramConfig {
10580    fn name() -> &'static str {
10581        "Telegram"
10582    }
10583    fn desc() -> &'static str {
10584        "connect your bot"
10585    }
10586}
10587
10588/// Discord bot channel configuration.
10589#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10590#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10591#[prefix = "channels.discord"]
10592#[allow(clippy::struct_excessive_bools)]
10593pub struct DiscordConfig {
10594    /// Whether this channel is active. The runtime only loads channels whose
10595    /// `enabled = true`. Default: `false` so an operator who pastes a partial
10596    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
10597    /// live before the rest of its config is filled in.
10598    #[serde(default)]
10599    pub enabled: bool,
10600    /// Discord bot token (from Discord Developer Portal).
10601    #[secret]
10602    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10603    pub bot_token: String,
10604    /// Guild (server) IDs to restrict the bot to. Empty = listen across all
10605    /// guilds the bot is invited to. Migrated from the legacy `guild_id`
10606    /// singular field.
10607    #[serde(default)]
10608    pub guild_ids: Vec<String>,
10609    /// Channel IDs to watch. Empty = watch every channel the bot can see.
10610    /// Used by the archive sidecar (when `archive = true`) and by the
10611    /// in-channel filter when set.
10612    #[serde(default)]
10613    pub channel_ids: Vec<String>,
10614    /// When true, the channel opens a sidecar `discord.db` SQLite memory
10615    /// backend, archives every non-bot message it sees, and registers the
10616    /// `discord_search` tool against it. Default: false. Folded in from
10617    /// the legacy `[channels.discord-history]` block.
10618    #[serde(default)]
10619    pub archive: bool,
10620    /// When true, process messages from other bots (not just humans).
10621    /// The bot still ignores its own messages to prevent feedback loops.
10622    #[serde(default)]
10623    pub listen_to_bots: bool,
10624    /// When true, a newer Discord message from the same sender in the same channel
10625    /// cancels the in-flight request and starts a fresh response with preserved history.
10626    #[serde(default)]
10627    pub interrupt_on_new_message: bool,
10628    /// When true, only respond to messages that @-mention the bot.
10629    /// Other messages in the guild are silently ignored.
10630    #[serde(default)]
10631    pub mention_only: bool,
10632    /// Per-channel proxy URL (http, https, socks5, socks5h).
10633    /// Overrides the global `[proxy]` setting for this channel only.
10634    #[serde(default)]
10635    pub proxy_url: Option<String>,
10636    /// Streaming mode for progressive response delivery.
10637    /// `off` (default): single message. `partial`: editable draft updates.
10638    /// `multi_message`: split response into separate messages at paragraph boundaries.
10639    #[serde(default)]
10640    pub stream_mode: StreamMode,
10641    /// Minimum interval (ms) between draft message edits to avoid rate limits.
10642    /// Only used when `stream_mode = "partial"`.
10643    #[serde(default = "default_draft_update_interval_ms")]
10644    pub draft_update_interval_ms: u64,
10645    /// Delay (ms) between sending each message chunk in multi-message mode.
10646    /// Only used when `stream_mode = "multi_message"`.
10647    #[serde(default = "default_multi_message_delay_ms")]
10648    pub multi_message_delay_ms: u64,
10649    /// Stall-watchdog timeout in seconds. When non-zero, the bot will abort
10650    /// and retry if no progress is made within this duration. 0 = disabled.
10651    #[serde(default)]
10652    pub stall_timeout_secs: u64,
10653    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
10654    #[serde(default = "default_channel_approval_timeout_secs")]
10655    pub approval_timeout_secs: u64,
10656
10657    /// Tools excluded from this channel's tool spec. When set, these tools
10658    /// are not exposed to the model when responding via this channel.
10659    #[serde(default)]
10660    pub excluded_tools: Vec<String>,
10661
10662    /// Default recipient for daemon/CLI `channel_send` calls.
10663    /// Injected into the agent system prompt so it knows where to deliver
10664    /// outbound messages without asking the user for a target ID.
10665    #[serde(default, skip_serializing_if = "Option::is_none")]
10666    pub default_target: Option<String>,
10667}
10668
10669impl ChannelConfig for DiscordConfig {
10670    fn name() -> &'static str {
10671        "Discord"
10672    }
10673    fn desc() -> &'static str {
10674        "connect your bot"
10675    }
10676}
10677
10678/// Slack bot channel configuration.
10679#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10680#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10681#[prefix = "channels.slack"]
10682#[allow(clippy::struct_excessive_bools)]
10683pub struct SlackConfig {
10684    /// Whether this channel is active. The runtime only loads channels whose
10685    /// `enabled = true`. Default: `false` so an operator who pastes a partial
10686    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
10687    /// live before the rest of its config is filled in.
10688    #[serde(default)]
10689    pub enabled: bool,
10690    /// Slack bot OAuth token (xoxb-...).
10691    #[secret]
10692    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10693    pub bot_token: String,
10694    /// Slack app-level token for Socket Mode (xapp-...).
10695    #[secret]
10696    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10697    pub app_token: Option<String>,
10698    /// Explicit list of channel IDs to watch.
10699    /// Empty = listen across all accessible channels.
10700    /// Migrated from the legacy `channel_id` singular field.
10701    #[serde(default)]
10702    pub channel_ids: Vec<String>,
10703    /// When true, a newer Slack message from the same sender in the same channel
10704    /// cancels the in-flight request and starts a fresh response with preserved history.
10705    #[serde(default)]
10706    pub interrupt_on_new_message: bool,
10707    /// When true (default), replies stay in the originating Slack thread.
10708    /// When false, replies go to the channel root instead.
10709    #[serde(default)]
10710    pub thread_replies: Option<bool>,
10711    /// When true, only respond to messages that @-mention the bot in groups.
10712    /// Direct messages remain allowed.
10713    #[serde(default)]
10714    pub mention_only: bool,
10715    /// When true (and `mention_only` is also true), messages inside a Slack
10716    /// thread must also @-mention the bot to trigger a response. By default,
10717    /// thread replies are allowed through without a mention so the bot can
10718    /// keep a back-and-forth going without the user repeating @-mentions.
10719    /// Set this to true in channels shared with human discussion where the
10720    /// bot should stay silent unless explicitly addressed.
10721    #[serde(default)]
10722    pub strict_mention_in_thread: bool,
10723    /// Use the newer Slack `markdown` block type (12 000 char limit, richer formatting).
10724    /// Defaults to false (uses universally supported `section` blocks with `mrkdwn`).
10725    /// Enable this only if your Slack workspace supports the `markdown` block type.
10726    #[serde(default)]
10727    pub use_markdown_blocks: bool,
10728    /// Per-channel proxy URL (http, https, socks5, socks5h).
10729    /// Overrides the global `[proxy]` setting for this channel only.
10730    #[serde(default)]
10731    pub proxy_url: Option<String>,
10732    /// Enable progressive draft message streaming via `chat.update`.
10733    #[serde(default)]
10734    pub stream_drafts: bool,
10735    /// Minimum interval (ms) between draft message edits to avoid Slack rate limits.
10736    #[serde(default = "default_slack_draft_update_interval_ms")]
10737    pub draft_update_interval_ms: u64,
10738    /// Emoji reaction name (without colons) that cancels an in-flight request.
10739    /// For example, `"x"` means reacting with `:x:` cancels the task.
10740    /// Leave unset to disable reaction-based cancellation.
10741    #[serde(default)]
10742    pub cancel_reaction: Option<String>,
10743    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
10744    #[serde(default = "default_channel_approval_timeout_secs")]
10745    pub approval_timeout_secs: u64,
10746
10747    /// Tools excluded from this channel's tool spec. When set, these tools
10748    /// are not exposed to the model when responding via this channel.
10749    #[serde(default)]
10750    pub excluded_tools: Vec<String>,
10751
10752    /// Default recipient for daemon/CLI `channel_send` calls.
10753    /// Injected into the agent system prompt so it knows where to deliver
10754    /// outbound messages without asking the user for a target ID.
10755    #[serde(default, skip_serializing_if = "Option::is_none")]
10756    pub default_target: Option<String>,
10757}
10758
10759fn default_slack_draft_update_interval_ms() -> u64 {
10760    1200
10761}
10762
10763impl ChannelConfig for SlackConfig {
10764    fn name() -> &'static str {
10765        "Slack"
10766    }
10767    fn desc() -> &'static str {
10768        "connect your bot"
10769    }
10770}
10771
10772/// Mattermost bot channel configuration.
10773#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10774#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10775#[prefix = "channels.mattermost"]
10776pub struct MattermostConfig {
10777    /// Whether this channel is active. The runtime only loads channels whose
10778    /// `enabled = true`. Default: `false` so an operator who pastes a partial
10779    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
10780    /// live before the rest of its config is filled in.
10781    #[serde(default)]
10782    pub enabled: bool,
10783    /// Mattermost server URL (e.g. `"https://mattermost.example.com"`).
10784    pub url: String,
10785    /// Mattermost bot access token. When unset, the channel falls back to
10786    /// the login flow using `login_id` + `password`.
10787    #[secret]
10788    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10789    #[serde(default)]
10790    pub bot_token: Option<String>,
10791    /// Login ID (email or username) for the password login flow. Used only
10792    /// when `bot_token` is unset; both `login_id` and `password` must be
10793    /// set together.
10794    #[serde(default)]
10795    pub login_id: Option<String>,
10796    /// Account password for the login flow. Used only when `bot_token` is
10797    /// unset; both `login_id` and `password` must be set together.
10798    #[secret]
10799    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10800    #[serde(default)]
10801    pub password: Option<String>,
10802    /// Channel IDs to restrict the bot to. Empty or `["*"]` = auto-discover
10803    /// every channel the bot can read (public, private, DMs, group DMs) and
10804    /// poll them all. Explicit IDs disable discovery and pin the bot to the
10805    /// listed channels only. Migrated from the legacy `channel_id` singular
10806    /// field.
10807    #[serde(default)]
10808    pub channel_ids: Vec<String>,
10809    /// Team IDs to restrict auto-discovery to. Empty = discover across every
10810    /// team the bot belongs to. Non-empty = only discover public/private
10811    /// channels whose `team_id` is in this list. DMs and group DMs (which
10812    /// have no team) are governed by `discover_dms` instead.
10813    #[serde(default)]
10814    pub team_ids: Vec<String>,
10815    /// When true (default), auto-discovery includes DM (`type=D`) and group
10816    /// DM (`type=G`) channels. Set false to restrict the bot to public and
10817    /// private team channels only. Has no effect when `channel_ids` lists
10818    /// explicit IDs. Defaults to `true` at the call site via
10819    /// `discover_dms.unwrap_or(true)`.
10820    #[serde(default)]
10821    pub discover_dms: Option<bool>,
10822    /// When true (default), replies thread on the original post.
10823    /// When false, replies go to the channel root.
10824    #[serde(default)]
10825    pub thread_replies: Option<bool>,
10826    /// When true, only respond to messages that @-mention the bot. Other
10827    /// messages in the channel are silently ignored. DM and group DM
10828    /// channels always bypass this filter: a 1:1 (or small-group) direct
10829    /// conversation has no ambient noise to gate against, so every message
10830    /// is treated as addressed to the bot.
10831    #[serde(default)]
10832    pub mention_only: Option<bool>,
10833    /// When true, a newer Mattermost message from the same sender in the same channel
10834    /// cancels the in-flight request and starts a fresh response with preserved history.
10835    #[serde(default)]
10836    pub interrupt_on_new_message: bool,
10837    /// Per-channel proxy URL (http, https, socks5, socks5h).
10838    /// Overrides the global `[proxy]` setting for this channel only.
10839    #[serde(default)]
10840    pub proxy_url: Option<String>,
10841
10842    /// Tools excluded from this channel's tool spec. When set, these tools
10843    /// are not exposed to the model when responding via this channel.
10844    #[serde(default)]
10845    pub excluded_tools: Vec<String>,
10846
10847    /// Default recipient for daemon/CLI `channel_send` calls.
10848    /// Injected into the agent system prompt so it knows where to deliver
10849    /// outbound messages without asking the user for a target ID.
10850    #[serde(default, skip_serializing_if = "Option::is_none")]
10851    pub default_target: Option<String>,
10852}
10853
10854impl ChannelConfig for MattermostConfig {
10855    fn name() -> &'static str {
10856        "Mattermost"
10857    }
10858    fn desc() -> &'static str {
10859        "connect to your bot"
10860    }
10861}
10862
10863/// Webhook channel configuration.
10864///
10865/// Receives messages via HTTP POST and sends replies to a configurable outbound URL.
10866/// This is the "universal adapter" for any system that supports webhooks.
10867#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10868#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10869#[prefix = "channels.webhook"]
10870pub struct WebhookConfig {
10871    /// Whether this channel is active. The runtime only loads channels whose
10872    /// `enabled = true`. Default: `false` so an operator who pastes a partial
10873    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
10874    /// live before the rest of its config is filled in.
10875    #[serde(default)]
10876    pub enabled: bool,
10877    /// Port to listen on for incoming webhooks.
10878    pub port: u16,
10879    /// URL path to listen on (default: `/webhook`).
10880    #[serde(default)]
10881    pub listen_path: Option<String>,
10882    /// URL to POST/PUT outbound messages to.
10883    #[serde(default)]
10884    pub send_url: Option<String>,
10885    /// HTTP method for outbound messages (`POST` or `PUT`). Default: `POST`.
10886    #[serde(default)]
10887    pub send_method: Option<String>,
10888    /// Optional `Authorization` header value for outbound requests.
10889    #[serde(default)]
10890    #[secret]
10891    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10892    pub auth_header: Option<String>,
10893    /// Optional shared secret for webhook signature verification (HMAC-SHA256).
10894    #[secret]
10895    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10896    pub secret: Option<String>,
10897
10898    /// Tools excluded from this channel's tool spec. When set, these tools
10899    /// are not exposed to the model when responding via this channel.
10900    #[serde(default)]
10901    pub excluded_tools: Vec<String>,
10902
10903    /// Maximum number of retry attempts for outbound sends on transient failures
10904    /// (network errors, 429, 5xx). Set to `0` to disable retries. Default: `3`.
10905    #[serde(default)]
10906    pub max_retries: Option<u32>,
10907    /// Base delay in milliseconds for exponential backoff between retries. Default: `500`.
10908    /// Values below `1` are clamped to `1ms` at runtime to avoid busy-retry loops.
10909    #[serde(default)]
10910    pub retry_base_delay_ms: Option<u64>,
10911    /// Maximum delay cap in milliseconds for any single retry wait. Default: `30000` (30s).
10912    /// Values below `1` are clamped to `1ms` at runtime to avoid busy-retry loops.
10913    #[serde(default)]
10914    pub retry_max_delay_ms: Option<u64>,
10915}
10916
10917impl ChannelConfig for WebhookConfig {
10918    fn name() -> &'static str {
10919        "Webhook"
10920    }
10921    fn desc() -> &'static str {
10922        "HTTP endpoint"
10923    }
10924}
10925
10926/// iMessage channel configuration (macOS only).
10927#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10928#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10929#[prefix = "channels.imessage"]
10930pub struct IMessageConfig {
10931    /// Whether this channel is active. The runtime only loads channels whose
10932    /// `enabled = true`. Default: `false` so an operator who pastes a partial
10933    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
10934    /// live before the rest of its config is filled in.
10935    #[serde(default)]
10936    pub enabled: bool,
10937    /// Tools excluded from this channel's tool spec. When set, these tools
10938    /// are not exposed to the model when responding via this channel.
10939    #[serde(default)]
10940    pub excluded_tools: Vec<String>,
10941}
10942
10943impl ChannelConfig for IMessageConfig {
10944    fn name() -> &'static str {
10945        "iMessage"
10946    }
10947    fn desc() -> &'static str {
10948        "macOS only"
10949    }
10950}
10951
10952/// Matrix channel configuration.
10953#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10954#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10955#[prefix = "channels.matrix"]
10956pub struct MatrixConfig {
10957    /// Whether this channel is active. The runtime only loads channels whose
10958    /// `enabled = true`. Default: `false` so an operator who pastes a partial
10959    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
10960    /// live before the rest of its config is filled in.
10961    #[serde(default)]
10962    pub enabled: bool,
10963    /// Matrix homeserver URL (e.g. `"https://matrix.org"`).
10964    pub homeserver: String,
10965    /// Matrix access token for the bot account. When unset, the channel
10966    /// falls back to password login using `user_id` + `password`.
10967    #[secret]
10968    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10969    #[serde(default)]
10970    pub access_token: Option<String>,
10971    /// Optional Matrix user ID (e.g. `"@bot:matrix.org"`).
10972    #[serde(default)]
10973    pub user_id: Option<String>,
10974    /// Optional Matrix device ID.
10975    #[serde(default)]
10976    pub device_id: Option<String>,
10977    /// Allowed Matrix room IDs or aliases. Empty = allow all rooms.
10978    /// Supports canonical room IDs (`!abc:server`) and aliases (`#room:server`).
10979    #[serde(default)]
10980    pub allowed_rooms: Vec<String>,
10981    /// Whether to interrupt an in-flight agent response when a new message arrives.
10982    #[serde(default)]
10983    pub interrupt_on_new_message: bool,
10984    /// Streaming mode for progressive response delivery.
10985    /// `"off"` (default): single message. `"partial"`: edit-in-place draft.
10986    /// `"multi_message"`: paragraph-split delivery.
10987    #[serde(default)]
10988    pub stream_mode: StreamMode,
10989    /// Minimum interval (ms) between draft message edits in Partial mode.
10990    #[serde(default = "default_matrix_draft_update_interval_ms")]
10991    pub draft_update_interval_ms: u64,
10992    /// Delay (ms) between sending each paragraph in MultiMessage mode.
10993    #[serde(default = "default_multi_message_delay_ms")]
10994    pub multi_message_delay_ms: u64,
10995    /// When true, only respond to messages that @-mention the bot in groups.
10996    /// Direct messages are always processed.
10997    #[serde(default)]
10998    pub mention_only: bool,
10999    /// Optional Matrix recovery key for automatic E2EE key backup restore.
11000    /// When set, ZeroClaw recovers room keys and cross-signing secrets on startup.
11001    #[secret]
11002    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11003    #[serde(default)]
11004    pub recovery_key: Option<String>,
11005    /// Optional login password for Matrix account (used for initial login flow).
11006    #[secret]
11007    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11008    #[serde(default)]
11009    pub password: Option<String>,
11010    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
11011    #[serde(default = "default_channel_approval_timeout_secs")]
11012    pub approval_timeout_secs: u64,
11013    /// When true (default), replies are sent as thread replies. Starts a new thread from the
11014    /// incoming message when none exists. When false, only continues existing threads.
11015    #[serde(default = "default_true")]
11016    pub reply_in_thread: bool,
11017    /// Override for the top-level `[channels].ack_reactions`. When
11018    /// `None`, falls back to the channels-wide default. When set
11019    /// explicitly (`true`/`false`), takes precedence for this Matrix
11020    /// instance only.
11021    #[serde(default)]
11022    pub ack_reactions: Option<bool>,
11023
11024    /// Tools excluded from this channel's tool spec. When set, these tools
11025    /// are not exposed to the model when responding via this channel.
11026    #[serde(default)]
11027    pub excluded_tools: Vec<String>,
11028
11029    /// Default recipient for daemon/CLI `channel_send` calls.
11030    /// Injected into the agent system prompt so it knows where to deliver
11031    /// outbound messages without asking the user for a target ID.
11032    #[serde(default, skip_serializing_if = "Option::is_none")]
11033    pub default_target: Option<String>,
11034}
11035
11036impl ChannelConfig for MatrixConfig {
11037    fn name() -> &'static str {
11038        "Matrix"
11039    }
11040    fn desc() -> &'static str {
11041        "self-hosted chat"
11042    }
11043}
11044
11045#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11046#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11047#[prefix = "channels.signal"]
11048pub struct SignalConfig {
11049    /// Whether this channel is active. The runtime only loads channels whose
11050    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11051    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11052    /// live before the rest of its config is filled in.
11053    #[serde(default)]
11054    pub enabled: bool,
11055    /// Base URL for the signal-cli HTTP daemon (e.g. `"http://127.0.0.1:8686"`).
11056    pub http_url: String,
11057    /// E.164 phone number of the signal-cli account (e.g. "+1234567890").
11058    pub account: String,
11059    /// Group IDs to filter messages. Empty = accept all messages (DMs and
11060    /// groups). When non-empty, only messages from listed groups are
11061    /// accepted (DMs are still accepted unless `dm_only` flips the policy
11062    /// to DMs-only). Migrated from the legacy `group_id` singular field.
11063    #[serde(default)]
11064    pub group_ids: Vec<String>,
11065    /// When true, only accept direct messages and ignore all group traffic.
11066    /// Mutually exclusive with `group_ids` (which is ignored when this is
11067    /// set). Migrated from the legacy `group_id = "dm"` sentinel.
11068    #[serde(default)]
11069    pub dm_only: bool,
11070    /// Skip messages that are attachment-only (no text body).
11071    #[serde(default)]
11072    pub ignore_attachments: bool,
11073    /// Skip incoming story messages.
11074    #[serde(default)]
11075    pub ignore_stories: bool,
11076    /// Per-channel proxy URL (http, https, socks5, socks5h).
11077    /// Overrides the global `[proxy]` setting for this channel only.
11078    #[serde(default)]
11079    pub proxy_url: Option<String>,
11080    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
11081    #[serde(default = "default_channel_approval_timeout_secs")]
11082    pub approval_timeout_secs: u64,
11083
11084    /// Tools excluded from this channel's tool spec. When set, these tools
11085    /// are not exposed to the model when responding via this channel.
11086    #[serde(default)]
11087    pub excluded_tools: Vec<String>,
11088
11089    /// Default recipient for daemon/CLI `channel_send` calls.
11090    /// Injected into the agent system prompt so it knows where to deliver
11091    /// outbound messages without asking the user for a target ID.
11092    #[serde(default, skip_serializing_if = "Option::is_none")]
11093    pub default_target: Option<String>,
11094}
11095
11096impl ChannelConfig for SignalConfig {
11097    fn name() -> &'static str {
11098        "Signal"
11099    }
11100    fn desc() -> &'static str {
11101        "An open-source, encrypted messaging service"
11102    }
11103}
11104
11105/// WhatsApp Web usage mode.
11106///
11107/// `Personal` treats the account as a personal phone — the bot only responds to
11108/// incoming messages that pass the DM/group/self-chat policy filters.
11109/// `Business` (default) responds to all incoming messages, subject only to the
11110/// `allowed_numbers` allowlist.
11111#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
11112#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11113#[serde(rename_all = "snake_case")]
11114pub enum WhatsAppWebMode {
11115    /// Respond to all messages passing the allowlist (default).
11116    #[default]
11117    Business,
11118    /// Apply per-chat-type policies (dm_policy, group_policy, self_chat_mode).
11119    Personal,
11120}
11121
11122/// Policy for a particular WhatsApp chat type (DMs or groups) when
11123/// `mode = "personal"`.
11124#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
11125#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11126#[serde(rename_all = "snake_case")]
11127pub enum WhatsAppChatPolicy {
11128    /// Only respond to senders on the `allowed_numbers` list (default).
11129    #[default]
11130    Allowlist,
11131    /// Ignore all messages in this chat type.
11132    Ignore,
11133    /// Respond to every message regardless of allowlist.
11134    All,
11135}
11136
11137/// WhatsApp channel configuration (Cloud API or Web mode).
11138///
11139/// Set `phone_number_id` for Cloud API mode, or `session_path` for Web mode.
11140#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11141#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11142#[prefix = "channels.whatsapp"]
11143pub struct WhatsAppConfig {
11144    /// Whether this channel is active. The runtime only loads channels whose
11145    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11146    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11147    /// live before the rest of its config is filled in.
11148    #[serde(default)]
11149    pub enabled: bool,
11150    /// Access token from Meta Business Suite (Cloud API mode)
11151    #[serde(default)]
11152    #[secret]
11153    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11154    pub access_token: Option<String>,
11155    /// Phone number ID from Meta Business API (Cloud API mode)
11156    #[serde(default)]
11157    pub phone_number_id: Option<String>,
11158    /// Webhook verify token (you define this, Meta sends it back for verification)
11159    /// Only used in Cloud API mode
11160    #[serde(default)]
11161    #[secret]
11162    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11163    pub verify_token: Option<String>,
11164    /// App secret from Meta Business Suite (for webhook signature verification)
11165    /// Can also be set via `ZEROCLAW_WHATSAPP_APP_SECRET` environment variable
11166    /// Only used in Cloud API mode
11167    #[serde(default)]
11168    #[secret]
11169    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11170    pub app_secret: Option<String>,
11171    /// Session database path for WhatsApp Web client (Web mode)
11172    /// When set, enables native WhatsApp Web mode with wa-rs
11173    #[serde(default)]
11174    pub session_path: Option<String>,
11175    /// Phone number for pair code linking (Web mode, optional)
11176    /// Format: country code + number (e.g., "15551234567")
11177    /// If not set, QR code pairing will be used
11178    #[serde(default)]
11179    pub pair_phone: Option<String>,
11180    /// Custom pair code for linking (Web mode, optional)
11181    /// Leave empty to let WhatsApp generate one
11182    #[serde(default)]
11183    pub pair_code: Option<String>,
11184    /// Override the WhatsApp Web WebSocket URL (Web mode, optional). Used
11185    /// by integration tests and proxy setups; leave unset to use the
11186    /// default endpoint that ships with `wa-rs`.
11187    #[serde(default)]
11188    pub ws_url: Option<String>,
11189    /// When true, only respond to messages that @-mention the bot in groups (Web mode only).
11190    /// Direct messages are always processed.
11191    /// Bot identity is resolved from the wa-rs device at runtime; `pair_phone` seeds it on first connect.
11192    #[serde(default)]
11193    pub mention_only: bool,
11194    /// Usage mode for WhatsApp Web: "business" (default) or "personal".
11195    /// In personal mode the bot applies dm_policy, group_policy, and
11196    /// self_chat_mode to decide which chats to respond in.
11197    #[serde(default)]
11198    pub mode: WhatsAppWebMode,
11199    /// Policy for direct messages when mode = "personal".
11200    /// "allowlist" (default) | "ignore" | "all".
11201    #[serde(default)]
11202    pub dm_policy: WhatsAppChatPolicy,
11203    /// Policy for group chats when mode = "personal".
11204    /// "allowlist" (default) | "ignore" | "all".
11205    #[serde(default)]
11206    pub group_policy: WhatsAppChatPolicy,
11207    /// When true and mode = "personal", always respond to messages in the
11208    /// user's own self-chat (Notes to Self). Defaults to false.
11209    #[serde(default)]
11210    pub self_chat_mode: bool,
11211    /// Regex patterns for DM mention gating (case-insensitive).
11212    /// When non-empty, only direct messages matching at least one pattern are
11213    /// processed; matched fragments are stripped from the forwarded content.
11214    /// Example: `["@?ZeroClaw", "\\+?15555550123"]`
11215    #[serde(default)]
11216    pub dm_mention_patterns: Vec<String>,
11217    /// Regex patterns for group-chat mention gating (case-insensitive).
11218    /// When non-empty, only group messages matching at least one pattern are
11219    /// processed; matched fragments are stripped from the forwarded content.
11220    /// Example: `["@?ZeroClaw", "\\+?15555550123"]`
11221    #[serde(default)]
11222    pub group_mention_patterns: Vec<String>,
11223    /// Per-channel proxy URL (http, https, socks5, socks5h).
11224    /// Overrides the global `[proxy]` setting for this channel only.
11225    #[serde(default)]
11226    pub proxy_url: Option<String>,
11227    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
11228    #[serde(default = "default_channel_approval_timeout_secs")]
11229    pub approval_timeout_secs: u64,
11230
11231    /// Tools excluded from this channel's tool spec. When set, these tools
11232    /// are not exposed to the model when responding via this channel.
11233    #[serde(default)]
11234    pub excluded_tools: Vec<String>,
11235
11236    /// Default recipient for daemon/CLI `channel_send` calls.
11237    /// Injected into the agent system prompt so it knows where to deliver
11238    /// outbound messages without asking the user for a target ID.
11239    #[serde(default, skip_serializing_if = "Option::is_none")]
11240    pub default_target: Option<String>,
11241}
11242
11243impl ChannelConfig for WhatsAppConfig {
11244    fn name() -> &'static str {
11245        "WhatsApp"
11246    }
11247    fn desc() -> &'static str {
11248        "Business Cloud API"
11249    }
11250}
11251
11252#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11253#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11254#[prefix = "channels.linq"]
11255pub struct LinqConfig {
11256    /// Whether this channel is active. The runtime only loads channels whose
11257    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11258    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11259    /// live before the rest of its config is filled in.
11260    #[serde(default)]
11261    pub enabled: bool,
11262    /// Linq Partner API token (Bearer auth)
11263    #[secret]
11264    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11265    pub api_token: String,
11266    /// Phone number to send from (E.164 format)
11267    pub from_phone: String,
11268    /// Webhook signing secret for signature verification
11269    #[serde(default)]
11270    #[secret]
11271    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11272    pub signing_secret: Option<String>,
11273
11274    /// Tools excluded from this channel's tool spec. When set, these tools
11275    /// are not exposed to the model when responding via this channel.
11276    #[serde(default)]
11277    pub excluded_tools: Vec<String>,
11278}
11279
11280impl ChannelConfig for LinqConfig {
11281    fn name() -> &'static str {
11282        "Linq"
11283    }
11284    fn desc() -> &'static str {
11285        "iMessage/RCS/SMS via Linq API"
11286    }
11287}
11288
11289/// WATI WhatsApp Business API channel configuration.
11290#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11291#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11292#[prefix = "channels.wati"]
11293pub struct WatiConfig {
11294    /// Whether this channel is active. The runtime only loads channels whose
11295    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11296    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11297    /// live before the rest of its config is filled in.
11298    #[serde(default)]
11299    pub enabled: bool,
11300    /// WATI API token (Bearer auth).
11301    #[secret]
11302    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11303    pub api_token: String,
11304    /// WATI API base URL (default: <https://live-mt-server.wati.io>).
11305    #[serde(default = "default_wati_api_url")]
11306    pub api_url: String,
11307    /// Tenant ID for multi-channel setups (optional).
11308    #[serde(default)]
11309    pub tenant_id: Option<String>,
11310    /// Per-channel proxy URL (http, https, socks5, socks5h).
11311    /// Overrides the global `[proxy]` setting for this channel only.
11312    #[serde(default)]
11313    pub proxy_url: Option<String>,
11314
11315    /// Tools excluded from this channel's tool spec. When set, these tools
11316    /// are not exposed to the model when responding via this channel.
11317    #[serde(default)]
11318    pub excluded_tools: Vec<String>,
11319}
11320
11321fn default_wati_api_url() -> String {
11322    "https://live-mt-server.wati.io".to_string()
11323}
11324
11325impl ChannelConfig for WatiConfig {
11326    fn name() -> &'static str {
11327        "WATI"
11328    }
11329    fn desc() -> &'static str {
11330        "WhatsApp via WATI Business API"
11331    }
11332}
11333
11334/// Nextcloud Talk bot configuration (webhook receive + OCS send API).
11335#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11336#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11337#[prefix = "channels.nextcloud-talk"]
11338pub struct NextcloudTalkConfig {
11339    /// Whether this channel is active. The runtime only loads channels whose
11340    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11341    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11342    /// live before the rest of its config is filled in.
11343    #[serde(default)]
11344    pub enabled: bool,
11345    /// Nextcloud base URL (e.g. `"https://cloud.example.com"`).
11346    pub base_url: String,
11347    /// Bot app token used for OCS API bearer auth.
11348    #[secret]
11349    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11350    pub app_token: String,
11351    /// Shared secret for webhook signature verification.
11352    ///
11353    /// Can also be set via `ZEROCLAW_NEXTCLOUD_TALK_WEBHOOK_SECRET`.
11354    #[serde(default)]
11355    #[secret]
11356    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11357    pub webhook_secret: Option<String>,
11358    /// Per-channel proxy URL (http, https, socks5, socks5h).
11359    /// Overrides the global `[proxy]` setting for this channel only.
11360    #[serde(default)]
11361    pub proxy_url: Option<String>,
11362    /// Display name of the bot in Nextcloud Talk (e.g. "zeroclaw").
11363    /// Used to filter out the bot's own messages and prevent feedback loops.
11364    /// If not set, defaults to an empty string (no self-message filtering by name).
11365    #[serde(default)]
11366    pub bot_name: Option<String>,
11367    /// Tools excluded from this channel's tool spec. When set, these tools
11368    /// are not exposed to the model when responding via this channel.
11369    #[serde(default)]
11370    pub excluded_tools: Vec<String>,
11371    /// Controls whether and how streaming draft updates are delivered.
11372    ///
11373    /// - `"off"` (default) — responses are sent as a single final message.
11374    /// - `"partial"` — a placeholder is posted first and edited incrementally
11375    ///   as tokens arrive, making long responses visible in real time.
11376    #[serde(default)]
11377    pub stream_mode: StreamMode,
11378    /// Minimum interval in milliseconds between consecutive OCS edit calls per
11379    /// room when `stream_mode = "partial"`. Default: 1000 ms.
11380    #[serde(default = "default_draft_update_interval_ms")]
11381    pub draft_update_interval_ms: u64,
11382}
11383
11384impl ChannelConfig for NextcloudTalkConfig {
11385    fn name() -> &'static str {
11386        "NextCloud Talk"
11387    }
11388    fn desc() -> &'static str {
11389        "NextCloud Talk platform"
11390    }
11391}
11392
11393impl WhatsAppConfig {
11394    /// Detect which backend to use based on config fields.
11395    /// Returns "cloud" if phone_number_id is set, "web" if session_path is set.
11396    pub fn backend_type(&self) -> &'static str {
11397        if self.phone_number_id.is_some() {
11398            "cloud"
11399        } else if self.session_path.is_some() {
11400            "web"
11401        } else {
11402            // Default to Cloud API for backward compatibility
11403            "cloud"
11404        }
11405    }
11406
11407    /// Check if this is a valid Cloud API config
11408    pub fn is_cloud_config(&self) -> bool {
11409        self.phone_number_id.is_some() && self.access_token.is_some() && self.verify_token.is_some()
11410    }
11411
11412    /// Check if this is a valid Web config
11413    pub fn is_web_config(&self) -> bool {
11414        self.session_path.is_some()
11415    }
11416
11417    /// Returns true when both Cloud and Web selectors are present.
11418    ///
11419    /// Runtime currently prefers Cloud mode in this case for backward compatibility.
11420    pub fn is_ambiguous_config(&self) -> bool {
11421        self.phone_number_id.is_some() && self.session_path.is_some()
11422    }
11423}
11424
11425/// MQTT channel configuration (SOP listener).
11426///
11427/// Subscribes to MQTT topics and dispatches incoming messages
11428/// to the SOP engine for processing.
11429#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11430#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11431#[prefix = "channels.mqtt"]
11432pub struct MqttConfig {
11433    /// Whether this channel is active. The runtime only loads channels whose
11434    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11435    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11436    /// live before the rest of its config is filled in.
11437    #[serde(default)]
11438    pub enabled: bool,
11439    /// MQTT broker URL (e.g., `mqtt://localhost:1883` or `mqtts://broker.example.com:8883`).
11440    /// Use `mqtt://` for plain connections or `mqtts://` for TLS.
11441    pub broker_url: String,
11442    /// MQTT client ID (must be unique per broker).
11443    pub client_id: String,
11444    /// Topics to subscribe to (e.g., `sensors/#`, `alerts/+/critical`).
11445    /// At least one topic is required.
11446    #[serde(default)]
11447    pub topics: Vec<String>,
11448    /// MQTT QoS level (0 = at-most-once, 1 = at-least-once, 2 = exactly-once). Default: 1.
11449    #[serde(default = "default_mqtt_qos")]
11450    pub qos: u8,
11451    /// Username for authentication (optional).
11452    pub username: Option<String>,
11453    /// Password for authentication (optional).
11454    #[secret]
11455    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11456    pub password: Option<String>,
11457    /// Enable TLS encryption. Must match the broker_url scheme:
11458    /// - `mqtt://` → `use_tls: false`
11459    /// - `mqtts://` → `use_tls: true`
11460    #[serde(default)]
11461    pub use_tls: bool,
11462    /// Keep-alive interval in seconds (default: 30). Prevents broker disconnect on idle.
11463    #[serde(default = "default_mqtt_keep_alive_secs")]
11464    pub keep_alive_secs: u64,
11465
11466    /// Tools excluded from this channel's tool spec. When set, these tools
11467    /// are not exposed to the model when responding via this channel.
11468    #[serde(default)]
11469    pub excluded_tools: Vec<String>,
11470}
11471
11472impl MqttConfig {
11473    /// Validate the MQTT configuration.
11474    ///
11475    /// Checks:
11476    /// - QoS is 0, 1, or 2
11477    /// - broker_url uses valid scheme (`mqtt://` or `mqtts://`)
11478    /// - `use_tls` flag matches broker_url scheme
11479    /// - At least one topic is configured
11480    /// - client_id is non-empty
11481    pub fn validate(&self) -> anyhow::Result<()> {
11482        // QoS validation
11483        if self.qos > 2 {
11484            anyhow::bail!("qos must be 0, 1, or 2, got {}", self.qos);
11485        }
11486
11487        // Broker URL validation
11488        let is_tls_scheme = self.broker_url.starts_with("mqtts://");
11489        let is_mqtt_scheme = self.broker_url.starts_with("mqtt://");
11490
11491        if !is_tls_scheme && !is_mqtt_scheme {
11492            anyhow::bail!(
11493                "broker_url must start with 'mqtt://' or 'mqtts://', got: {}",
11494                self.broker_url
11495            );
11496        }
11497
11498        // TLS flag validation
11499        if is_mqtt_scheme && self.use_tls {
11500            anyhow::bail!("use_tls is true but broker_url uses 'mqtt://' (not 'mqtts://')");
11501        }
11502
11503        if is_tls_scheme && !self.use_tls {
11504            anyhow::bail!(
11505                "use_tls is false but broker_url uses 'mqtts://' (requires use_tls: true)"
11506            );
11507        }
11508
11509        // Topics validation
11510        if self.topics.is_empty() {
11511            anyhow::bail!("at least one topic must be configured");
11512        }
11513
11514        // Client ID validation
11515        if self.client_id.is_empty() {
11516            validation_bail!(
11517                RequiredFieldEmpty,
11518                "client_id",
11519                "client_id must not be empty"
11520            );
11521        }
11522
11523        Ok(())
11524    }
11525}
11526
11527impl ChannelConfig for MqttConfig {
11528    fn name() -> &'static str {
11529        "MQTT"
11530    }
11531    fn desc() -> &'static str {
11532        "MQTT SOP Listener"
11533    }
11534}
11535
11536fn default_mqtt_qos() -> u8 {
11537    1
11538}
11539
11540fn default_mqtt_keep_alive_secs() -> u64 {
11541    30
11542}
11543
11544/// IRC channel configuration.
11545#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11546#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11547#[prefix = "channels.irc"]
11548pub struct IrcConfig {
11549    /// Whether this channel is active. The runtime only loads channels whose
11550    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11551    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11552    /// live before the rest of its config is filled in.
11553    #[serde(default)]
11554    pub enabled: bool,
11555    /// IRC server hostname
11556    pub server: String,
11557    /// IRC server port (default: 6697 for TLS)
11558    #[serde(default = "default_irc_port")]
11559    pub port: u16,
11560    /// Bot nickname
11561    pub nickname: String,
11562    /// Username (defaults to nickname if not set)
11563    pub username: Option<String>,
11564    /// Channels to join on connect
11565    #[serde(default)]
11566    pub channels: Vec<String>,
11567    /// Server password (for bouncers like ZNC)
11568    #[secret]
11569    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11570    pub server_password: Option<String>,
11571    /// NickServ IDENTIFY password
11572    #[secret]
11573    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11574    pub nickserv_password: Option<String>,
11575    /// SASL PLAIN password (IRCv3)
11576    #[secret]
11577    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11578    pub sasl_password: Option<String>,
11579    /// Verify TLS certificate (default: true)
11580    pub verify_tls: Option<bool>,
11581    /// When true, only respond to messages that mention the bot.
11582    /// Other messages in the channel are silently ignored.
11583    #[serde(default)]
11584    pub mention_only: bool,
11585
11586    /// Tools excluded from this channel's tool spec. When set, these tools
11587    /// are not exposed to the model when responding via this channel.
11588    #[serde(default)]
11589    pub excluded_tools: Vec<String>,
11590
11591    /// Default recipient for daemon/CLI `channel_send` calls.
11592    /// Injected into the agent system prompt so it knows where to deliver
11593    /// outbound messages without asking the user for a target ID.
11594    #[serde(default, skip_serializing_if = "Option::is_none")]
11595    pub default_target: Option<String>,
11596}
11597
11598impl ChannelConfig for IrcConfig {
11599    fn name() -> &'static str {
11600        "IRC"
11601    }
11602    fn desc() -> &'static str {
11603        "IRC over TLS"
11604    }
11605}
11606
11607fn default_irc_port() -> u16 {
11608    6697
11609}
11610
11611/// How ZeroClaw receives events from Feishu / Lark.
11612///
11613/// - `websocket` (default) — persistent WSS long-connection; no public URL required.
11614/// - `webhook`             — HTTP callback server; requires a public HTTPS endpoint.
11615#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
11616#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11617#[serde(rename_all = "lowercase")]
11618pub enum LarkReceiveMode {
11619    #[default]
11620    Websocket,
11621    Webhook,
11622}
11623
11624/// Lark/Feishu configuration for messaging integration.
11625/// Lark is the international version; Feishu is the Chinese version.
11626#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11627#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11628#[prefix = "channels.lark"]
11629pub struct LarkConfig {
11630    /// Whether this channel is active. The runtime only loads channels whose
11631    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11632    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11633    /// live before the rest of its config is filled in.
11634    #[serde(default)]
11635    pub enabled: bool,
11636    /// App ID from Lark/Feishu developer console
11637    pub app_id: String,
11638    /// App Secret from Lark/Feishu developer console
11639    #[secret]
11640    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11641    pub app_secret: String,
11642    /// Encrypt key for webhook message decryption (optional)
11643    #[serde(default)]
11644    #[secret]
11645    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11646    pub encrypt_key: Option<String>,
11647    /// Verification token for webhook validation (optional)
11648    #[serde(default)]
11649    #[secret]
11650    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11651    pub verification_token: Option<String>,
11652    /// When true, only respond to messages that @-mention the bot in groups.
11653    /// Direct messages are always processed.
11654    #[serde(default)]
11655    pub mention_only: bool,
11656    /// Whether to use the Feishu (Chinese) endpoint instead of Lark (International)
11657    #[serde(default)]
11658    pub use_feishu: bool,
11659    /// Event receive mode: "websocket" (default) or "webhook"
11660    #[serde(default)]
11661    pub receive_mode: LarkReceiveMode,
11662    /// HTTP port for webhook mode only. Must be set when receive_mode = "webhook".
11663    /// Not required (and ignored) for websocket mode.
11664    #[serde(default)]
11665    pub port: Option<u16>,
11666    /// Per-channel proxy URL (http, https, socks5, socks5h).
11667    /// Overrides the global `[proxy]` setting for this channel only.
11668    #[serde(default)]
11669    pub proxy_url: Option<String>,
11670
11671    /// Tools excluded from this channel's tool spec. When set, these tools
11672    /// are not exposed to the model when responding via this channel.
11673    #[serde(default)]
11674    pub excluded_tools: Vec<String>,
11675
11676    /// Default recipient for daemon/CLI `channel_send` calls.
11677    /// Injected into the agent system prompt so it knows where to deliver
11678    /// outbound messages without asking the user for a target ID.
11679    #[serde(default, skip_serializing_if = "Option::is_none")]
11680    pub default_target: Option<String>,
11681}
11682
11683impl ChannelConfig for LarkConfig {
11684    fn name() -> &'static str {
11685        "Lark"
11686    }
11687    fn desc() -> &'static str {
11688        "Lark Bot"
11689    }
11690}
11691
11692/// DM (1:1 chat) access policy for the LINE channel.
11693#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
11694#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11695#[serde(rename_all = "lowercase")]
11696pub enum LineDmPolicy {
11697    /// Respond to every DM regardless of who sent it.
11698    Open,
11699    /// Require a one-time `/bind <code>` handshake before responding (default).
11700    /// ZeroClaw prints the bind code on startup; send it once to unlock access.
11701    #[default]
11702    Pairing,
11703    /// Respond only to LINE user IDs listed in `allowed_users`.
11704    Allowlist,
11705}
11706
11707/// Group / multi-person chat policy for the LINE channel.
11708#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
11709#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11710#[serde(rename_all = "lowercase")]
11711pub enum LineGroupPolicy {
11712    /// Respond to every message in group/room chats.
11713    Open,
11714    /// Respond only when the bot is @mentioned (default).
11715    #[default]
11716    Mention,
11717    /// Ignore all messages in group/room chats.
11718    Disabled,
11719}
11720
11721/// LINE Messaging API channel configuration.
11722#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11723#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11724#[prefix = "channels.line"]
11725pub struct LineConfig {
11726    /// Whether this channel is active. The runtime only loads channels whose
11727    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11728    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11729    /// live before the rest of its config is filled in.
11730    #[serde(default)]
11731    pub enabled: bool,
11732    /// Long-lived channel access token (from LINE Developers Console).
11733    /// Used for both the Reply API and the Push API fallback.
11734    /// Falls back to the `LINE_CHANNEL_ACCESS_TOKEN` environment variable if empty.
11735    #[serde(default)]
11736    #[secret]
11737    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11738    pub channel_access_token: String,
11739    /// Channel secret (from LINE Developers Console).
11740    /// Used to verify the `X-Line-Signature` header on incoming webhooks.
11741    /// Falls back to the `LINE_CHANNEL_SECRET` environment variable if empty.
11742    #[serde(default)]
11743    #[secret]
11744    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11745    pub channel_secret: String,
11746    /// DM (1:1 chat) access policy. Default: `pairing`.
11747    ///
11748    /// - `open`      — respond to everyone
11749    /// - `pairing`   — require one-time `/bind <code>` handshake on first contact
11750    /// - `allowlist` — respond only to user IDs listed in `allowed_users`
11751    #[serde(default)]
11752    pub dm_policy: LineDmPolicy,
11753    /// Group / multi-person chat policy. Default: `mention`.
11754    ///
11755    /// - `open`     — respond to every message
11756    /// - `mention`  — respond only when @mentioned
11757    /// - `disabled` — ignore all group messages
11758    #[serde(default)]
11759    pub group_policy: LineGroupPolicy,
11760    /// TCP port the embedded webhook server listens on. Default: `8443`.
11761    #[serde(default = "default_line_webhook_port")]
11762    pub webhook_port: u16,
11763    /// Per-channel proxy URL (http, https, socks5, socks5h).
11764    /// Overrides the global `[proxy]` setting for this channel only.
11765    #[serde(default)]
11766    pub proxy_url: Option<String>,
11767
11768    /// Tools excluded from this channel's tool spec. When set, these tools
11769    /// are not exposed to the model when responding via this channel.
11770    #[serde(default)]
11771    pub excluded_tools: Vec<String>,
11772}
11773
11774fn default_line_webhook_port() -> u16 {
11775    8443
11776}
11777
11778impl ChannelConfig for LineConfig {
11779    fn name() -> &'static str {
11780        "LINE"
11781    }
11782    fn desc() -> &'static str {
11783        "connect your LINE bot"
11784    }
11785}
11786
11787// ── Security Config ─────────────────────────────────────────────────
11788
11789/// Security configuration for audit logging, OTP, e-stop, IAM/SSO, and WebAuthn.
11790///
11791/// Sandbox backend and resource limits live on per-agent risk profiles
11792/// (see `RiskProfileConfig::sandbox_*` and `RiskProfileConfig::max_*`); the
11793/// runtime resolves them via `Config::active_risk_profile(agent_alias)`.
11794#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
11795#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11796#[prefix = "security"]
11797pub struct SecurityConfig {
11798    /// Audit logging configuration
11799    #[serde(default)]
11800    #[nested]
11801    pub audit: AuditConfig,
11802
11803    /// OTP gating configuration for sensitive actions/domains.
11804    #[serde(default)]
11805    #[nested]
11806    pub otp: OtpConfig,
11807
11808    /// Emergency-stop state machine configuration.
11809    #[serde(default)]
11810    #[nested]
11811    pub estop: EstopConfig,
11812
11813    /// Nevis IAM integration for SSO/MFA authentication and role-based access.
11814    #[serde(default)]
11815    #[nested]
11816    pub nevis: NevisConfig,
11817
11818    /// WebAuthn / FIDO2 hardware key authentication configuration.
11819    #[serde(default)]
11820    #[nested]
11821    pub webauthn: WebAuthnConfig,
11822}
11823
11824/// WebAuthn / FIDO2 hardware key authentication configuration (`[security.webauthn]`).
11825///
11826/// Enables registration and authentication via hardware security keys
11827/// (YubiKey, SoloKey, etc.) and platform authenticators (Touch ID, Windows Hello).
11828#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
11829#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11830#[prefix = "security.webauthn"]
11831pub struct WebAuthnConfig {
11832    /// Enable WebAuthn authentication. Default: false.
11833    #[serde(default)]
11834    pub enabled: bool,
11835    /// Relying Party ID (domain name, e.g. "example.com"). Default: "localhost".
11836    #[serde(default = "default_webauthn_rp_id")]
11837    pub rp_id: String,
11838    /// Relying Party origin URL (e.g. `"https://example.com"`). Default: `"http://localhost:42617"`.
11839    #[serde(default = "default_webauthn_rp_origin")]
11840    pub rp_origin: String,
11841    /// Relying Party display name. Default: "ZeroClaw".
11842    #[serde(default = "default_webauthn_rp_name")]
11843    pub rp_name: String,
11844}
11845
11846impl Default for WebAuthnConfig {
11847    fn default() -> Self {
11848        Self {
11849            enabled: false,
11850            rp_id: default_webauthn_rp_id(),
11851            rp_origin: default_webauthn_rp_origin(),
11852            rp_name: default_webauthn_rp_name(),
11853        }
11854    }
11855}
11856
11857fn default_webauthn_rp_id() -> String {
11858    "localhost".into()
11859}
11860
11861fn default_webauthn_rp_origin() -> String {
11862    "http://localhost:42617".into()
11863}
11864
11865fn default_webauthn_rp_name() -> String {
11866    "ZeroClaw".into()
11867}
11868
11869/// OTP validation strategy.
11870#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
11871#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11872#[serde(rename_all = "kebab-case")]
11873pub enum OtpMethod {
11874    /// Time-based one-time password (RFC 6238).
11875    #[default]
11876    Totp,
11877    /// Future method for paired-device confirmations.
11878    Pairing,
11879    /// Future method for local CLI challenge prompts.
11880    CliPrompt,
11881}
11882
11883/// Security OTP configuration.
11884#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
11885#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11886#[prefix = "security.otp"]
11887#[serde(deny_unknown_fields)]
11888pub struct OtpConfig {
11889    /// Enable OTP gating. Defaults to disabled for backward compatibility.
11890    #[serde(default)]
11891    pub enabled: bool,
11892
11893    /// OTP method.
11894    #[serde(default)]
11895    pub method: OtpMethod,
11896
11897    /// TOTP time-step in seconds.
11898    #[serde(default = "default_otp_token_ttl_secs")]
11899    pub token_ttl_secs: u64,
11900
11901    /// Reuse window for recently validated OTP codes.
11902    #[serde(default = "default_otp_cache_valid_secs")]
11903    pub cache_valid_secs: u64,
11904
11905    /// Tool/action names gated by OTP.
11906    #[serde(default = "default_otp_gated_actions")]
11907    pub gated_actions: Vec<String>,
11908
11909    /// Explicit domain patterns gated by OTP.
11910    #[serde(default)]
11911    pub gated_domains: Vec<String>,
11912
11913    /// Domain-category presets expanded into `gated_domains`.
11914    #[serde(default)]
11915    pub gated_domain_categories: Vec<String>,
11916
11917    /// Maximum number of OTP challenge attempts before lockout.
11918    #[serde(default = "default_otp_challenge_max_attempts")]
11919    pub challenge_max_attempts: u32,
11920}
11921
11922fn default_otp_token_ttl_secs() -> u64 {
11923    30
11924}
11925
11926fn default_otp_cache_valid_secs() -> u64 {
11927    300
11928}
11929
11930fn default_otp_challenge_max_attempts() -> u32 {
11931    3
11932}
11933
11934fn default_otp_gated_actions() -> Vec<String> {
11935    vec![
11936        "shell".to_string(),
11937        "file_write".to_string(),
11938        "browser_open".to_string(),
11939        "browser".to_string(),
11940        "memory_forget".to_string(),
11941    ]
11942}
11943
11944impl Default for OtpConfig {
11945    fn default() -> Self {
11946        Self {
11947            enabled: false,
11948            method: OtpMethod::Totp,
11949            token_ttl_secs: default_otp_token_ttl_secs(),
11950            cache_valid_secs: default_otp_cache_valid_secs(),
11951            gated_actions: default_otp_gated_actions(),
11952            gated_domains: Vec::new(),
11953            gated_domain_categories: Vec::new(),
11954            challenge_max_attempts: default_otp_challenge_max_attempts(),
11955        }
11956    }
11957}
11958
11959/// Emergency stop configuration.
11960#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
11961#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11962#[prefix = "security.estop"]
11963#[serde(deny_unknown_fields)]
11964pub struct EstopConfig {
11965    /// Enable emergency stop controls.
11966    #[serde(default)]
11967    pub enabled: bool,
11968
11969    /// File path used to persist estop state.
11970    #[serde(default = "default_estop_state_file")]
11971    pub state_file: String,
11972
11973    /// Require a valid OTP before resume operations.
11974    #[serde(default = "default_true")]
11975    pub require_otp_to_resume: bool,
11976}
11977
11978fn default_estop_state_file() -> String {
11979    default_path_under_config_dir("estop-state.json")
11980}
11981
11982impl Default for EstopConfig {
11983    fn default() -> Self {
11984        Self {
11985            enabled: false,
11986            state_file: default_estop_state_file(),
11987            require_otp_to_resume: true,
11988        }
11989    }
11990}
11991
11992/// Nevis IAM integration configuration.
11993///
11994/// When `enabled` is true, ZeroClaw validates incoming requests against a Nevis
11995/// Security Suite instance and maps Nevis roles to tool/workspace permissions.
11996#[derive(Clone, Serialize, Deserialize, Configurable)]
11997#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11998#[prefix = "security.nevis"]
11999#[serde(deny_unknown_fields)]
12000pub struct NevisConfig {
12001    /// Enable Nevis IAM integration. Defaults to false for backward compatibility.
12002    #[serde(default)]
12003    pub enabled: bool,
12004
12005    /// Base URL of the Nevis instance (e.g. `https://nevis.example.com`).
12006    #[serde(default)]
12007    pub instance_url: String,
12008
12009    /// Nevis realm to authenticate against.
12010    #[serde(default = "default_nevis_realm")]
12011    pub realm: String,
12012
12013    /// OAuth2 client ID registered in Nevis.
12014    #[serde(default)]
12015    pub client_id: String,
12016
12017    /// OAuth2 client secret. Encrypted via SecretStore when stored on disk.
12018    #[serde(default)]
12019    #[secret]
12020    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12021    pub client_secret: Option<String>,
12022
12023    /// Token validation strategy: `"local"` (JWKS) or `"remote"` (introspection).
12024    #[serde(default = "default_nevis_token_validation")]
12025    pub token_validation: String,
12026
12027    /// JWKS endpoint URL for local token validation.
12028    #[serde(default)]
12029    pub jwks_url: Option<String>,
12030
12031    /// Nevis role to ZeroClaw permission mappings.
12032    #[serde(default)]
12033    pub role_mapping: Vec<NevisRoleMappingConfig>,
12034
12035    /// Require MFA verification for all Nevis-authenticated requests.
12036    #[serde(default)]
12037    pub require_mfa: bool,
12038
12039    /// Session timeout in seconds.
12040    #[serde(default = "default_nevis_session_timeout_secs")]
12041    pub session_timeout_secs: u64,
12042}
12043
12044impl std::fmt::Debug for NevisConfig {
12045    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
12046        f.debug_struct("NevisConfig")
12047            .field("enabled", &self.enabled)
12048            .field("instance_url", &self.instance_url)
12049            .field("realm", &self.realm)
12050            .field("client_id", &self.client_id)
12051            .field(
12052                "client_secret",
12053                &self.client_secret.as_ref().map(|_| "[REDACTED]"),
12054            )
12055            .field("token_validation", &self.token_validation)
12056            .field("jwks_url", &self.jwks_url)
12057            .field("role_mapping", &self.role_mapping)
12058            .field("require_mfa", &self.require_mfa)
12059            .field("session_timeout_secs", &self.session_timeout_secs)
12060            .finish()
12061    }
12062}
12063
12064impl NevisConfig {
12065    /// Validate that required fields are present when Nevis is enabled.
12066    ///
12067    /// Call at config load time to fail fast on invalid configuration rather
12068    /// than deferring errors to the first authentication request.
12069    pub fn validate(&self) -> Result<(), String> {
12070        if !self.enabled {
12071            return Ok(());
12072        }
12073
12074        if self.instance_url.trim().is_empty() {
12075            return Err("nevis.instance_url is required when Nevis IAM is enabled".into());
12076        }
12077
12078        if self.client_id.trim().is_empty() {
12079            return Err("nevis.client_id is required when Nevis IAM is enabled".into());
12080        }
12081
12082        if self.realm.trim().is_empty() {
12083            return Err("nevis.realm is required when Nevis IAM is enabled".into());
12084        }
12085
12086        match self.token_validation.as_str() {
12087            "local" | "remote" => {}
12088            other => {
12089                return Err(format!(
12090                    "nevis.token_validation has invalid value '{other}': \
12091                     expected 'local' or 'remote'"
12092                ));
12093            }
12094        }
12095
12096        if self.token_validation == "local" && self.jwks_url.is_none() {
12097            return Err("nevis.jwks_url is required when token_validation is 'local'".into());
12098        }
12099
12100        if self.session_timeout_secs == 0 {
12101            return Err("nevis.session_timeout_secs must be greater than 0".into());
12102        }
12103
12104        Ok(())
12105    }
12106}
12107
12108fn default_nevis_realm() -> String {
12109    "master".into()
12110}
12111
12112fn default_nevis_token_validation() -> String {
12113    "local".into()
12114}
12115
12116fn default_nevis_session_timeout_secs() -> u64 {
12117    3600
12118}
12119
12120impl Default for NevisConfig {
12121    fn default() -> Self {
12122        Self {
12123            enabled: false,
12124            instance_url: String::new(),
12125            realm: default_nevis_realm(),
12126            client_id: String::new(),
12127            client_secret: None,
12128            token_validation: default_nevis_token_validation(),
12129            jwks_url: None,
12130            role_mapping: Vec::new(),
12131            require_mfa: false,
12132            session_timeout_secs: default_nevis_session_timeout_secs(),
12133        }
12134    }
12135}
12136
12137/// Maps a Nevis role to ZeroClaw tool permissions and workspace access.
12138#[derive(Debug, Clone, Serialize, Deserialize)]
12139#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12140#[serde(deny_unknown_fields)]
12141pub struct NevisRoleMappingConfig {
12142    /// Nevis role name (case-insensitive).
12143    pub nevis_role: String,
12144
12145    /// Tool names this role can access. Use `"all"` for unrestricted tool access.
12146    #[serde(default)]
12147    pub zeroclaw_permissions: Vec<String>,
12148
12149    /// Workspace names this role can access. Use `"all"` for unrestricted.
12150    #[serde(default)]
12151    pub workspace_access: Vec<String>,
12152}
12153
12154/// Sandbox configuration for OS-level isolation
12155#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12156#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12157#[prefix = "security.sandbox"]
12158pub struct SandboxConfig {
12159    /// Enable sandboxing (None = auto-detect, Some = explicit)
12160    #[serde(default)]
12161    pub enabled: Option<bool>,
12162
12163    /// Sandbox backend to use
12164    #[serde(default)]
12165    pub backend: SandboxBackend,
12166
12167    /// Custom Firejail arguments (when backend = firejail)
12168    #[serde(default)]
12169    pub firejail_args: Vec<String>,
12170}
12171
12172impl Default for SandboxConfig {
12173    fn default() -> Self {
12174        Self {
12175            enabled: None, // Auto-detect
12176            backend: SandboxBackend::Auto,
12177            firejail_args: Vec::new(),
12178        }
12179    }
12180}
12181
12182/// Sandbox backend selection
12183#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12184#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12185#[serde(rename_all = "lowercase")]
12186pub enum SandboxBackend {
12187    /// Auto-detect best available (default)
12188    #[default]
12189    Auto,
12190    /// Landlock (Linux kernel LSM, native)
12191    Landlock,
12192    /// Firejail (user-space sandbox)
12193    Firejail,
12194    /// Bubblewrap (user namespaces)
12195    Bubblewrap,
12196    /// Docker container isolation
12197    Docker,
12198    /// macOS sandbox-exec (Seatbelt)
12199    #[serde(alias = "sandbox-exec")]
12200    SandboxExec,
12201    /// No sandboxing (application-layer only)
12202    None,
12203}
12204
12205/// Audit logging configuration
12206#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12207#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12208#[prefix = "security.audit"]
12209pub struct AuditConfig {
12210    /// Enable audit logging
12211    #[serde(default = "default_audit_enabled")]
12212    pub enabled: bool,
12213
12214    /// Path to audit log file (relative to zeroclaw dir)
12215    #[serde(default = "default_audit_log_path")]
12216    pub log_path: String,
12217
12218    /// Maximum log size in MB before rotation
12219    #[serde(default = "default_audit_max_size_mb")]
12220    pub max_size_mb: u32,
12221
12222    /// Sign events with HMAC for tamper evidence
12223    #[serde(default)]
12224    pub sign_events: bool,
12225}
12226
12227fn default_audit_enabled() -> bool {
12228    true
12229}
12230
12231fn default_audit_log_path() -> String {
12232    "audit.log".to_string()
12233}
12234
12235fn default_audit_max_size_mb() -> u32 {
12236    100
12237}
12238
12239impl Default for AuditConfig {
12240    fn default() -> Self {
12241        Self {
12242            enabled: default_audit_enabled(),
12243            log_path: default_audit_log_path(),
12244            max_size_mb: default_audit_max_size_mb(),
12245            sign_events: false,
12246        }
12247    }
12248}
12249
12250/// DingTalk configuration for Stream Mode messaging
12251#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12252#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12253#[prefix = "channels.dingtalk"]
12254pub struct DingTalkConfig {
12255    /// Whether this channel is active. The runtime only loads channels whose
12256    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12257    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12258    /// live before the rest of its config is filled in.
12259    #[serde(default)]
12260    pub enabled: bool,
12261    /// Client ID (AppKey) from DingTalk developer console
12262    pub client_id: String,
12263    /// Client Secret (AppSecret) from DingTalk developer console
12264    #[secret]
12265    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12266    pub client_secret: String,
12267    /// Per-channel proxy URL (http, https, socks5, socks5h).
12268    /// Overrides the global `[proxy]` setting for this channel only.
12269    #[serde(default)]
12270    pub proxy_url: Option<String>,
12271
12272    /// Tools excluded from this channel's tool spec. When set, these tools
12273    /// are not exposed to the model when responding via this channel.
12274    #[serde(default)]
12275    pub excluded_tools: Vec<String>,
12276}
12277
12278impl ChannelConfig for DingTalkConfig {
12279    fn name() -> &'static str {
12280        "DingTalk"
12281    }
12282    fn desc() -> &'static str {
12283        "DingTalk Stream Mode"
12284    }
12285}
12286
12287/// WeCom (WeChat Enterprise) Bot Webhook configuration
12288#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12289#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12290#[prefix = "channels.wecom"]
12291pub struct WeComConfig {
12292    /// Whether this channel is active. The runtime only loads channels whose
12293    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12294    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12295    /// live before the rest of its config is filled in.
12296    #[serde(default)]
12297    pub enabled: bool,
12298    /// Webhook key from WeCom Bot configuration
12299    #[secret]
12300    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12301    pub webhook_key: String,
12302
12303    /// Tools excluded from this channel's tool spec. When set, these tools
12304    /// are not exposed to the model when responding via this channel.
12305    #[serde(default)]
12306    pub excluded_tools: Vec<String>,
12307}
12308
12309impl ChannelConfig for WeComConfig {
12310    fn name() -> &'static str {
12311        "WeCom"
12312    }
12313    fn desc() -> &'static str {
12314        "WeCom Bot Webhook"
12315    }
12316}
12317
12318fn default_wecom_ws_file_retention_days() -> u32 {
12319    7
12320}
12321
12322fn default_wecom_ws_max_file_size_mb() -> u64 {
12323    20
12324}
12325
12326fn default_wecom_ws_stream_mode() -> StreamMode {
12327    StreamMode::Partial
12328}
12329
12330/// WeCom AI Bot WebSocket configuration.
12331///
12332/// This is distinct from webhook-based [`WeComConfig`] and uses the WeCom AI
12333/// Bot long-connection API for inbound messages and active-session replies.
12334#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12335#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12336#[prefix = "channels.wecom_ws"]
12337pub struct WeComWsConfig {
12338    /// Whether this channel is active. The runtime only loads channels whose
12339    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12340    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12341    /// live before the rest of its config is filled in.
12342    #[serde(default)]
12343    pub enabled: bool,
12344    /// Bot ID for WeCom WebSocket subscription.
12345    pub bot_id: String,
12346    /// Secret for WeCom WebSocket subscription authentication.
12347    #[secret]
12348    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12349    pub secret: String,
12350    /// Allowed WeCom user IDs. Empty = deny all, "*" = allow all users.
12351    #[serde(default)]
12352    pub allowed_users: Vec<String>,
12353    /// Allowed WeCom group chat IDs. Empty = deny all groups, "*" = allow all groups.
12354    #[serde(default)]
12355    pub allowed_groups: Vec<String>,
12356    /// Display name or mention alias of the WeCom AI bot, for example `danya`.
12357    ///
12358    /// WeCom group text often arrives as plain text such as `@danya say hi`;
12359    /// passing this name through lets the generic reply-intent precheck
12360    /// recognize that a group message was addressed to the bot.
12361    #[serde(default)]
12362    pub bot_name: Option<String>,
12363    /// File retention days for downloaded WeCom attachments under the workspace cache.
12364    #[serde(default = "default_wecom_ws_file_retention_days")]
12365    pub file_retention_days: u32,
12366    /// Maximum accepted file size in MiB for WeCom attachment download attempts.
12367    #[serde(default = "default_wecom_ws_max_file_size_mb")]
12368    pub max_file_size_mb: u64,
12369    /// Streaming mode for progressive draft delivery over the WeCom long connection.
12370    #[serde(default = "default_wecom_ws_stream_mode")]
12371    pub stream_mode: StreamMode,
12372    /// Optional per-channel proxy override. Falls back to the global proxy config when empty.
12373    #[serde(default)]
12374    pub proxy_url: Option<String>,
12375    /// Tools excluded from this channel's tool spec. When set, these tools
12376    /// are not exposed to the model when responding via this channel.
12377    #[serde(default)]
12378    pub excluded_tools: Vec<String>,
12379}
12380
12381impl Default for WeComWsConfig {
12382    fn default() -> Self {
12383        Self {
12384            enabled: false,
12385            bot_id: String::new(),
12386            secret: String::new(),
12387            allowed_users: Vec::new(),
12388            allowed_groups: Vec::new(),
12389            bot_name: None,
12390            file_retention_days: default_wecom_ws_file_retention_days(),
12391            max_file_size_mb: default_wecom_ws_max_file_size_mb(),
12392            stream_mode: default_wecom_ws_stream_mode(),
12393            proxy_url: None,
12394            excluded_tools: Vec::new(),
12395        }
12396    }
12397}
12398
12399impl ChannelConfig for WeComWsConfig {
12400    fn name() -> &'static str {
12401        "WeCom WebSocket"
12402    }
12403    fn desc() -> &'static str {
12404        "WeCom AI Bot long connection"
12405    }
12406}
12407
12408/// WeChat personal iLink Bot channel configuration.
12409///
12410/// Uses the iLink Bot API (`ilinkai.weixin.qq.com`) with QR-code login.
12411/// The bot token is obtained by scanning a QR code and persisted to disk
12412/// so subsequent restarts do not require re-scanning.
12413#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12414#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12415#[prefix = "channels.wechat"]
12416pub struct WeChatConfig {
12417    /// Whether this channel is active. The runtime only loads channels whose
12418    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12419    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12420    /// live before the rest of its config is filled in.
12421    #[serde(default)]
12422    pub enabled: bool,
12423    /// Override the iLink API base URL. Default: `https://ilinkai.weixin.qq.com`.
12424    #[serde(default)]
12425    pub api_base_url: Option<String>,
12426    /// Override the CDN base URL. Default: `https://novac2c.cdn.weixin.qq.com/c2c`.
12427    #[serde(default)]
12428    pub cdn_base_url: Option<String>,
12429    /// Directory to persist bot token and sync cursor.
12430    /// Default: `~/.zeroclaw/wechat/`.
12431    #[serde(default)]
12432    pub state_dir: Option<String>,
12433
12434    /// Tools excluded from this channel's tool spec. When set, these tools
12435    /// are not exposed to the model when responding via this channel.
12436    #[serde(default)]
12437    pub excluded_tools: Vec<String>,
12438}
12439
12440impl ChannelConfig for WeChatConfig {
12441    fn name() -> &'static str {
12442        "WeChat"
12443    }
12444    fn desc() -> &'static str {
12445        "WeChat iLink Bot"
12446    }
12447}
12448
12449/// QQ Official Bot configuration (Tencent QQ Bot SDK)
12450#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12451#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12452#[prefix = "channels.qq"]
12453pub struct QQConfig {
12454    /// Whether this channel is active. The runtime only loads channels whose
12455    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12456    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12457    /// live before the rest of its config is filled in.
12458    #[serde(default)]
12459    pub enabled: bool,
12460    /// App ID from QQ Bot developer console
12461    pub app_id: String,
12462    /// App Secret from QQ Bot developer console
12463    #[secret]
12464    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12465    pub app_secret: String,
12466    /// Per-channel proxy URL (http, https, socks5, socks5h).
12467    /// Overrides the global `[proxy]` setting for this channel only.
12468    #[serde(default)]
12469    pub proxy_url: Option<String>,
12470
12471    /// Tools excluded from this channel's tool spec. When set, these tools
12472    /// are not exposed to the model when responding via this channel.
12473    #[serde(default)]
12474    pub excluded_tools: Vec<String>,
12475}
12476
12477impl ChannelConfig for QQConfig {
12478    fn name() -> &'static str {
12479        "QQ Official"
12480    }
12481    fn desc() -> &'static str {
12482        "Tencent QQ Bot"
12483    }
12484}
12485
12486/// X/Twitter channel configuration (Twitter API v2)
12487#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12488#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12489#[prefix = "channels.twitter"]
12490pub struct TwitterConfig {
12491    /// Whether this channel is active. The runtime only loads channels whose
12492    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12493    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12494    /// live before the rest of its config is filled in.
12495    #[serde(default)]
12496    pub enabled: bool,
12497    /// Twitter API v2 Bearer Token (OAuth 2.0)
12498    #[secret]
12499    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12500    pub bearer_token: String,
12501
12502    /// Tools excluded from this channel's tool spec. When set, these tools
12503    /// are not exposed to the model when responding via this channel.
12504    #[serde(default)]
12505    pub excluded_tools: Vec<String>,
12506}
12507
12508impl ChannelConfig for TwitterConfig {
12509    fn name() -> &'static str {
12510        "X/Twitter"
12511    }
12512    fn desc() -> &'static str {
12513        "X/Twitter Bot via API v2"
12514    }
12515}
12516
12517/// Mochat channel configuration (Mochat customer service API)
12518#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12519#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12520#[prefix = "channels.mochat"]
12521pub struct MochatConfig {
12522    /// Whether this channel is active. The runtime only loads channels whose
12523    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12524    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12525    /// live before the rest of its config is filled in.
12526    #[serde(default)]
12527    pub enabled: bool,
12528    /// Mochat API base URL
12529    pub api_url: String,
12530    /// Mochat API token
12531    #[secret]
12532    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12533    pub api_token: String,
12534    /// Poll interval in seconds for new messages. Default: 5
12535    #[serde(default = "default_mochat_poll_interval")]
12536    pub poll_interval_secs: u64,
12537
12538    /// Tools excluded from this channel's tool spec. When set, these tools
12539    /// are not exposed to the model when responding via this channel.
12540    #[serde(default)]
12541    pub excluded_tools: Vec<String>,
12542}
12543
12544fn default_mochat_poll_interval() -> u64 {
12545    5
12546}
12547
12548impl ChannelConfig for MochatConfig {
12549    fn name() -> &'static str {
12550        "Mochat"
12551    }
12552    fn desc() -> &'static str {
12553        "Mochat Customer Service"
12554    }
12555}
12556
12557/// Reddit channel configuration (OAuth2 bot).
12558#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12559#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12560#[prefix = "channels.reddit"]
12561pub struct RedditConfig {
12562    /// Whether this channel is active. The runtime only loads channels whose
12563    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12564    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12565    /// live before the rest of its config is filled in.
12566    #[serde(default)]
12567    pub enabled: bool,
12568    /// Reddit OAuth2 client ID.
12569    pub client_id: String,
12570    /// Reddit OAuth2 client secret.
12571    #[secret]
12572    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12573    pub client_secret: String,
12574    /// Reddit OAuth2 refresh token for persistent access.
12575    #[secret]
12576    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12577    pub refresh_token: String,
12578    /// Reddit bot username (without `u/` prefix).
12579    pub username: String,
12580    /// Subreddits to filter messages (without `r/` prefix). Empty = accept
12581    /// from any subreddit the bot has access to. Migrated from the legacy
12582    /// `subreddit` singular field.
12583    #[serde(default)]
12584    pub subreddits: Vec<String>,
12585
12586    /// Tools excluded from this channel's tool spec. When set, these tools
12587    /// are not exposed to the model when responding via this channel.
12588    #[serde(default)]
12589    pub excluded_tools: Vec<String>,
12590}
12591
12592impl ChannelConfig for RedditConfig {
12593    fn name() -> &'static str {
12594        "Reddit"
12595    }
12596    fn desc() -> &'static str {
12597        "Reddit bot (OAuth2)"
12598    }
12599}
12600
12601/// Bluesky channel configuration (AT Protocol).
12602#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12603#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12604#[prefix = "channels.bluesky"]
12605pub struct BlueskyConfig {
12606    /// Whether this channel is active. The runtime only loads channels whose
12607    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12608    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12609    /// live before the rest of its config is filled in.
12610    #[serde(default)]
12611    pub enabled: bool,
12612    /// Bluesky handle (e.g. `"mybot.bsky.social"`).
12613    pub handle: String,
12614    /// App-specific password (from Bluesky settings).
12615    #[secret]
12616    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12617    pub app_password: String,
12618
12619    /// Tools excluded from this channel's tool spec. When set, these tools
12620    /// are not exposed to the model when responding via this channel.
12621    #[serde(default)]
12622    pub excluded_tools: Vec<String>,
12623}
12624
12625impl ChannelConfig for BlueskyConfig {
12626    fn name() -> &'static str {
12627        "Bluesky"
12628    }
12629    fn desc() -> &'static str {
12630        "AT Protocol"
12631    }
12632}
12633
12634/// Voice duplex configuration (`[channels.voice_duplex]`).
12635///
12636/// Enables full-duplex voice event handling over WebSocket.
12637/// When disabled (default), voice events are rejected as unknown types.
12638#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
12639#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12640pub struct VoiceDuplexConfig {
12641    /// Whether this channel is active. The runtime only loads channels whose
12642    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12643    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12644    /// live before the rest of its config is filled in.
12645    #[serde(default)]
12646    pub enabled: bool,
12647    /// Tools excluded from this channel's tool spec. When set, these tools
12648    /// are not exposed to the model when responding via this channel.
12649    #[serde(default)]
12650    pub excluded_tools: Vec<String>,
12651}
12652
12653/// Voice wake word detection channel configuration.
12654///
12655/// Listens on the default microphone for a configurable wake word,
12656/// then captures the following utterance and transcribes it via the
12657/// existing transcription API.
12658#[derive(Debug, Clone, Serialize, Deserialize, zeroclaw_macros::Configurable)]
12659#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12660#[prefix = "voice-wake"]
12661pub struct VoiceWakeConfig {
12662    /// Whether this channel is active. The runtime only loads channels whose
12663    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12664    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12665    /// live before the rest of its config is filled in.
12666    #[serde(default)]
12667    pub enabled: bool,
12668    /// Wake word phrase to listen for (case-insensitive substring match).
12669    /// Default: `"hey zeroclaw"`.
12670    #[serde(default = "default_voice_wake_word")]
12671    pub wake_word: String,
12672    /// Silence timeout in milliseconds — how long to wait after the last
12673    /// energy spike before finalizing a capture window. Default: `2000`.
12674    #[serde(default = "default_voice_wake_silence_timeout_ms")]
12675    pub silence_timeout_ms: u32,
12676    /// RMS energy threshold for voice activity detection. Samples below
12677    /// this level are treated as silence. Default: `0.01`.
12678    #[serde(default = "default_voice_wake_energy_threshold")]
12679    pub energy_threshold: f32,
12680    /// Maximum capture duration in seconds before forcing transcription.
12681    /// Default: `30`.
12682    #[serde(default = "default_voice_wake_max_capture_secs")]
12683    pub max_capture_secs: u32,
12684
12685    /// Tools excluded from this channel's tool spec. When set, these tools
12686    /// are not exposed to the model when responding via this channel.
12687    #[serde(default)]
12688    pub excluded_tools: Vec<String>,
12689}
12690
12691fn default_voice_wake_word() -> String {
12692    "hey zeroclaw".into()
12693}
12694
12695fn default_voice_wake_silence_timeout_ms() -> u32 {
12696    2000
12697}
12698
12699fn default_voice_wake_energy_threshold() -> f32 {
12700    0.01
12701}
12702
12703fn default_voice_wake_max_capture_secs() -> u32 {
12704    30
12705}
12706
12707impl Default for VoiceWakeConfig {
12708    fn default() -> Self {
12709        Self {
12710            enabled: false,
12711            wake_word: default_voice_wake_word(),
12712            silence_timeout_ms: default_voice_wake_silence_timeout_ms(),
12713            energy_threshold: default_voice_wake_energy_threshold(),
12714            max_capture_secs: default_voice_wake_max_capture_secs(),
12715            excluded_tools: Vec::new(),
12716        }
12717    }
12718}
12719
12720impl ChannelConfig for VoiceWakeConfig {
12721    fn name() -> &'static str {
12722        "VoiceWake"
12723    }
12724    fn desc() -> &'static str {
12725        "voice wake word detection"
12726    }
12727}
12728
12729/// Nostr channel configuration (NIP-04 + NIP-17 private messages)
12730#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12731#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12732#[prefix = "channels.nostr"]
12733pub struct NostrConfig {
12734    /// Whether this channel is active. The runtime only loads channels whose
12735    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12736    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12737    /// live before the rest of its config is filled in.
12738    #[serde(default)]
12739    pub enabled: bool,
12740    /// Private key in hex or nsec bech32 format
12741    #[secret]
12742    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12743    pub private_key: String,
12744    /// Relay URLs (wss://). Defaults to popular public relays if omitted.
12745    #[serde(default = "default_nostr_relays")]
12746    pub relays: Vec<String>,
12747
12748    /// Tools excluded from this channel's tool spec. When set, these tools
12749    /// are not exposed to the model when responding via this channel.
12750    #[serde(default)]
12751    pub excluded_tools: Vec<String>,
12752}
12753
12754impl ChannelConfig for NostrConfig {
12755    fn name() -> &'static str {
12756        "Nostr"
12757    }
12758    fn desc() -> &'static str {
12759        "Nostr DMs"
12760    }
12761}
12762
12763pub fn default_nostr_relays() -> Vec<String> {
12764    vec![
12765        "wss://relay.damus.io".to_string(),
12766        "wss://nos.lol".to_string(),
12767        "wss://relay.primal.net".to_string(),
12768        "wss://relay.snort.social".to_string(),
12769    ]
12770}
12771
12772// -- Notion --
12773
12774/// Notion integration configuration (`[notion]`).
12775///
12776/// When `enabled = true`, the agent polls a Notion database for pending tasks
12777/// and exposes a `notion` tool for querying, reading, creating, and updating pages.
12778/// Requires `api_key` (or the `NOTION_API_KEY` env var) and `database_id`.
12779#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12780#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12781#[prefix = "notion"]
12782pub struct NotionConfig {
12783    #[serde(default)]
12784    pub enabled: bool,
12785    #[serde(default)]
12786    #[secret]
12787    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12788    pub api_key: String,
12789    #[serde(default)]
12790    pub database_id: String,
12791    #[serde(default = "default_notion_poll_interval")]
12792    pub poll_interval_secs: u64,
12793    #[serde(default = "default_notion_status_prop")]
12794    pub status_property: String,
12795    #[serde(default = "default_notion_input_prop")]
12796    pub input_property: String,
12797    #[serde(default = "default_notion_result_prop")]
12798    pub result_property: String,
12799    #[serde(default = "default_notion_max_concurrent")]
12800    pub max_concurrent: usize,
12801    #[serde(default = "default_notion_recover_stale")]
12802    pub recover_stale: bool,
12803}
12804
12805fn default_notion_poll_interval() -> u64 {
12806    5
12807}
12808fn default_notion_status_prop() -> String {
12809    "Status".into()
12810}
12811fn default_notion_input_prop() -> String {
12812    "Input".into()
12813}
12814fn default_notion_result_prop() -> String {
12815    "Result".into()
12816}
12817fn default_notion_max_concurrent() -> usize {
12818    4
12819}
12820fn default_notion_recover_stale() -> bool {
12821    true
12822}
12823
12824impl Default for NotionConfig {
12825    fn default() -> Self {
12826        Self {
12827            enabled: false,
12828            api_key: String::new(),
12829            database_id: String::new(),
12830            poll_interval_secs: default_notion_poll_interval(),
12831            status_property: default_notion_status_prop(),
12832            input_property: default_notion_input_prop(),
12833            result_property: default_notion_result_prop(),
12834            max_concurrent: default_notion_max_concurrent(),
12835            recover_stale: default_notion_recover_stale(),
12836        }
12837    }
12838}
12839
12840/// Jira integration configuration (`[jira]`).
12841///
12842/// When `enabled = true`, registers the `jira` tool which can get tickets,
12843/// search with JQL, and add comments. Requires `base_url` and `api_token`
12844/// (or the `JIRA_API_TOKEN` env var).
12845///
12846/// ## Defaults
12847/// - `enabled`: `false`
12848/// - `allowed_actions`: `["get_ticket"]` — read-only by default.
12849///   Add `"search_tickets"` or `"comment_ticket"` to unlock them.
12850/// - `timeout_secs`: `30`
12851///
12852/// ## Auth
12853/// Jira Cloud uses HTTP Basic auth: `email` + `api_token`.
12854/// Jira Server/Data Center uses Bearer token auth: omit `email` and set
12855/// `api_token` to a personal access token.
12856/// `api_token` is stored encrypted at rest; set it here or via `JIRA_API_TOKEN`.
12857#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12858#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12859#[prefix = "jira"]
12860pub struct JiraConfig {
12861    /// Enable the `jira` tool. Default: `false`.
12862    #[serde(default)]
12863    pub enabled: bool,
12864    /// Atlassian instance base URL, e.g. `https://yourco.atlassian.net`.
12865    #[serde(default)]
12866    pub base_url: String,
12867    /// Jira account email used for Basic auth (Cloud).
12868    /// Omit for Server/DC deployments using Bearer token auth.
12869    /// An empty string (`email = ""`) deserializes as `None`. Configs
12870    /// that round-tripped the empty default to disk would otherwise
12871    /// silently regress to Basic auth with empty username, since the
12872    /// email-required validation was dropped when Server/DC Bearer-token
12873    /// support landed.
12874    #[serde(
12875        default,
12876        skip_serializing_if = "Option::is_none",
12877        deserialize_with = "deserialize_optional_email_skip_empty"
12878    )]
12879    pub email: Option<String>,
12880    /// Jira API token. Encrypted at rest. Falls back to `JIRA_API_TOKEN` env var.
12881    #[serde(default)]
12882    #[secret]
12883    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12884    pub api_token: String,
12885    /// Actions the agent is permitted to call.
12886    /// Valid values: `"get_ticket"`, `"search_tickets"`, `"comment_ticket"`,
12887    /// `"list_projects"`, `"myself"`, `"list_transitions"`,
12888    /// `"transition_ticket"`, `"create_ticket"`.
12889    /// Defaults to `["get_ticket"]` (read-only).
12890    #[serde(default = "default_jira_allowed_actions")]
12891    pub allowed_actions: Vec<String>,
12892    /// Request timeout in seconds. Default: `30`.
12893    #[serde(default = "default_jira_timeout_secs")]
12894    pub timeout_secs: u64,
12895}
12896
12897fn default_jira_allowed_actions() -> Vec<String> {
12898    vec!["get_ticket".to_string()]
12899}
12900
12901fn default_jira_timeout_secs() -> u64 {
12902    30
12903}
12904
12905impl Default for JiraConfig {
12906    fn default() -> Self {
12907        Self {
12908            enabled: false,
12909            base_url: String::new(),
12910            email: None,
12911            api_token: String::new(),
12912            allowed_actions: default_jira_allowed_actions(),
12913            timeout_secs: default_jira_timeout_secs(),
12914        }
12915    }
12916}
12917
12918///
12919/// Controls the read-only cloud transformation analysis tools:
12920/// IaC review, migration assessment, cost analysis, and architecture review.
12921#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12922#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12923#[prefix = "cloud-ops"]
12924pub struct CloudOpsConfig {
12925    /// Enable cloud operations tools. Default: false.
12926    #[serde(default)]
12927    pub enabled: bool,
12928    /// Default cloud model_provider for analysis context. Default: "aws".
12929    #[serde(default = "default_cloud_ops_cloud")]
12930    pub default_cloud: String,
12931    /// Supported cloud model_providers. Default: [`aws`, `azure`, `gcp`].
12932    #[serde(default = "default_cloud_ops_supported_clouds")]
12933    pub supported_clouds: Vec<String>,
12934    /// Supported IaC tools for review. Default: \[`terraform`\].
12935    #[serde(default = "default_cloud_ops_iac_tools")]
12936    pub iac_tools: Vec<String>,
12937    /// Monthly USD threshold to flag cost items. Default: 100.0.
12938    #[serde(default = "default_cloud_ops_cost_threshold")]
12939    pub cost_threshold_monthly_usd: f64,
12940    /// Well-Architected Frameworks to check against. Default: \[`aws-waf`\].
12941    #[serde(default = "default_cloud_ops_waf")]
12942    pub well_architected_frameworks: Vec<String>,
12943}
12944
12945impl Default for CloudOpsConfig {
12946    fn default() -> Self {
12947        Self {
12948            enabled: false,
12949            default_cloud: default_cloud_ops_cloud(),
12950            supported_clouds: default_cloud_ops_supported_clouds(),
12951            iac_tools: default_cloud_ops_iac_tools(),
12952            cost_threshold_monthly_usd: default_cloud_ops_cost_threshold(),
12953            well_architected_frameworks: default_cloud_ops_waf(),
12954        }
12955    }
12956}
12957
12958impl CloudOpsConfig {
12959    pub fn validate(&self) -> Result<()> {
12960        if self.enabled {
12961            if self.default_cloud.trim().is_empty() {
12962                anyhow::bail!(
12963                    "cloud_ops.default_cloud must not be empty when cloud_ops is enabled"
12964                );
12965            }
12966            if self.supported_clouds.is_empty() {
12967                anyhow::bail!(
12968                    "cloud_ops.supported_clouds must not be empty when cloud_ops is enabled"
12969                );
12970            }
12971            for (i, cloud) in self.supported_clouds.iter().enumerate() {
12972                if cloud.trim().is_empty() {
12973                    validation_bail!(
12974                        RequiredFieldEmpty,
12975                        format!("cloud_ops.supported_clouds[{i}]"),
12976                        "cloud_ops.supported_clouds[{i}] must not be empty"
12977                    );
12978                }
12979            }
12980            if !self.supported_clouds.contains(&self.default_cloud) {
12981                anyhow::bail!(
12982                    "cloud_ops.default_cloud '{}' is not in cloud_ops.supported_clouds {:?}",
12983                    self.default_cloud,
12984                    self.supported_clouds
12985                );
12986            }
12987            if self.cost_threshold_monthly_usd < 0.0 {
12988                anyhow::bail!(
12989                    "cloud_ops.cost_threshold_monthly_usd must be non-negative, got {}",
12990                    self.cost_threshold_monthly_usd
12991                );
12992            }
12993            if self.iac_tools.is_empty() {
12994                anyhow::bail!("cloud_ops.iac_tools must not be empty when cloud_ops is enabled");
12995            }
12996        }
12997        Ok(())
12998    }
12999}
13000
13001fn default_cloud_ops_cloud() -> String {
13002    "aws".into()
13003}
13004
13005fn default_cloud_ops_supported_clouds() -> Vec<String> {
13006    vec!["aws".into(), "azure".into(), "gcp".into()]
13007}
13008
13009fn default_cloud_ops_iac_tools() -> Vec<String> {
13010    vec!["terraform".into()]
13011}
13012
13013fn default_cloud_ops_cost_threshold() -> f64 {
13014    100.0
13015}
13016
13017fn default_cloud_ops_waf() -> Vec<String> {
13018    vec!["aws-waf".into()]
13019}
13020
13021// ── Conversational AI ──────────────────────────────────────────────
13022
13023fn default_conversational_ai_language() -> String {
13024    "en".into()
13025}
13026
13027fn default_conversational_ai_supported_languages() -> Vec<String> {
13028    vec!["en".into(), "de".into(), "fr".into(), "it".into()]
13029}
13030
13031fn default_conversational_ai_escalation_threshold() -> f64 {
13032    0.3
13033}
13034
13035fn default_conversational_ai_max_turns() -> usize {
13036    50
13037}
13038
13039fn default_conversational_ai_timeout_secs() -> u64 {
13040    1800
13041}
13042
13043/// Conversational AI agent builder configuration (`[conversational_ai]` section).
13044///
13045/// **Status: Reserved for future use.** This configuration is parsed but not yet
13046/// consumed by the runtime. Setting `enabled = true` will produce a startup warning.
13047#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13048#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13049#[prefix = "conversational-ai"]
13050pub struct ConversationalAiConfig {
13051    /// Enable conversational AI features. Default: false.
13052    #[serde(default)]
13053    pub enabled: bool,
13054    /// Default language for conversations (BCP-47 tag). Default: "en".
13055    #[serde(default = "default_conversational_ai_language")]
13056    pub default_language: String,
13057    /// Supported languages for conversations. Default: [`en`, `de`, `fr`, `it`].
13058    #[serde(default = "default_conversational_ai_supported_languages")]
13059    pub supported_languages: Vec<String>,
13060    /// Automatically detect user language from message content. Default: true.
13061    #[serde(default = "default_true")]
13062    pub auto_detect_language: bool,
13063    /// Intent confidence below this threshold triggers escalation. Default: 0.3.
13064    #[serde(default = "default_conversational_ai_escalation_threshold")]
13065    pub escalation_confidence_threshold: f64,
13066    /// Maximum conversation turns before auto-ending. Default: 50.
13067    #[serde(default = "default_conversational_ai_max_turns")]
13068    pub max_conversation_turns: usize,
13069    /// Conversation timeout in seconds (inactivity). Default: 1800.
13070    #[serde(default = "default_conversational_ai_timeout_secs")]
13071    pub conversation_timeout_secs: u64,
13072    /// Enable conversation analytics tracking. Default: false (privacy-by-default).
13073    #[serde(default)]
13074    pub analytics_enabled: bool,
13075    /// Optional tool name for RAG-based knowledge base lookup during conversations.
13076    #[serde(default)]
13077    pub knowledge_base_tool: Option<String>,
13078}
13079
13080impl ConversationalAiConfig {
13081    /// Returns `true` when the feature is disabled (the default).
13082    ///
13083    /// Used by `#[serde(skip_serializing_if)]` to omit the entire
13084    /// `[conversational_ai]` section from newly-generated config files,
13085    /// avoiding user confusion over an undocumented / experimental section.
13086    pub fn is_disabled(&self) -> bool {
13087        !self.enabled
13088    }
13089}
13090
13091impl Default for ConversationalAiConfig {
13092    fn default() -> Self {
13093        Self {
13094            enabled: false,
13095            default_language: default_conversational_ai_language(),
13096            supported_languages: default_conversational_ai_supported_languages(),
13097            auto_detect_language: true,
13098            escalation_confidence_threshold: default_conversational_ai_escalation_threshold(),
13099            max_conversation_turns: default_conversational_ai_max_turns(),
13100            conversation_timeout_secs: default_conversational_ai_timeout_secs(),
13101            analytics_enabled: false,
13102            knowledge_base_tool: None,
13103        }
13104    }
13105}
13106
13107// ── Security ops config ─────────────────────────────────────────
13108
13109/// Managed Cybersecurity Service (MCSS) dashboard agent configuration (`[security_ops]`).
13110#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13111#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13112#[prefix = "security-ops"]
13113pub struct SecurityOpsConfig {
13114    /// Enable security operations tools.
13115    #[serde(default)]
13116    pub enabled: bool,
13117    /// Directory containing incident response playbook definitions (JSON).
13118    #[serde(default = "default_playbooks_dir")]
13119    pub playbooks_dir: String,
13120    /// Automatically triage incoming alerts without user prompt.
13121    #[serde(default)]
13122    pub auto_triage: bool,
13123    /// Require human approval before executing playbook actions.
13124    #[serde(default = "default_require_approval")]
13125    pub require_approval_for_actions: bool,
13126    /// Maximum severity level that can be auto-remediated without approval.
13127    /// One of: "low", "medium", "high", "critical". Default: "low".
13128    #[serde(default = "default_max_auto_severity")]
13129    pub max_auto_severity: String,
13130    /// Directory for generated security reports.
13131    #[serde(default = "default_report_output_dir")]
13132    pub report_output_dir: String,
13133    /// Optional SIEM webhook URL for alert ingestion.
13134    #[serde(default)]
13135    pub siem_integration: Option<String>,
13136}
13137
13138fn default_playbooks_dir() -> String {
13139    default_path_under_config_dir("playbooks")
13140}
13141
13142fn default_require_approval() -> bool {
13143    true
13144}
13145
13146fn default_max_auto_severity() -> String {
13147    "low".into()
13148}
13149
13150fn default_report_output_dir() -> String {
13151    default_path_under_config_dir("security-reports")
13152}
13153
13154impl Default for SecurityOpsConfig {
13155    fn default() -> Self {
13156        Self {
13157            enabled: false,
13158            playbooks_dir: default_playbooks_dir(),
13159            auto_triage: false,
13160            require_approval_for_actions: true,
13161            max_auto_severity: default_max_auto_severity(),
13162            report_output_dir: default_report_output_dir(),
13163            siem_integration: None,
13164        }
13165    }
13166}
13167
13168// ── Config impl ──────────────────────────────────────────────────
13169
13170impl Default for Config {
13171    fn default() -> Self {
13172        let home =
13173            UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
13174        let zeroclaw_dir = home.join(".zeroclaw");
13175
13176        Self {
13177            data_dir: zeroclaw_dir.join("data"),
13178            config_path: zeroclaw_dir.join("config.toml"),
13179            env_overridden_paths: std::collections::HashSet::new(),
13180            pre_override_snapshots: std::collections::HashMap::new(),
13181            dirty_paths: std::collections::HashSet::new(),
13182            schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
13183            providers: crate::providers::Providers::default(),
13184            model_routes: Vec::new(),
13185            embedding_routes: Vec::new(),
13186            observability: ObservabilityConfig::default(),
13187            trust: crate::scattered_types::TrustConfig::default(),
13188            backup: BackupConfig::default(),
13189            data_retention: DataRetentionConfig::default(),
13190            cloud_ops: CloudOpsConfig::default(),
13191            conversational_ai: ConversationalAiConfig::default(),
13192            security: SecurityConfig::default(),
13193            security_ops: SecurityOpsConfig::default(),
13194            runtime: RuntimeConfig::default(),
13195            reliability: ReliabilityConfig::default(),
13196            scheduler: SchedulerConfig::default(),
13197            pacing: PacingConfig::default(),
13198            skills: SkillsConfig::default(),
13199            pipeline: PipelineConfig::default(),
13200            heartbeat: HeartbeatConfig::default(),
13201            cron: HashMap::new(),
13202            acp: AcpConfig::default(),
13203            channels: ChannelsConfig::default(),
13204            memory: MemoryConfig::default(),
13205            storage: StorageConfig::default(),
13206            tunnel: TunnelConfig::default(),
13207            gateway: GatewayConfig::default(),
13208            composio: ComposioConfig::default(),
13209            microsoft365: Microsoft365Config::default(),
13210            secrets: SecretsConfig::default(),
13211            browser: BrowserConfig::default(),
13212            browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
13213            http_request: HttpRequestConfig::default(),
13214            multimodal: MultimodalConfig::default(),
13215            media_pipeline: MediaPipelineConfig::default(),
13216            web_fetch: WebFetchConfig::default(),
13217            link_enricher: LinkEnricherConfig::default(),
13218            text_browser: TextBrowserConfig::default(),
13219            web_search: WebSearchConfig::default(),
13220            project_intel: ProjectIntelConfig::default(),
13221            google_workspace: GoogleWorkspaceConfig::default(),
13222            proxy: ProxyConfig::default(),
13223            cost: CostConfig::default(),
13224            peripherals: PeripheralsConfig::default(),
13225            delegate: DelegateToolConfig::default(),
13226            agents: HashMap::new(),
13227            risk_profiles: HashMap::new(),
13228            runtime_profiles: HashMap::new(),
13229            skill_bundles: HashMap::new(),
13230            knowledge_bundles: HashMap::new(),
13231            mcp_bundles: HashMap::new(),
13232            peer_groups: HashMap::new(),
13233            hooks: HooksConfig::default(),
13234            hardware: HardwareConfig::default(),
13235            query_classification: QueryClassificationConfig::default(),
13236            transcription: TranscriptionConfig::default(),
13237            tts: TtsConfig::default(),
13238            mcp: McpConfig::default(),
13239            nodes: NodesConfig::default(),
13240            onboard_state: OnboardStateConfig::default(),
13241            notion: NotionConfig::default(),
13242            jira: JiraConfig::default(),
13243            node_transport: NodeTransportConfig::default(),
13244            knowledge: KnowledgeConfig::default(),
13245            linkedin: LinkedInConfig::default(),
13246            image_gen: ImageGenConfig::default(),
13247            file_upload: FileUploadConfig::default(),
13248            file_upload_bundle: FileUploadBundleConfig::default(),
13249            file_download: FileDownloadConfig::default(),
13250            plugins: PluginsConfig::default(),
13251            locale: None,
13252            verifiable_intent: VerifiableIntentConfig::default(),
13253            claude_code: ClaudeCodeConfig::default(),
13254            claude_code_runner: ClaudeCodeRunnerConfig::default(),
13255            codex_cli: CodexCliConfig::default(),
13256            gemini_cli: GeminiCliConfig::default(),
13257            opencode_cli: OpenCodeCliConfig::default(),
13258            sop: SopConfig::default(),
13259            shell_tool: ShellToolConfig::default(),
13260            escalation: EscalationConfig::default(),
13261        }
13262    }
13263}
13264
13265fn default_config_and_data_dirs() -> Result<(PathBuf, PathBuf)> {
13266    let config_dir = default_config_dir()?;
13267    // The second value is the shared instance data directory
13268    // (databases + state files). Per-agent identity + markdown lives
13269    // at `<config-dir>/agents/<alias>/workspace/`, resolved separately
13270    // via `Config::agent_workspace_dir`.
13271    Ok((config_dir.clone(), config_dir.join("data")))
13272}
13273
13274fn default_config_dir() -> Result<PathBuf> {
13275    if let Ok(custom) = std::env::var("ZEROCLAW_CONFIG_DIR") {
13276        let custom = custom.trim();
13277        if !custom.is_empty() {
13278            return Ok(expand_tilde_path(custom));
13279        }
13280    }
13281
13282    if let Ok(home) = std::env::var("HOME")
13283        && !home.is_empty()
13284    {
13285        return Ok(PathBuf::from(home).join(".zeroclaw"));
13286    }
13287
13288    let home = UserDirs::new()
13289        .map(|u| u.home_dir().to_path_buf())
13290        .context("Could not find home directory")?;
13291    Ok(home.join(".zeroclaw"))
13292}
13293
13294/// Build a default path string by joining `relative` onto the resolved
13295/// platform config dir. The form sees the resolved absolute path
13296/// (`/home/<user>/.zeroclaw/<relative>` on Linux,
13297/// `C:\Users\<user>\.zeroclaw\<relative>` on Windows, etc.) instead of a
13298/// literal `~/...` token that doesn't expand on Windows. Falls back to
13299/// `~/.zeroclaw/<relative>` if the platform dir can't be resolved (rare —
13300/// e.g. no HOME and `directories::UserDirs` returns None); the runtime's
13301/// `expand_tilde_path()` handles that literal at use-time.
13302///
13303/// Switching to platform-native config locations (`~/Library/Application
13304/// Support/zeroclaw/` on macOS, `%APPDATA%\zeroclaw\` on Windows) is the
13305/// schema-v3 follow-up tracked in #5947 — that needs a migration to move
13306/// existing users' configs.
13307fn default_path_under_config_dir(relative: &str) -> String {
13308    match default_config_dir() {
13309        Ok(dir) => dir.join(relative).to_string_lossy().into_owned(),
13310        Err(_) => format!("~/.zeroclaw/{relative}"),
13311    }
13312}
13313
13314pub fn resolve_config_dir_for_data(data_dir: &Path) -> (PathBuf, PathBuf) {
13315    let data_config_dir = data_dir.to_path_buf();
13316    if data_config_dir.join("config.toml").exists() {
13317        return (data_config_dir.clone(), data_config_dir.join("data"));
13318    }
13319
13320    let legacy_config_dir = data_dir.parent().map(|parent| parent.join(".zeroclaw"));
13321    if let Some(legacy_dir) = legacy_config_dir {
13322        if legacy_dir.join("config.toml").exists() {
13323            return (legacy_dir, data_config_dir);
13324        }
13325
13326        // Accept either the new "data" suffix or the legacy "workspace"
13327        // suffix; the V2->V3 filesystem migration renames the on-disk
13328        // dir but operator-set env-var paths from before the rename
13329        // still resolve correctly.
13330        if data_dir.file_name().is_some_and(|name| {
13331            name == std::ffi::OsStr::new("data") || name == std::ffi::OsStr::new("workspace")
13332        }) {
13333            return (legacy_dir, data_config_dir);
13334        }
13335    }
13336
13337    (data_config_dir.clone(), data_config_dir.join("data"))
13338}
13339
13340/// Resolve the current runtime config/data directories for onboarding flows.
13341///
13342/// This mirrors the same precedence used by `Config::load_or_init()`:
13343/// `ZEROCLAW_CONFIG_DIR` > `ZEROCLAW_DATA_DIR` > `ZEROCLAW_WORKSPACE`
13344/// (deprecated) > defaults.
13345pub async fn resolve_runtime_dirs_for_onboarding() -> Result<(PathBuf, PathBuf)> {
13346    let (default_zeroclaw_dir, default_data_dir) = default_config_and_data_dirs()?;
13347    let (config_dir, data_dir, _) =
13348        resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_data_dir).await?;
13349    Ok((config_dir, data_dir))
13350}
13351
13352#[derive(Clone, Copy, Debug, Eq, PartialEq)]
13353enum ConfigResolutionSource {
13354    EnvConfigDir,
13355    EnvDataDir,
13356    EnvWorkspaceLegacy,
13357    DefaultConfigDir,
13358    HomebrewConfigDir,
13359}
13360
13361impl ConfigResolutionSource {
13362    const fn as_str(self) -> &'static str {
13363        match self {
13364            Self::EnvConfigDir => "ZEROCLAW_CONFIG_DIR",
13365            Self::EnvDataDir => "ZEROCLAW_DATA_DIR",
13366            Self::EnvWorkspaceLegacy => "ZEROCLAW_WORKSPACE",
13367            Self::DefaultConfigDir => "default",
13368            Self::HomebrewConfigDir => "homebrew",
13369        }
13370    }
13371}
13372
13373/// Expand tilde in paths, falling back to `UserDirs` when HOME is unset.
13374///
13375/// In non-TTY environments (e.g. cron), HOME may not be set, causing
13376/// `shellexpand::tilde` to return the literal `~` unexpanded. This helper
13377/// detects that case and uses `directories::UserDirs` as a fallback.
13378fn expand_tilde_path(path: &str) -> PathBuf {
13379    let expanded = shellexpand::tilde(path);
13380    let expanded_str = expanded.as_ref();
13381
13382    // If the path still starts with '~', tilde expansion failed (HOME unset)
13383    if expanded_str.starts_with('~') {
13384        if let Some(user_dirs) = UserDirs::new() {
13385            let home = user_dirs.home_dir();
13386            // Replace leading ~ with home directory
13387            if let Some(rest) = expanded_str.strip_prefix('~') {
13388                return home.join(rest.trim_start_matches(['/', '\\']));
13389            }
13390        }
13391        // If UserDirs also fails, log a warning and use the literal path
13392        ::zeroclaw_log::record!(
13393            WARN,
13394            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13395                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
13396                .with_attrs(::serde_json::json!({"path": path})),
13397            "Failed to expand tilde: HOME environment variable is not set and UserDirs failed. \
13398             In cron/non-TTY environments, use absolute paths or set HOME explicitly."
13399        );
13400    }
13401
13402    PathBuf::from(expanded_str)
13403}
13404
13405/// Detect if an executable path lives under a macOS Homebrew prefix and return
13406/// the Homebrew-managed config directory.
13407///
13408/// Homebrew can execute ZeroClaw from `<prefix>/Cellar/zeroclaw/<version>/bin/`,
13409/// `<prefix>/bin/`, or `<prefix>/opt/zeroclaw/bin/`.
13410async fn try_resolve_macos_homebrew_config_dir(exe: &Path) -> Option<PathBuf> {
13411    let parts = exe.iter().collect::<Vec<_>>();
13412    let prefix = match parts.as_slice() {
13413        [prefix @ .., cellar, formula, _version, bin, exe_name]
13414            if *cellar == std::ffi::OsStr::new("Cellar")
13415                && *formula == std::ffi::OsStr::new("zeroclaw")
13416                && *bin == std::ffi::OsStr::new("bin")
13417                && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
13418        {
13419            prefix.iter().collect::<PathBuf>()
13420        }
13421        [prefix @ .., opt, formula, bin, exe_name]
13422            if *opt == std::ffi::OsStr::new("opt")
13423                && *formula == std::ffi::OsStr::new("zeroclaw")
13424                && *bin == std::ffi::OsStr::new("bin")
13425                && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
13426        {
13427            let prefix = prefix.iter().collect::<PathBuf>();
13428            if !prefix.as_os_str().is_empty()
13429                && fs::metadata(prefix.join("Cellar"))
13430                    .await
13431                    .is_ok_and(|metadata| metadata.is_dir())
13432            {
13433                prefix
13434            } else {
13435                return None;
13436            }
13437        }
13438        [prefix @ .., bin, exe_name]
13439            if *bin == std::ffi::OsStr::new("bin")
13440                && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
13441        {
13442            let prefix = prefix.iter().collect::<PathBuf>();
13443            if !prefix.as_os_str().is_empty()
13444                && fs::metadata(prefix.join("Cellar"))
13445                    .await
13446                    .is_ok_and(|metadata| metadata.is_dir())
13447            {
13448                prefix
13449            } else {
13450                return None;
13451            }
13452        }
13453        _ => return None,
13454    };
13455    Some(prefix.join("var").join("zeroclaw"))
13456}
13457
13458async fn resolve_runtime_config_dirs(
13459    default_zeroclaw_dir: &Path,
13460    default_data_dir: &Path,
13461) -> Result<(PathBuf, PathBuf, ConfigResolutionSource)> {
13462    if let Ok(custom_config_dir) = std::env::var("ZEROCLAW_CONFIG_DIR") {
13463        let custom_config_dir = custom_config_dir.trim();
13464        if !custom_config_dir.is_empty() {
13465            // If the operator ALSO set ZEROCLAW_DATA_DIR or
13466            // ZEROCLAW_WORKSPACE, CONFIG_DIR wins; surface the
13467            // collision so they know which one took effect.
13468            if std::env::var("ZEROCLAW_DATA_DIR")
13469                .ok()
13470                .filter(|v| !v.trim().is_empty())
13471                .is_some()
13472            {
13473                ::zeroclaw_log::record!(
13474                    WARN,
13475                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13476                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
13477                    "ZEROCLAW_CONFIG_DIR is set; ZEROCLAW_DATA_DIR is ignored \
13478                     (CONFIG_DIR pins both the config directory and the data \
13479                     directory under it)."
13480                );
13481            }
13482            if std::env::var("ZEROCLAW_WORKSPACE")
13483                .ok()
13484                .filter(|v| !v.is_empty())
13485                .is_some()
13486            {
13487                ::zeroclaw_log::record!(
13488                    WARN,
13489                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13490                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
13491                    "ZEROCLAW_CONFIG_DIR is set; ZEROCLAW_WORKSPACE (deprecated) \
13492                     is ignored. ZEROCLAW_WORKSPACE will be removed in a future \
13493                     release; switch any remaining references to ZEROCLAW_DATA_DIR."
13494                );
13495            }
13496            let zeroclaw_dir = expand_tilde_path(custom_config_dir);
13497            return Ok((
13498                zeroclaw_dir.clone(),
13499                zeroclaw_dir.join("data"),
13500                ConfigResolutionSource::EnvConfigDir,
13501            ));
13502        }
13503    }
13504
13505    if let Ok(custom_data) = std::env::var("ZEROCLAW_DATA_DIR")
13506        && !custom_data.trim().is_empty()
13507    {
13508        if std::env::var("ZEROCLAW_WORKSPACE")
13509            .ok()
13510            .filter(|v| !v.is_empty())
13511            .is_some()
13512        {
13513            ::zeroclaw_log::record!(
13514                WARN,
13515                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13516                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
13517                "ZEROCLAW_DATA_DIR and ZEROCLAW_WORKSPACE are both set; \
13518                 ZEROCLAW_WORKSPACE (deprecated) is ignored. \
13519                 ZEROCLAW_WORKSPACE will be removed in a future release."
13520            );
13521        }
13522        let expanded = expand_tilde_path(&custom_data);
13523        let (zeroclaw_dir, data_dir) = resolve_config_dir_for_data(&expanded);
13524        return Ok((zeroclaw_dir, data_dir, ConfigResolutionSource::EnvDataDir));
13525    }
13526
13527    if let Ok(custom_workspace) = std::env::var("ZEROCLAW_WORKSPACE")
13528        && !custom_workspace.is_empty()
13529    {
13530        ::zeroclaw_log::record!(
13531            WARN,
13532            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13533                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
13534            "ZEROCLAW_WORKSPACE is deprecated; use ZEROCLAW_DATA_DIR instead. \
13535             ZEROCLAW_WORKSPACE will be removed in a future release."
13536        );
13537        let expanded = expand_tilde_path(&custom_workspace);
13538        let (zeroclaw_dir, data_dir) = resolve_config_dir_for_data(&expanded);
13539        return Ok((
13540            zeroclaw_dir,
13541            data_dir,
13542            ConfigResolutionSource::EnvWorkspaceLegacy,
13543        ));
13544    }
13545
13546    if cfg!(target_os = "macos")
13547        && let Ok(exe) = std::env::current_exe()
13548        && let Some(homebrew_config_dir) = try_resolve_macos_homebrew_config_dir(&exe).await
13549    {
13550        return Ok((
13551            homebrew_config_dir.clone(),
13552            homebrew_config_dir.join("workspace"),
13553            ConfigResolutionSource::HomebrewConfigDir,
13554        ));
13555    }
13556
13557    Ok((
13558        default_zeroclaw_dir.to_path_buf(),
13559        default_data_dir.to_path_buf(),
13560        ConfigResolutionSource::DefaultConfigDir,
13561    ))
13562}
13563
13564fn config_dir_creation_error(path: &Path) -> String {
13565    format!(
13566        "Failed to create config directory: {}. If running as an OpenRC service, \
13567         ensure this path is writable by user 'zeroclaw'.",
13568        path.display()
13569    )
13570}
13571
13572/// Top-level keys that must always appear in the saved config even
13573/// when their value equals the default. `schema_version` is the
13574/// migration detector's anchor — dropping it from a freshly-saved
13575/// config would make the next load mis-detect the file as V1 (no
13576/// version key = V1).
13577const SAVE_PRESERVE_KEYS: &[&str] = &["schema_version"];
13578
13579/// Insert a blank line before every `[section]` header that doesn't
13580/// already have one, so the serialized TOML reads as discrete blocks
13581/// instead of running every section header directly after the
13582/// previous line (`toml::to_string_pretty` doesn't gap between a
13583/// trailing scalar and the next section header).
13584fn ensure_blank_line_before_sections(toml: &str) -> String {
13585    let mut out = String::with_capacity(toml.len() + 64);
13586    let mut prev_line_blank = true; // start of file counts as blank
13587    for line in toml.lines() {
13588        let is_section_header = line.starts_with('[');
13589        if is_section_header && !prev_line_blank {
13590            out.push('\n');
13591        }
13592        out.push_str(line);
13593        out.push('\n');
13594        prev_line_blank = line.trim().is_empty();
13595    }
13596    out
13597}
13598
13599/// Walk `actual` and drop every key whose value matches the same
13600/// key's value in `defaults`. Tables recurse; the recursion drops a
13601/// sub-table when every one of its keys was itself dropped (i.e. the
13602/// sub-table contained only defaults). Keys that don't appear in
13603/// `defaults` are operator-added and always survive.
13604///
13605/// HashMap-keyed sub-trees (e.g. `agents`, `providers.models.<family>`)
13606/// are not in the typed default tree, so their operator-added aliases
13607/// pass through this filter unchanged.
13608fn prune_default_values(actual: &mut toml::Table, defaults: &toml::Table) {
13609    let keys: Vec<String> = actual.keys().cloned().collect();
13610    for key in keys {
13611        if SAVE_PRESERVE_KEYS.contains(&key.as_str()) {
13612            continue;
13613        }
13614        let Some(default_value) = defaults.get(&key) else {
13615            // Operator added this key; not in the typed default tree.
13616            // Always keep — recursing in would either be a no-op or
13617            // strip operator content.
13618            continue;
13619        };
13620        let Some(child) = actual.remove(&key) else {
13621            continue;
13622        };
13623        let pruned = match (child, default_value) {
13624            (toml::Value::Table(mut child_table), toml::Value::Table(default_subtable)) => {
13625                prune_default_values(&mut child_table, default_subtable);
13626                if child_table.is_empty() {
13627                    None
13628                } else {
13629                    Some(toml::Value::Table(child_table))
13630                }
13631            }
13632            (child, default_value) => {
13633                if &child == default_value {
13634                    None
13635                } else {
13636                    Some(child)
13637                }
13638            }
13639        };
13640        if let Some(value) = pruned {
13641            actual.insert(key, value);
13642        }
13643    }
13644}
13645
13646fn is_local_ollama_endpoint(api_url: Option<&str>) -> bool {
13647    let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
13648        return true;
13649    };
13650
13651    reqwest::Url::parse(raw)
13652        .ok()
13653        .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
13654        .is_some_and(|host| matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1" | "0.0.0.0"))
13655}
13656
13657fn is_official_ollama_cloud_endpoint(api_url: Option<&str>) -> bool {
13658    let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
13659        return false;
13660    };
13661
13662    reqwest::Url::parse(raw)
13663        .ok()
13664        .and_then(|url| {
13665            url.host_str().map(|host| {
13666                host.eq_ignore_ascii_case("ollama.com")
13667                    || host.eq_ignore_ascii_case("api.ollama.com")
13668            })
13669        })
13670        .unwrap_or(false)
13671}
13672
13673fn has_ollama_cloud_credential(config_api_key: Option<&str>) -> bool {
13674    config_api_key
13675        .map(str::trim)
13676        .is_some_and(|value| !value.is_empty())
13677}
13678
13679/// Ensure that essential bootstrap files exist in the workspace directory.
13680///
13681/// When the workspace is created outside of `zeroclaw onboard` (e.g., non-tty
13682/// daemon/cron sessions), these files would otherwise be missing. This function
13683/// creates sensible defaults that allow the agent to operate with a basic identity.
13684pub async fn ensure_bootstrap_files(workspace_dir: &Path) -> Result<()> {
13685    let defaults: &[(&str, &str)] = &[
13686        (
13687            "IDENTITY.md",
13688            "# IDENTITY.md — Who Am I?\n\n\
13689             I am ZeroClaw, an autonomous AI agent.\n\n\
13690             ## Traits\n\
13691             - Helpful, precise, and safety-conscious\n\
13692             - I prioritize clarity and correctness\n",
13693        ),
13694        (
13695            "SOUL.md",
13696            "# SOUL.md — Who You Are\n\n\
13697             You are ZeroClaw, an autonomous AI agent.\n\n\
13698             ## Core Principles\n\
13699             - Be helpful and accurate\n\
13700             - Respect user intent and boundaries\n\
13701             - Ask before taking destructive actions\n\
13702             - Prefer safe, reversible operations\n",
13703        ),
13704    ];
13705
13706    for (filename, content) in defaults {
13707        let path = workspace_dir.join(filename);
13708        if !path.exists() {
13709            fs::write(&path, content)
13710                .await
13711                .with_context(|| format!("Failed to create default {filename} in workspace"))?;
13712        }
13713    }
13714
13715    Ok(())
13716}
13717
13718impl Config {
13719    /// External-peer usernames authorized on `<channel_type>.<alias>`.
13720    ///
13721    /// A `[peer_groups.<name>]` contributes when its `channel` field either
13722    /// matches `channel_type` (type-wide group, applies to every alias of
13723    /// that type) or matches the full dotted `"<channel_type>.<alias>"`
13724    /// (instance-scoped group, applies to that one alias only).
13725    pub fn channel_external_peers(&self, channel_type: &str, alias: &str) -> Vec<String> {
13726        let mut out: Vec<String> = Vec::new();
13727        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
13728        for group in self.peer_groups.values() {
13729            let group_matches = match group.channel.split_once('.') {
13730                Some((ty, al)) => ty == channel_type && al == alias,
13731                None => group.channel == channel_type,
13732            };
13733            if !group_matches {
13734                continue;
13735            }
13736            for peer in &group.external_peers {
13737                let username = peer.as_str().to_string();
13738                if seen.insert(username.clone()) {
13739                    out.push(username);
13740                }
13741            }
13742        }
13743        out
13744    }
13745
13746    /// Collect the `IntegrationDescriptor` from every nested config that
13747    /// declares one via `#[integration(...)]`. Adding a new toggleable
13748    /// integration is one struct-level attribute on the new config + one
13749    /// row in this method. The integrations registry consumes the result
13750    /// without per-vendor branches.
13751    pub fn integration_descriptors(&self) -> Vec<crate::config::IntegrationDescriptor> {
13752        // BrowserConfig and GoogleWorkspaceConfig carry
13753        // `#[integration(...)]` annotations on V3, so the macro emits
13754        // `integration_descriptor()` on each. Cron has been flattened
13755        // to `HashMap<String, CronJobDecl>` with no enable toggle, so
13756        // it gets a hand-crafted descriptor whose `active` reflects
13757        // whether any job is configured. Display copy lives next to
13758        // the field so the registry never branches on a category name.
13759        vec![
13760            self.browser.integration_descriptor(),
13761            self.google_workspace.integration_descriptor(),
13762            crate::config::IntegrationDescriptor {
13763                display_name: "Cron",
13764                description: "Scheduled tasks",
13765                category: "ToolsAutomation",
13766                active: !self.cron.is_empty(),
13767            },
13768        ]
13769    }
13770
13771    /// Return top-level TOML keys in `raw_toml` that Config does not recognise.
13772    ///
13773    /// Keys present in `Config::default()` serialization pass immediately.
13774    /// Remaining keys are probed: the key is deserialized in isolation and
13775    /// the result compared to the default — a changed output means serde
13776    /// consumed it (covers `Option<T>` fields and `#[serde(alias)]` names).
13777    /// V1 legacy keys (consumed by migration) are also accepted.
13778    pub fn unknown_keys(raw_toml: &str) -> Vec<String> {
13779        let raw: toml::Table = match raw_toml.parse() {
13780            Ok(t) => t,
13781            Err(_) => return Vec::new(),
13782        };
13783        static DEFAULTS: OnceLock<toml::Table> = OnceLock::new();
13784        let defaults = DEFAULTS.get_or_init(|| {
13785            toml::to_string(&Config::default())
13786                .ok()
13787                .and_then(|s| s.parse().ok())
13788                .unwrap_or_default()
13789        });
13790        raw.keys()
13791            .filter(|key| {
13792                if defaults.contains_key(key.as_str()) {
13793                    return false;
13794                }
13795                if crate::migration::V1_LEGACY_KEYS.contains(&key.as_str()) {
13796                    return false;
13797                }
13798                let mut t = toml::Table::new();
13799                t.insert((*key).clone(), raw[key.as_str()].clone());
13800                let consumed = toml::to_string(&t)
13801                    .ok()
13802                    .and_then(|s| toml::from_str::<Config>(&s).ok())
13803                    .and_then(|c| toml::to_string(&c).ok())
13804                    .and_then(|s| s.parse::<toml::Table>().ok())
13805                    .is_some_and(|t| t != *defaults);
13806                !consumed
13807            })
13808            .cloned()
13809            .collect()
13810    }
13811
13812    /// Returns `true` if `path` was populated by a `ZEROCLAW_*` env-var
13813    /// override at load time. O(1) HashSet lookup; safe to call per row in
13814    /// list-rendering paths (`config list`, dashboard, onboarding).
13815    pub fn prop_is_env_overridden(&self, path: &str) -> bool {
13816        self.env_overridden_paths.contains(path)
13817    }
13818
13819    pub async fn load_or_init() -> Result<Self> {
13820        let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_data_dirs()?;
13821
13822        // Resolve env overrides FIRST so the migration runs against
13823        // the install root the operator actually uses. Running the
13824        // migration against `default_zeroclaw_dir` would silently skip
13825        // any install reached via `ZEROCLAW_CONFIG_DIR` or
13826        // `ZEROCLAW_WORKSPACE`.
13827        let (zeroclaw_dir, _legacy_workspace_dir, resolution_source) =
13828            resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
13829
13830        // One-time, V<3 → V3 ONLY move of `<install>/workspace/` into
13831        // `<install>/agents/default/workspace/`. The "default" alias is
13832        // the migration bridge — it must NEVER appear on a fresh install
13833        // or on a V3 install that already declared its own aliases.
13834        //
13835        // Gate strictly on the on-disk config's `schema_version`:
13836        // - missing config.toml      → fresh install, skip.
13837        // - schema_version >= 3      → already V3, skip.
13838        // - schema_version 1 or 2    → upgrade in progress, run.
13839        // Anything else (parse failure, weird value) is treated as
13840        // "don't touch the filesystem"; the TOML migrator will surface
13841        // the real error.
13842        let config_toml_path = zeroclaw_dir.join("config.toml");
13843        let needs_fs_migration = config_toml_path.is_file()
13844            && matches!(
13845                std::fs::read_to_string(&config_toml_path)
13846                    .ok()
13847                    .and_then(|raw| toml::from_str::<toml::Value>(&raw).ok())
13848                    .and_then(|v| crate::migration::detect_version(&v).ok()),
13849                Some(v) if v < crate::migration::CURRENT_SCHEMA_VERSION
13850            );
13851        if needs_fs_migration
13852            && let Err(e) = crate::schema::v2::migrate_v2_to_v3_install_filesystem(&zeroclaw_dir)
13853        {
13854            ::zeroclaw_log::record!(
13855                WARN,
13856                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13857                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
13858                    .with_attrs(::serde_json::json!({
13859                        "install": zeroclaw_dir.display().to_string(),
13860                        "error": format!("{}", e),
13861                    })),
13862                "[system] filesystem migration failed; continuing with legacy layout"
13863            );
13864        } else if !needs_fs_migration
13865            && let Err(e) =
13866                crate::schema::v2::relocate_default_agent_skills_to_shared(&zeroclaw_dir)
13867        {
13868            ::zeroclaw_log::record!(
13869                WARN,
13870                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13871                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
13872                    .with_attrs(::serde_json::json!({
13873                        "install": zeroclaw_dir.display().to_string(),
13874                        "error": format!("{}", e),
13875                    })),
13876                "[system] skills relocation to shared workspace failed; continuing"
13877            );
13878        }
13879
13880        let config_path = zeroclaw_dir.join("config.toml");
13881
13882        // The install dir is the only directory `load_or_init` creates
13883        // unconditionally. Per-agent workspaces (`agents/<alias>/workspace/`)
13884        // are seeded lazily at agent-loop entry by
13885        // `Agent::from_config_with_session_cwd_and_mcp`, which runs
13886        // `ensure_bootstrap_files` for the agent it is starting. There
13887        // is no fresh-install "default" agent and therefore no
13888        // `agents/default/workspace/` synthesized at boot; the only
13889        // legitimate origin for that directory is the V1/V2→V3
13890        // legacy-workspace migration above, which fires only when a
13891        // pre-multi-agent install's `<install>/workspace/` is present
13892        // and needs to be moved into the new layout.
13893        //
13894        // `config.data_dir` resolves to `<install>/data/` — the shared
13895        // instance data directory holding databases (memory, sessions,
13896        // cost records) and hygiene/state files. Per-agent identity
13897        // and markdown (MEMORY.md, IDENTITY.md, SOUL.md) lives at
13898        // `Config::agent_workspace_dir(alias)` instead.
13899        let data_dir = zeroclaw_dir.join("data");
13900        fs::create_dir_all(&data_dir).await.with_context(|| {
13901            format!(
13902                "Failed to create data directory: {}",
13903                data_dir.display().to_string()
13904            )
13905        })?;
13906        // Legacy alias retained for clarity in the struct initializer
13907        // and existing field assignments below.
13908        let workspace_dir = data_dir;
13909
13910        // `<install>/shared/` — root workspace shared across every agent
13911        // on the host. Holds skills, skill bundles, and other content
13912        // not scoped to a single agent. Per-agent state still lives at
13913        // `<install>/agents/<alias>/workspace/`.
13914        let shared_dir = zeroclaw_dir.join("shared");
13915        fs::create_dir_all(&shared_dir).await.with_context(|| {
13916            format!(
13917                "Failed to create shared workspace directory: {}",
13918                shared_dir.display()
13919            )
13920        })?;
13921
13922        fs::create_dir_all(&zeroclaw_dir)
13923            .await
13924            .with_context(|| config_dir_creation_error(&zeroclaw_dir))?;
13925
13926        if config_path.exists() {
13927            // Warn if config file is world-readable (may contain API keys)
13928            #[cfg(unix)]
13929            {
13930                use std::os::unix::fs::PermissionsExt;
13931                if let Ok(meta) = fs::metadata(&config_path).await
13932                    && meta.permissions().mode() & 0o004 != 0
13933                {
13934                    ::zeroclaw_log::record!(
13935                        WARN,
13936                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13937                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
13938                        &format!(
13939                            "Config file {:?} is world-readable (mode {:o}). \
13940                             Consider restricting with: chmod 600 {:?}",
13941                            config_path,
13942                            meta.permissions().mode() & 0o777,
13943                            config_path
13944                        )
13945                    );
13946                }
13947            }
13948
13949            let contents = fs::read_to_string(&config_path)
13950                .await
13951                .context("Failed to read config file")?;
13952
13953            // Deserialize the config with the standard TOML parser.
13954            //
13955            // Previously this used `serde_ignored::deserialize` for both
13956            // deserialization and unknown-key detection.  However,
13957            // `serde_ignored` silently drops field values inside nested
13958            // structs that carry `#[serde(default)]` (e.g. the entire
13959            // `[autonomy]` table), causing user-supplied values to be
13960            // replaced by defaults.
13961            //
13962            // We now deserialize with `toml::from_str` (which is correct)
13963            // and run `serde_ignored` separately just for diagnostics.
13964            //
13965            // `migrate_to_current` parses the TOML, detects the schema
13966            // version, runs the typed V1→V2→V3 chain via `V1Config::migrate`
13967            // / `V2Config::migrate`, and deserializes the result into the
13968            // current `Config` shape.
13969            //
13970            // Detect the on-disk version up-front so we can emit one WARN
13971            // line when the daemon auto-migrates an older config in memory:
13972            // the disk file is left untouched and the user is advised to lock
13973            // the migration in with `zeroclaw config migrate`.
13974            let stale_version = toml::from_str::<toml::Value>(&contents)
13975                .ok()
13976                .as_ref()
13977                .and_then(|v| crate::migration::detect_version(v).ok())
13978                .filter(|n| *n != crate::migration::CURRENT_SCHEMA_VERSION);
13979            let mut config: Config = crate::migration::migrate_to_current(&contents)
13980                .context("Failed to migrate config")?;
13981            if let Some(from_version) = stale_version {
13982                ::zeroclaw_log::record!(
13983                    WARN,
13984                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
13985                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
13986                    &format!(
13987                        "Config at {} is schema_version {from_version}; auto-migrated to {} in memory. \
13988                     Run `zeroclaw config migrate` to commit the migration to disk. \
13989                     V0.8.0 also replaced the env-var override grammar; see \
13990                     https://github.com/zeroclaw-labs/zeroclaw/blob/master/docs/book/src/reference/env-vars.md \
13991                     for the migration recipes.",
13992                        config_path.display().to_string(),
13993                        crate::migration::CURRENT_SCHEMA_VERSION
13994                    )
13995                );
13996            }
13997
13998            // Ensure the built-in default auto_approve entries are always
13999            // present on a `risk_profiles.default` entry that already
14000            // exists (typically post-V1/V2→V3 migration). When a user
14001            // specifies `auto_approve` in their TOML (e.g. to add a
14002            // custom tool), serde replaces the default list instead of
14003            // merging — this re-adds the framework defaults so safe
14004            // tools like `weather` and `calculator` keep their
14005            // auto-approve status.
14006            //
14007            // Users who want to require approval for a default tool can
14008            // add it to `always_ask`, which takes precedence over
14009            // `auto_approve` in the approval decision (see approval/mod.rs).
14010            //
14011            // Skipped when the loaded config has no `risk_profiles.default`
14012            // entry: we will not synthesize a `default` alias here. Per
14013            // v0.8.0 rules, `default` is a migration artifact (V1/V2→V3
14014            // single-instance bridge); a config that arrives without it
14015            // is a legitimate multi-aliased shape and must not have one
14016            // injected at load time.
14017            if let Some(default_profile) = config.risk_profiles.get_mut("default") {
14018                default_profile.ensure_default_auto_approve();
14019            }
14020
14021            // Detect unknown top-level config keys by comparing the raw
14022            // TOML table keys against what Config actually deserializes.
14023            // This replaces the previous serde_ignored-based approach which
14024            // had false-positive issues with #[serde(default)] nested structs.
14025            for key in Self::unknown_keys(&contents) {
14026                ::zeroclaw_log::record!(
14027                    WARN,
14028                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14029                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
14030                        .with_attrs(::serde_json::json!({"key": key})),
14031                    "Unknown config key ignored: \"\". Check config.toml for typos or deprecated options."
14032                );
14033            }
14034            // Set computed paths that are skipped during serialization
14035            config.config_path = config_path.clone();
14036            config.data_dir = workspace_dir;
14037
14038            // Ensure each configured skill-bundle's resolved directory
14039            // exists on disk so the bundle has somewhere for skills to
14040            // land immediately. Idempotent.
14041            let install_root = config.install_root_dir();
14042            for alias in config.skill_bundles.keys().cloned().collect::<Vec<_>>() {
14043                if let Ok(dir) =
14044                    crate::skill_bundles::resolve_directory(&config, &install_root, &alias)
14045                    && let Err(e) = std::fs::create_dir_all(&dir)
14046                {
14047                    ::zeroclaw_log::record!(
14048                        WARN,
14049                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14050                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
14051                        &format!(
14052                            "skill-bundle '{alias}' directory creation failed at {}: {e}",
14053                            dir.display().to_string()
14054                        )
14055                    );
14056                }
14057            }
14058
14059            let store = crate::secrets::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt);
14060            // Decrypt all #[secret]-annotated fields via Configurable derive
14061            config.decrypt_secrets(&store)?;
14062
14063            // Apply ZEROCLAW_<lowercase_path> env-var overrides. Hard-errors
14064            // on any unresolvable path — no silent ignores. Tracks overridden
14065            // paths and per-path pre-override snapshots so save() can mask
14066            // env-injected values back to the original on-disk state.
14067            let applied = crate::env_overrides::apply_env_overrides(&mut config)?;
14068            config.env_overridden_paths = applied.paths;
14069            config.pre_override_snapshots = applied.snapshots;
14070
14071            // Validation must NOT prevent the daemon from booting. If
14072            // it did, a single broken agent reference would lock the
14073            // operator out of `/config` — the only place they can fix
14074            // it. Demote to a startup warning; the gateway and dashboard
14075            // still come up so the user can navigate to the bad section
14076            // and repair it.
14077            if let Err(e) = config.validate() {
14078                ::zeroclaw_log::record!(
14079                    WARN,
14080                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14081                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
14082                        .with_attrs(::serde_json::json!({"error": format!("{e:#}")})),
14083                    "[system] config has validation errors — booting anyway so you \
14084                     can fix them via /config or `zeroclaw config set`"
14085                );
14086            }
14087            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"path": config.config_path.display().to_string(), "workspace": config.data_dir.display().to_string(), "source": resolution_source.as_str(), "initialized": true})), "Config loaded");
14088            Ok(config)
14089        } else {
14090            let mut config = Config {
14091                config_path: config_path.clone(),
14092                data_dir: workspace_dir,
14093                ..Config::default()
14094            };
14095            // Save defaults FIRST so env-injected values never reach the
14096            // freshly-created config file. Env overrides apply post-save to
14097            // populate the in-memory Config for the running process.
14098            config.save().await?;
14099
14100            // Restrict permissions on newly created config file (may contain API keys)
14101            #[cfg(unix)]
14102            {
14103                use std::{fs::Permissions, os::unix::fs::PermissionsExt};
14104                let _ = fs::set_permissions(&config_path, Permissions::from_mode(0o600)).await;
14105            }
14106
14107            let applied = crate::env_overrides::apply_env_overrides(&mut config)?;
14108            config.env_overridden_paths = applied.paths;
14109            config.pre_override_snapshots = applied.snapshots;
14110
14111            // Same boot-resilience as the load-existing branch above:
14112            // a fresh-init config can't realistically fail validation,
14113            // but if it does we still want the daemon up.
14114            if let Err(e) = config.validate() {
14115                ::zeroclaw_log::record!(
14116                    WARN,
14117                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14118                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
14119                        .with_attrs(::serde_json::json!({"error": format!("{e:#}")})),
14120                    "[system] freshly-initialized config has validation errors — \
14121                     booting anyway so you can fix them via /config"
14122                );
14123            }
14124            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"path": config.config_path.display().to_string(), "workspace": config.data_dir.display().to_string(), "source": resolution_source.as_str(), "initialized": true})), "Config loaded");
14125            Ok(config)
14126        }
14127    }
14128
14129    /// Collect non-fatal validation warnings — config that loads and
14130    /// validates successfully (`validate()` returns `Ok(())`) but will fail
14131    /// at runtime because of a logical inconsistency the schema cannot
14132    /// enforce structurally.
14133    ///
14134    /// Called by `validate()` (which emits each warning via `tracing::warn!`
14135    /// for log visibility) and by the gateway HTTP API (which returns the
14136    /// structured list in `PropResponse` / `PatchResponse` so dashboard
14137    /// callers see the same signal the CLI sees on stderr).
14138    ///
14139    /// Adding a new warning: append a check here, pick a stable `code`,
14140    /// and document the code in `validation_warnings.rs`.
14141    pub fn collect_warnings(&self) -> Vec<crate::validation_warnings::ValidationWarning> {
14142        Vec::new()
14143    }
14144
14145    /// Validate configuration values that would cause runtime failures.
14146    ///
14147    /// Called after TOML deserialization and env-override application to catch
14148    /// obviously invalid values early instead of failing at arbitrary runtime points.
14149    pub fn validate(&self) -> Result<()> {
14150        // Tunnel — OpenVPN
14151        if self.tunnel.tunnel_provider.trim() == "openvpn" {
14152            let openvpn = self.tunnel.openvpn.as_ref().ok_or_else(|| {
14153                ::zeroclaw_log::record!(
14154                    WARN,
14155                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
14156                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
14157                    "tunnel.tunnel_provider='openvpn' rejected: [tunnel.openvpn] block missing"
14158                );
14159                anyhow::Error::msg("tunnel.tunnel_provider='openvpn' requires [tunnel.openvpn]")
14160            })?;
14161
14162            if openvpn.config_file.trim().is_empty() {
14163                validation_bail!(
14164                    RequiredFieldEmpty,
14165                    "tunnel.openvpn.config_file",
14166                    "tunnel.openvpn.config_file must not be empty"
14167                );
14168            }
14169            if openvpn.connect_timeout_secs == 0 {
14170                validation_bail!(
14171                    InvalidNumericRange,
14172                    "tunnel.openvpn.connect_timeout_secs",
14173                    "tunnel.openvpn.connect_timeout_secs must be greater than 0"
14174                );
14175            }
14176        }
14177
14178        // Gateway
14179        if self.gateway.host.trim().is_empty() {
14180            validation_bail!(
14181                RequiredFieldEmpty,
14182                "gateway.host",
14183                "gateway.host must not be empty"
14184            );
14185        }
14186        // Heartbeat agent: when heartbeat is enabled, the agent field
14187        // must name a configured agent.
14188        if self.heartbeat.enabled {
14189            let hb_agent = self.heartbeat.agent.trim();
14190            if hb_agent.is_empty() {
14191                validation_bail!(
14192                    RequiredFieldEmpty,
14193                    "heartbeat.agent",
14194                    "heartbeat.agent must reference a configured agent when heartbeat.enabled = true"
14195                );
14196            }
14197            if !self.agents.contains_key(hb_agent) {
14198                validation_bail!(
14199                    DanglingReference,
14200                    "heartbeat.agent",
14201                    "heartbeat.agent = {hb_agent:?} but no [agents.{hb_agent}] entry is configured"
14202                );
14203            }
14204        }
14205        if let Some(ref prefix) = self.gateway.path_prefix {
14206            // Validate the raw value — no silent trimming so the stored
14207            // value is exactly what was validated.
14208            if !prefix.is_empty() {
14209                if !prefix.starts_with('/') {
14210                    validation_bail!(
14211                        InvalidFormat,
14212                        "gateway.path_prefix",
14213                        "gateway.path_prefix must start with '/'"
14214                    );
14215                }
14216                if prefix.ends_with('/') {
14217                    validation_bail!(
14218                        InvalidFormat,
14219                        "gateway.path_prefix",
14220                        "gateway.path_prefix must not end with '/' (including bare '/')"
14221                    );
14222                }
14223                // Reject characters unsafe for URL paths or HTML/JS injection.
14224                // Whitespace is intentionally excluded from the allowed set.
14225                if let Some(bad) = prefix.chars().find(|c| {
14226                    !matches!(c, '/' | '-' | '_' | '.' | '~'
14227                        | 'a'..='z' | 'A'..='Z' | '0'..='9'
14228                        | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '='
14229                        | ':' | '@')
14230                }) {
14231                    anyhow::bail!(
14232                        "gateway.path_prefix contains invalid character '{bad}'; \
14233                         only unreserved and sub-delim URI characters are allowed"
14234                    );
14235                }
14236            }
14237        }
14238
14239        // Skill bundles — directories must stay inside `<install>/shared/`
14240        // and no two bundles may resolve to the same directory. Default
14241        // directory and the rules themselves live in
14242        // [`crate::skill_bundles`] so the runtime SkillsService and this
14243        // validator share one implementation.
14244        if !self.skill_bundles.is_empty() {
14245            let install_root = self.install_root_dir();
14246            for alias in self.skill_bundles.keys() {
14247                let dir = crate::skill_bundles::resolve_directory(self, &install_root, alias)
14248                    .map_err(|e| {
14249                        ::zeroclaw_log::record!(
14250                            WARN,
14251                            ::zeroclaw_log::Event::new(
14252                                module_path!(),
14253                                ::zeroclaw_log::Action::Reject
14254                            )
14255                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
14256                            .with_attrs(::serde_json::json!({
14257                                "skill_bundle": alias,
14258                                "error": format!("{}", e),
14259                            })),
14260                            "skill_bundles.<alias>.directory could not be resolved"
14261                        );
14262                        anyhow::Error::msg(e.to_string())
14263                    })?;
14264                if let Err(e) = crate::skill_bundles::validate_directory(&dir, &install_root) {
14265                    validation_bail!(
14266                        InvalidFormat,
14267                        format!("skill-bundles.{alias}.directory"),
14268                        "{e}"
14269                    );
14270                }
14271            }
14272            if let Err(e) = crate::skill_bundles::validate_uniqueness(self, &install_root) {
14273                validation_bail!(InvalidFormat, "skill-bundles", "{e}");
14274            }
14275        }
14276
14277        // Validate every configured risk profile. Each profile stands on
14278        // its own — there is no "active" or "default" risk profile concept;
14279        // an agent's `risk_profile` field names exactly which one applies.
14280        let mut profile_aliases: Vec<&String> = self.risk_profiles.keys().collect();
14281        profile_aliases.sort();
14282        for profile_alias in profile_aliases {
14283            let profile = &self.risk_profiles[profile_alias];
14284            for (i, env_name) in profile.shell_env_passthrough.iter().enumerate() {
14285                if !is_valid_env_var_name(env_name) {
14286                    anyhow::bail!(
14287                        "risk_profiles.{profile_alias}.shell_env_passthrough[{i}] is invalid ({env_name}); expected [A-Za-z_][A-Za-z0-9_]*"
14288                    );
14289                }
14290            }
14291        }
14292
14293        // Security OTP / estop
14294        if self.security.otp.challenge_max_attempts == 0 {
14295            validation_bail!(
14296                InvalidNumericRange,
14297                "security.otp.challenge_max_attempts",
14298                "security.otp.challenge_max_attempts must be greater than 0"
14299            );
14300        }
14301        if self.security.otp.token_ttl_secs == 0 {
14302            validation_bail!(
14303                InvalidNumericRange,
14304                "security.otp.token_ttl_secs",
14305                "security.otp.token_ttl_secs must be greater than 0"
14306            );
14307        }
14308        if self.security.otp.cache_valid_secs == 0 {
14309            validation_bail!(
14310                InvalidNumericRange,
14311                "security.otp.cache_valid_secs",
14312                "security.otp.cache_valid_secs must be greater than 0"
14313            );
14314        }
14315        if self.security.otp.cache_valid_secs < self.security.otp.token_ttl_secs {
14316            anyhow::bail!(
14317                "security.otp.cache_valid_secs must be greater than or equal to security.otp.token_ttl_secs"
14318            );
14319        }
14320        if self.security.otp.challenge_max_attempts == 0 {
14321            validation_bail!(
14322                InvalidNumericRange,
14323                "security.otp.challenge_max_attempts",
14324                "security.otp.challenge_max_attempts must be greater than 0"
14325            );
14326        }
14327        for (i, action) in self.security.otp.gated_actions.iter().enumerate() {
14328            let normalized = action.trim();
14329            if normalized.is_empty() {
14330                validation_bail!(
14331                    RequiredFieldEmpty,
14332                    format!("security.otp.gated_actions[{i}]"),
14333                    "security.otp.gated_actions[{i}] must not be empty"
14334                );
14335            }
14336            if !normalized
14337                .chars()
14338                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
14339            {
14340                anyhow::bail!(
14341                    "security.otp.gated_actions[{i}] contains invalid characters: {normalized}"
14342                );
14343            }
14344        }
14345        DomainMatcher::new(
14346            &self.security.otp.gated_domains,
14347            &self.security.otp.gated_domain_categories,
14348        )
14349        .with_context(
14350            || "Invalid security.otp.gated_domains or security.otp.gated_domain_categories",
14351        )?;
14352        if self.security.estop.state_file.trim().is_empty() {
14353            validation_bail!(
14354                RequiredFieldEmpty,
14355                "security.estop.state_file",
14356                "security.estop.state_file must not be empty"
14357            );
14358        }
14359
14360        // Scheduler
14361        if self.scheduler.max_concurrent == 0 {
14362            validation_bail!(
14363                InvalidNumericRange,
14364                "scheduler.max_concurrent",
14365                "scheduler.max_concurrent must be greater than 0"
14366            );
14367        }
14368        if self.scheduler.max_tasks == 0 {
14369            validation_bail!(
14370                InvalidNumericRange,
14371                "scheduler.max_tasks",
14372                "scheduler.max_tasks must be greater than 0"
14373            );
14374        }
14375
14376        // Model routes
14377        for (i, route) in self.model_routes.iter().enumerate() {
14378            if route.hint.trim().is_empty() {
14379                validation_bail!(
14380                    RequiredFieldEmpty,
14381                    format!("model_routes[{i}].hint"),
14382                    "model_routes[{i}].hint must not be empty"
14383                );
14384            }
14385            let mp = route.model_provider.trim();
14386            if mp.is_empty() {
14387                validation_bail!(
14388                    RequiredFieldEmpty,
14389                    format!("model_routes[{i}].model_provider"),
14390                    "model_routes[{i}].model_provider must not be empty"
14391                );
14392            }
14393            // Route refs are dotted `<type>.<alias>` and must resolve to a
14394            // configured `[model_providers.<type>.<alias>]` entry. Unresolved
14395            // routes are dropped at runtime construction; rejecting them here
14396            // keeps that drift visible at config-load time.
14397            match mp.split_once('.') {
14398                Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
14399                    if self.providers.models.find(ty, inner).is_none() {
14400                        validation_bail!(
14401                            DanglingReference,
14402                            format!("model_routes[{i}].model_provider"),
14403                            "model_routes[{i}].model_provider = {mp:?} but providers.models.{ty}.{inner} is not configured",
14404                        );
14405                    }
14406                }
14407                _ => validation_bail!(
14408                    InvalidFormat,
14409                    format!("model_routes[{i}].model_provider"),
14410                    "model_routes[{i}].model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
14411                ),
14412            }
14413            if route.model.trim().is_empty() {
14414                validation_bail!(
14415                    RequiredFieldEmpty,
14416                    format!("model_routes[{i}].model"),
14417                    "model_routes[{i}].model must not be empty"
14418                );
14419            }
14420        }
14421
14422        // Embedding routes
14423        for (i, route) in self.embedding_routes.iter().enumerate() {
14424            if route.hint.trim().is_empty() {
14425                validation_bail!(
14426                    RequiredFieldEmpty,
14427                    format!("embedding_routes[{i}].hint"),
14428                    "embedding_routes[{i}].hint must not be empty"
14429                );
14430            }
14431            let mp = route.model_provider.trim();
14432            if mp.is_empty() {
14433                validation_bail!(
14434                    RequiredFieldEmpty,
14435                    format!("embedding_routes[{i}].model_provider"),
14436                    "embedding_routes[{i}].model_provider must not be empty"
14437                );
14438            }
14439            // Embedding routes resolve against the same model-provider map;
14440            // there is no separate `providers.embeddings` typed section.
14441            match mp.split_once('.') {
14442                Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
14443                    if self.providers.models.find(ty, inner).is_none() {
14444                        validation_bail!(
14445                            DanglingReference,
14446                            format!("embedding_routes[{i}].model_provider"),
14447                            "embedding_routes[{i}].model_provider = {mp:?} but providers.models.{ty}.{inner} is not configured",
14448                        );
14449                    }
14450                }
14451                _ => validation_bail!(
14452                    InvalidFormat,
14453                    format!("embedding_routes[{i}].model_provider"),
14454                    "embedding_routes[{i}].model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
14455                ),
14456            }
14457            if route.model.trim().is_empty() {
14458                validation_bail!(
14459                    RequiredFieldEmpty,
14460                    format!("embedding_routes[{i}].model"),
14461                    "embedding_routes[{i}].model must not be empty"
14462                );
14463            }
14464        }
14465
14466        for (type_key, alias_key, profile) in self.providers.models.iter_entries() {
14467            let profile_name = format!("{type_key}.{alias_key}");
14468
14469            let has_uri = profile
14470                .uri
14471                .as_deref()
14472                .map(str::trim)
14473                .is_some_and(|value| !value.is_empty());
14474
14475            // Entries created by migration from top-level fields use the
14476            // model_provider type+alias as the map key and may not have
14477            // explicit `uri` (the model_provider factory resolves the
14478            // family's default endpoint via `ModelEndpoint`). An entry
14479            // with no identifying information at all is almost always an
14480            // in-progress onboarding state — the user picked the model
14481            // provider but hasn't filled anything in yet. Warn but don't
14482            // bail; the runtime falls back to family-default endpoint at
14483            // use time, and a chat against the unconfigured model
14484            // provider fails with a clear error then.
14485            let has_api_key = profile
14486                .api_key
14487                .as_deref()
14488                .is_some_and(|v| !v.trim().is_empty());
14489            let has_model = profile
14490                .model
14491                .as_deref()
14492                .is_some_and(|v| !v.trim().is_empty());
14493            if !has_uri && !has_api_key && !has_model {
14494                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"model_provider": profile_name, "profile_name": profile_name})), "providers.models. is empty (no uri / api_key / model). \
14495                     Skipping at runtime; finish onboarding via the dashboard or `zeroclaw onboard` \
14496                     to make this model_provider usable.");
14497                continue;
14498            }
14499
14500            if let Some(uri) = profile.uri.as_deref().map(str::trim)
14501                && !uri.is_empty()
14502            {
14503                let parsed = reqwest::Url::parse(uri).with_context(|| {
14504                    format!("providers.models.{profile_name}.uri is not a valid URL")
14505                })?;
14506                if !matches!(parsed.scheme(), "http" | "https") {
14507                    anyhow::bail!("providers.models.{profile_name}.uri must use http/https");
14508                }
14509            }
14510
14511            if let Some(temp) = profile.temperature {
14512                validate_temperature(temp).map_err(|e| {
14513                    ::zeroclaw_log::record!(
14514                        WARN,
14515                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
14516                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
14517                            .with_attrs(::serde_json::json!({
14518                                "profile": profile_name,
14519                                "temperature": temp,
14520                                "error": format!("{}", e),
14521                            })),
14522                        "providers.models.<alias>.temperature rejected"
14523                    );
14524                    anyhow::Error::msg(format!("providers.models.{profile_name}.temperature: {e}"))
14525                })?;
14526            }
14527
14528            for (key, value) in &profile.pricing {
14529                if value.is_nan() {
14530                    anyhow::bail!(
14531                        "providers.models.{profile_name}.pricing.{key}: value must not be NaN"
14532                    );
14533                }
14534                if *value < 0.0 {
14535                    anyhow::bail!(
14536                        "providers.models.{profile_name}.pricing.{key}: value must be >= 0.0 (got {value})"
14537                    );
14538                }
14539            }
14540        }
14541
14542        // Non-fatal validation warnings: surfaced both via tracing (CLI sees
14543        // on stderr) and via Config::collect_warnings (gateway HTTP returns
14544        // structured to dashboard callers). Single source of truth lives in
14545        // collect_warnings; emit each one to tracing here so the existing
14546        // log behavior is preserved.
14547        for w in self.collect_warnings() {
14548            ::zeroclaw_log::record!(
14549                WARN,
14550                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14551                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
14552                    .with_attrs(::serde_json::json!({"path": w.path, "code": w.code})),
14553                &format!("{}", w.message)
14554            );
14555        }
14556
14557        // Ollama cloud-routing safety checks
14558        for (alias, cfg) in &self.providers.models.ollama {
14559            let entry = &cfg.base;
14560            if !entry
14561                .model
14562                .as_deref()
14563                .is_some_and(|model| model.trim().ends_with(":cloud"))
14564            {
14565                continue;
14566            }
14567
14568            if is_local_ollama_endpoint(entry.uri.as_deref()) {
14569                anyhow::bail!(
14570                    "providers.models.ollama.{alias}.model uses ':cloud', but uri is local or unset. Set uri to a remote Ollama endpoint (for example https://ollama.com)."
14571                );
14572            }
14573            if is_official_ollama_cloud_endpoint(entry.uri.as_deref())
14574                && !has_ollama_cloud_credential(entry.api_key.as_deref())
14575            {
14576                anyhow::bail!(
14577                    "providers.models.ollama.{alias}.model uses ':cloud', but no API key is configured. Set api_key on [providers.models.ollama.{alias}] (or via the schema-mirror grammar: ZEROCLAW_providers__models__ollama__{alias}__api_key=<value>)."
14578                );
14579            }
14580        }
14581
14582        // Microsoft 365
14583        if self.microsoft365.enabled {
14584            let tenant = self
14585                .microsoft365
14586                .tenant_id
14587                .as_deref()
14588                .map(str::trim)
14589                .filter(|s| !s.is_empty());
14590            if tenant.is_none() {
14591                anyhow::bail!(
14592                    "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
14593                );
14594            }
14595            let client = self
14596                .microsoft365
14597                .client_id
14598                .as_deref()
14599                .map(str::trim)
14600                .filter(|s| !s.is_empty());
14601            if client.is_none() {
14602                anyhow::bail!(
14603                    "microsoft365.client_id must not be empty when microsoft365 is enabled"
14604                );
14605            }
14606            let flow = self.microsoft365.auth_flow.trim();
14607            if flow != "client_credentials" && flow != "device_code" {
14608                anyhow::bail!(
14609                    "microsoft365.auth_flow must be 'client_credentials' or 'device_code'"
14610                );
14611            }
14612            if flow == "client_credentials"
14613                && self
14614                    .microsoft365
14615                    .client_secret
14616                    .as_deref()
14617                    .is_none_or(|s| s.trim().is_empty())
14618            {
14619                anyhow::bail!(
14620                    "microsoft365.client_secret must not be empty when auth_flow is 'client_credentials'"
14621                );
14622            }
14623        }
14624
14625        // Microsoft 365
14626        if self.microsoft365.enabled {
14627            let tenant = self
14628                .microsoft365
14629                .tenant_id
14630                .as_deref()
14631                .map(str::trim)
14632                .filter(|s| !s.is_empty());
14633            if tenant.is_none() {
14634                anyhow::bail!(
14635                    "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
14636                );
14637            }
14638            let client = self
14639                .microsoft365
14640                .client_id
14641                .as_deref()
14642                .map(str::trim)
14643                .filter(|s| !s.is_empty());
14644            if client.is_none() {
14645                anyhow::bail!(
14646                    "microsoft365.client_id must not be empty when microsoft365 is enabled"
14647                );
14648            }
14649            let flow = self.microsoft365.auth_flow.trim();
14650            if flow != "client_credentials" && flow != "device_code" {
14651                anyhow::bail!("microsoft365.auth_flow must be client_credentials or device_code");
14652            }
14653            if flow == "client_credentials"
14654                && self
14655                    .microsoft365
14656                    .client_secret
14657                    .as_deref()
14658                    .is_none_or(|s| s.trim().is_empty())
14659            {
14660                anyhow::bail!(
14661                    "microsoft365.client_secret must not be empty when auth_flow is client_credentials"
14662                );
14663            }
14664        }
14665
14666        // MCP
14667        if self.mcp.enabled {
14668            validate_mcp_config(&self.mcp)?;
14669        }
14670
14671        // Knowledge graph
14672        if self.knowledge.enabled {
14673            if self.knowledge.max_nodes == 0 {
14674                validation_bail!(
14675                    InvalidNumericRange,
14676                    "knowledge.max_nodes",
14677                    "knowledge.max_nodes must be greater than 0"
14678                );
14679            }
14680            if self.knowledge.db_path.trim().is_empty() {
14681                validation_bail!(
14682                    RequiredFieldEmpty,
14683                    "knowledge.db_path",
14684                    "knowledge.db_path must not be empty"
14685                );
14686            }
14687        }
14688
14689        // Google Workspace allowed_services validation
14690        let mut seen_gws_services = std::collections::HashSet::new();
14691        for (i, service) in self.google_workspace.allowed_services.iter().enumerate() {
14692            let normalized = service.trim();
14693            if normalized.is_empty() {
14694                validation_bail!(
14695                    RequiredFieldEmpty,
14696                    format!("google_workspace.allowed_services[{i}]"),
14697                    "google_workspace.allowed_services[{i}] must not be empty"
14698                );
14699            }
14700            if !normalized
14701                .chars()
14702                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
14703            {
14704                anyhow::bail!(
14705                    "google_workspace.allowed_services[{i}] contains invalid characters: {normalized}"
14706                );
14707            }
14708            if !seen_gws_services.insert(normalized.to_string()) {
14709                anyhow::bail!(
14710                    "google_workspace.allowed_services contains duplicate entry: {normalized}"
14711                );
14712            }
14713        }
14714
14715        // Build the effective allowed-services set for cross-validation.
14716        // When the operator leaves allowed_services empty the tool falls back to
14717        // DEFAULT_GWS_SERVICES; use the same constant here so validation is
14718        // consistent in both cases.
14719        let effective_services: std::collections::HashSet<&str> =
14720            if self.google_workspace.allowed_services.is_empty() {
14721                DEFAULT_GWS_SERVICES.iter().copied().collect()
14722            } else {
14723                self.google_workspace
14724                    .allowed_services
14725                    .iter()
14726                    .map(|s| s.trim())
14727                    .collect()
14728            };
14729
14730        let mut seen_gws_operations = std::collections::HashSet::new();
14731        for (i, operation) in self.google_workspace.allowed_operations.iter().enumerate() {
14732            let service = operation.service.trim();
14733            let resource = operation.resource.trim();
14734
14735            if service.is_empty() {
14736                validation_bail!(
14737                    RequiredFieldEmpty,
14738                    format!("google_workspace.allowed_operations[{i}].service"),
14739                    "google_workspace.allowed_operations[{i}].service must not be empty"
14740                );
14741            }
14742            if resource.is_empty() {
14743                anyhow::bail!(
14744                    "google_workspace.allowed_operations[{i}].resource must not be empty"
14745                );
14746            }
14747
14748            if !effective_services.contains(service) {
14749                anyhow::bail!(
14750                    "google_workspace.allowed_operations[{i}].service '{service}' is not in the \
14751                     effective allowed_services; this entry can never match at runtime"
14752                );
14753            }
14754            if !service
14755                .chars()
14756                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
14757            {
14758                anyhow::bail!(
14759                    "google_workspace.allowed_operations[{i}].service contains invalid characters: {service}"
14760                );
14761            }
14762            if !resource
14763                .chars()
14764                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
14765            {
14766                anyhow::bail!(
14767                    "google_workspace.allowed_operations[{i}].resource contains invalid characters: {resource}"
14768                );
14769            }
14770
14771            if let Some(ref sub_resource) = operation.sub_resource {
14772                let sub = sub_resource.trim();
14773                if sub.is_empty() {
14774                    anyhow::bail!(
14775                        "google_workspace.allowed_operations[{i}].sub_resource must not be empty when present"
14776                    );
14777                }
14778                if !sub
14779                    .chars()
14780                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
14781                {
14782                    anyhow::bail!(
14783                        "google_workspace.allowed_operations[{i}].sub_resource contains invalid characters: {sub}"
14784                    );
14785                }
14786            }
14787
14788            if operation.methods.is_empty() {
14789                validation_bail!(
14790                    RequiredFieldEmpty,
14791                    format!("google_workspace.allowed_operations[{i}].methods"),
14792                    "google_workspace.allowed_operations[{i}].methods must not be empty"
14793                );
14794            }
14795
14796            let mut seen_methods = std::collections::HashSet::new();
14797            for (j, method) in operation.methods.iter().enumerate() {
14798                let normalized = method.trim();
14799                if normalized.is_empty() {
14800                    anyhow::bail!(
14801                        "google_workspace.allowed_operations[{i}].methods[{j}] must not be empty"
14802                    );
14803                }
14804                if !normalized
14805                    .chars()
14806                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
14807                {
14808                    anyhow::bail!(
14809                        "google_workspace.allowed_operations[{i}].methods[{j}] contains invalid characters: {normalized}"
14810                    );
14811                }
14812                if !seen_methods.insert(normalized.to_string()) {
14813                    anyhow::bail!(
14814                        "google_workspace.allowed_operations[{i}].methods contains duplicate entry: {normalized}"
14815                    );
14816                }
14817            }
14818
14819            let sub_key = operation
14820                .sub_resource
14821                .as_deref()
14822                .map(str::trim)
14823                .unwrap_or("");
14824            let operation_key = format!("{service}:{resource}:{sub_key}");
14825            if !seen_gws_operations.insert(operation_key.clone()) {
14826                anyhow::bail!(
14827                    "google_workspace.allowed_operations contains duplicate service/resource/sub_resource entry: {operation_key}"
14828                );
14829            }
14830        }
14831
14832        // Project intelligence
14833        if self.project_intel.enabled {
14834            let lang = &self.project_intel.default_language;
14835            if !["en", "de", "fr", "it"].contains(&lang.as_str()) {
14836                anyhow::bail!(
14837                    "project_intel.default_language must be one of: en, de, fr, it (got '{lang}')"
14838                );
14839            }
14840            let sens = &self.project_intel.risk_sensitivity;
14841            if !["low", "medium", "high"].contains(&sens.as_str()) {
14842                anyhow::bail!(
14843                    "project_intel.risk_sensitivity must be one of: low, medium, high (got '{sens}')"
14844                );
14845            }
14846            if let Some(ref tpl_dir) = self.project_intel.templates_dir
14847                && !std::path::Path::new(tpl_dir).exists()
14848            {
14849                anyhow::bail!("project_intel.templates_dir path does not exist: {tpl_dir}");
14850            }
14851        }
14852
14853        // Proxy (delegate to existing validation)
14854        self.proxy.validate()?;
14855        self.cloud_ops.validate()?;
14856
14857        // Notion
14858        if self.notion.enabled {
14859            if self.notion.database_id.trim().is_empty() {
14860                anyhow::bail!("notion.database_id must not be empty when notion.enabled = true");
14861            }
14862            if self.notion.poll_interval_secs == 0 {
14863                validation_bail!(
14864                    InvalidNumericRange,
14865                    "notion.poll_interval_secs",
14866                    "notion.poll_interval_secs must be greater than 0"
14867                );
14868            }
14869            if self.notion.max_concurrent == 0 {
14870                validation_bail!(
14871                    InvalidNumericRange,
14872                    "notion.max_concurrent",
14873                    "notion.max_concurrent must be greater than 0"
14874                );
14875            }
14876            if self.notion.status_property.trim().is_empty() {
14877                validation_bail!(
14878                    RequiredFieldEmpty,
14879                    "notion.status_property",
14880                    "notion.status_property must not be empty"
14881                );
14882            }
14883            if self.notion.input_property.trim().is_empty() {
14884                validation_bail!(
14885                    RequiredFieldEmpty,
14886                    "notion.input_property",
14887                    "notion.input_property must not be empty"
14888                );
14889            }
14890            if self.notion.result_property.trim().is_empty() {
14891                validation_bail!(
14892                    RequiredFieldEmpty,
14893                    "notion.result_property",
14894                    "notion.result_property must not be empty"
14895                );
14896            }
14897        }
14898
14899        // Pinggy tunnel region — validate allowed values (case-insensitive, auto-lowercased at runtime).
14900        if let Some(ref pinggy) = self.tunnel.pinggy
14901            && let Some(ref region) = pinggy.region
14902        {
14903            let r = region.trim().to_ascii_lowercase();
14904            if !r.is_empty() && !matches!(r.as_str(), "us" | "eu" | "ap" | "br" | "au") {
14905                anyhow::bail!(
14906                    "tunnel.pinggy.region must be one of: us, eu, ap, br, au (or omitted for auto)"
14907                );
14908            }
14909        }
14910
14911        // Jira
14912        if self.jira.enabled {
14913            if self.jira.base_url.trim().is_empty() {
14914                anyhow::bail!("jira.base_url must not be empty when jira.enabled = true");
14915            }
14916            if self.jira.api_token.trim().is_empty()
14917                && std::env::var("JIRA_API_TOKEN")
14918                    .unwrap_or_default()
14919                    .trim()
14920                    .is_empty()
14921            {
14922                anyhow::bail!(
14923                    "jira.api_token must be set (or JIRA_API_TOKEN env var) when jira.enabled = true"
14924                );
14925            }
14926            let valid_actions = [
14927                "get_ticket",
14928                "search_tickets",
14929                "comment_ticket",
14930                "list_projects",
14931                "myself",
14932                "list_transitions",
14933                "transition_ticket",
14934                "create_ticket",
14935            ];
14936            for action in &self.jira.allowed_actions {
14937                if !valid_actions.contains(&action.as_str()) {
14938                    anyhow::bail!(
14939                        "jira.allowed_actions contains unknown action: '{}'. \
14940                         Valid: get_ticket, search_tickets, comment_ticket, list_projects, myself, list_transitions, transition_ticket, create_ticket",
14941                        action
14942                    );
14943                }
14944            }
14945        }
14946
14947        // Nevis IAM — delegate to NevisConfig::validate() for field-level checks
14948        if let Err(msg) = self.security.nevis.validate() {
14949            anyhow::bail!("security.nevis: {msg}");
14950        }
14951
14952        // Delegate tool global defaults
14953        if self.delegate.timeout_secs == 0 {
14954            validation_bail!(
14955                InvalidNumericRange,
14956                "delegate.timeout_secs",
14957                "delegate.timeout_secs must be greater than 0"
14958            );
14959        }
14960        if self.delegate.agentic_timeout_secs == 0 {
14961            validation_bail!(
14962                InvalidNumericRange,
14963                "delegate.agentic_timeout_secs",
14964                "delegate.agentic_timeout_secs must be greater than 0"
14965            );
14966        }
14967
14968        // Per-agent validation. Mandatory + alias-existence checks live
14969        // here so the gateway PATCH path returns structured per-field
14970        // errors and the frontend never owns this rule. Sorted iteration
14971        // keeps error ordering stable across runs.
14972        let mut agent_aliases: Vec<&String> = self.agents.keys().collect();
14973        agent_aliases.sort();
14974        for alias in agent_aliases {
14975            let agent = &self.agents[alias];
14976
14977            // model_provider: mandatory, dotted `<type>.<inner>` ref into
14978            // model_providers.<type>.<inner>.
14979            let mp = agent.model_provider.trim();
14980            if mp.is_empty() {
14981                validation_bail!(
14982                    RequiredFieldEmpty,
14983                    format!("agents.{alias}.model_provider"),
14984                    "agents.{alias}.model_provider must reference a configured model model_provider (e.g. \"anthropic.default\")",
14985                );
14986            }
14987            match mp.split_once('.') {
14988                Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
14989                    let exists = self
14990                        .get_map_keys(&format!("providers.models.{ty}"))
14991                        .is_some_and(|keys| keys.iter().any(|k| k == inner));
14992                    if !exists {
14993                        validation_bail!(
14994                            DanglingReference,
14995                            format!("agents.{alias}.model_provider"),
14996                            "agents.{alias}.model_provider = {mp:?} but providers.models.{ty}.{inner} is not configured",
14997                        );
14998                    }
14999                }
15000                _ => validation_bail!(
15001                    InvalidFormat,
15002                    format!("agents.{alias}.model_provider"),
15003                    "agents.{alias}.model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
15004                ),
15005            }
15006
15007            // channels: each entry is a dotted `<type>.<inner>` ref into
15008            // channels.<type>.<inner>. Empty list is valid (delegate-only agent).
15009            // Uses the schema-derived `get_map_keys` so new channel types
15010            // surface here automatically — no per-type match arm.
15011            for (i, ch) in agent.channels.iter().enumerate() {
15012                let trimmed = ch.trim();
15013                match trimmed.split_once('.') {
15014                    Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
15015                        // `get_map_keys` stores section names in kebab form
15016                        // (the schema macro converts snake idents via
15017                        // `snake_to_kebab`). Operator-written refs use the
15018                        // dotted alias they see in TOML, which is the raw
15019                        // field ident — snake for `gmail_push`, `voice_call`,
15020                        // `nextcloud_talk`, etc. Convert before the lookup so
15021                        // underscored channel types resolve correctly.
15022                        let ty_kebab = ty.replace('_', "-");
15023                        let exists = self
15024                            .get_map_keys(&format!("channels.{ty_kebab}"))
15025                            .is_some_and(|keys| keys.iter().any(|k| k == inner));
15026                        if !exists {
15027                            validation_bail!(
15028                                DanglingReference,
15029                                format!("agents.{alias}.channels[{i}]"),
15030                                "agents.{alias}.channels[{i}] = {trimmed:?} but channels.{ty}.{inner} is not configured",
15031                            );
15032                        }
15033                    }
15034                    _ => validation_bail!(
15035                        InvalidFormat,
15036                        format!("agents.{alias}.channels[{i}]"),
15037                        "agents.{alias}.channels[{i}] must be dotted form `<type>.<alias>` (got {trimmed:?})",
15038                    ),
15039                }
15040            }
15041
15042            // Per-agent provider refs that resolve into the typed provider
15043            // sections. Empty = no preference for that category (no TTS / no
15044            // STT for this agent), which is valid. Non-empty values must
15045            // match a configured `[providers.<category>.<type>.<alias>]`
15046            // entry, fail loud with the dangling ref otherwise.
15047            // there is no global default-X-provider concept — every consumer
15048            // either picks a configured alias or opts out entirely.
15049            let typed_provider_refs: &[(&str, &str, &str)] = &[
15050                ("providers.tts", "tts_provider", agent.tts_provider.trim()),
15051                (
15052                    "providers.transcription",
15053                    "transcription_provider",
15054                    agent.transcription_provider.trim(),
15055                ),
15056                // NEW in this PR (kanmars.req.20260522.001):
15057                (
15058                    "providers.models",
15059                    "classifier_provider",
15060                    agent.classifier_provider.trim(),
15061                ),
15062            ];
15063            for (section_prefix, field, value) in typed_provider_refs {
15064                if value.is_empty() {
15065                    continue;
15066                }
15067                match value.split_once('.') {
15068                    Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
15069                        let exists = self
15070                            .get_map_keys(&format!("{section_prefix}.{ty}"))
15071                            .is_some_and(|keys| keys.iter().any(|k| k == inner));
15072                        if !exists {
15073                            validation_bail!(
15074                                DanglingReference,
15075                                format!("agents.{alias}.{field}"),
15076                                "agents.{alias}.{field} = {value:?} but {section_prefix}.{ty}.{inner} is not configured",
15077                            );
15078                        }
15079                    }
15080                    _ => validation_bail!(
15081                        InvalidFormat,
15082                        format!("agents.{alias}.{field}"),
15083                        "agents.{alias}.{field} must be dotted form `<type>.<alias>` (got {value:?})",
15084                    ),
15085                }
15086            }
15087
15088            // Bare-alias bundle refs. Tuple is (kebab section path, kebab
15089            // agent field name, value list). Both names use the schema's
15090            // kebab form: section name matches what `get_map_keys` expects
15091            // (macro converts snake→kebab via `snake_to_kebab` per
15092            // crates/zeroclaw-macros/src/lib.rs:1056); field name matches
15093            // what `prop_fields()` emits, so DanglingReference paths bind
15094            // directly to the right inline error in the dashboard form.
15095            let bare_multi: &[(&str, &str, &[String])] = &[
15096                ("skill-bundles", "skill_bundles", &agent.skill_bundles),
15097                (
15098                    "knowledge-bundles",
15099                    "knowledge_bundles",
15100                    &agent.knowledge_bundles,
15101                ),
15102                ("mcp-bundles", "mcp_bundles", &agent.mcp_bundles),
15103            ];
15104            for (section, field, values) in bare_multi {
15105                for (i, key) in values.iter().enumerate() {
15106                    let trimmed = key.trim();
15107                    if trimmed.is_empty() {
15108                        continue;
15109                    }
15110                    let exists = self
15111                        .get_map_keys(section)
15112                        .is_some_and(|keys| keys.iter().any(|k| k == trimmed));
15113                    if !exists {
15114                        validation_bail!(
15115                            DanglingReference,
15116                            format!("agents.{alias}.{field}[{i}]"),
15117                            "agents.{alias}.{field}[{i}] = {trimmed:?} but {section}.{trimmed} is not configured",
15118                        );
15119                    }
15120                }
15121            }
15122            let bare_single: &[(&str, &str, &str)] = &[
15123                ("risk-profiles", "risk-profile", agent.risk_profile.as_str()),
15124                (
15125                    "runtime-profiles",
15126                    "runtime-profile",
15127                    agent.runtime_profile.as_str(),
15128                ),
15129            ];
15130            for (section, field, raw) in bare_single {
15131                let trimmed = raw.trim();
15132                if trimmed.is_empty() {
15133                    continue;
15134                }
15135                let exists = self
15136                    .get_map_keys(section)
15137                    .is_some_and(|keys| keys.iter().any(|k| k == trimmed));
15138                if !exists {
15139                    validation_bail!(
15140                        DanglingReference,
15141                        format!("agents.{alias}.{field}"),
15142                        "agents.{alias}.{field} = {trimmed:?} but {section}.{trimmed} is not configured",
15143                    );
15144                }
15145            }
15146
15147            // risk_profile is mandatory for enabled agents — there is no
15148            // global fallback, so an enabled agent with no profile can't
15149            // gate its actions. Run this check last so the more specific
15150            // dangling/format errors above surface first.
15151            if agent.enabled && agent.risk_profile.trim().is_empty() {
15152                validation_bail!(
15153                    RequiredFieldEmpty,
15154                    format!("agents.{alias}.risk-profile"),
15155                    "agents.{alias}.risk_profile must reference a configured [risk_profiles.<alias>] entry",
15156                );
15157            }
15158
15159            if agent.precheck.timeout_secs == 0 {
15160                validation_bail!(
15161                    InvalidNumericRange,
15162                    format!("agents.{alias}.precheck.timeout_secs"),
15163                    "agents.{alias}.precheck.timeout_secs must be greater than 0",
15164                );
15165            }
15166
15167            // workspace.access: keys must point at OTHER agents, never
15168            // self, and every target must be a configured agent.
15169            for (target, mode) in &agent.workspace.access {
15170                let target_str = target.as_str();
15171                if target_str == alias.as_str() {
15172                    validation_bail!(
15173                        InvalidFormat,
15174                        format!("agents.{alias}.workspace.access.{target_str}"),
15175                        "agents.{alias}.workspace.access.{target_str} = {mode:?} but {target_str} is this agent itself; an agent always has full access to its own workspace, so self-references in the cross-agent allowlist are not permitted",
15176                    );
15177                }
15178                if !self.agents.contains_key(target_str) {
15179                    validation_bail!(
15180                        DanglingReference,
15181                        format!("agents.{alias}.workspace.access.{target_str}"),
15182                        "agents.{alias}.workspace.access.{target_str} = {mode:?} but agents.{target_str} is not configured",
15183                    );
15184                }
15185            }
15186
15187            // workspace.read_memory_from: every alias must exist as a
15188            // configured agent and must use the same MemoryBackendKind
15189            // as the declaring agent. Mismatched backends fail at
15190            // config load rather than producing a runtime error when
15191            // the per-agent memory plumbing consumes the allowlist.
15192            let agent_backend = agent.memory.backend;
15193            for (i, target) in agent.workspace.read_memory_from.iter().enumerate() {
15194                let target_str = target.as_str();
15195                if target_str == alias.as_str() {
15196                    validation_bail!(
15197                        InvalidFormat,
15198                        format!("agents.{alias}.workspace.read_memory_from[{i}]"),
15199                        "agents.{alias}.workspace.read_memory_from[{i}] = {target_str:?} but {target_str} is this agent itself; an agent always sees its own memory rows, so self-references in the cross-agent allowlist are not permitted",
15200                    );
15201                }
15202                let Some(target_agent) = self.agents.get(target_str) else {
15203                    validation_bail!(
15204                        DanglingReference,
15205                        format!("agents.{alias}.workspace.read_memory_from[{i}]"),
15206                        "agents.{alias}.workspace.read_memory_from[{i}] = {target_str:?} but agents.{target_str} is not configured",
15207                    );
15208                };
15209                if target_agent.memory.backend != agent_backend {
15210                    let target_backend = target_agent.memory.backend;
15211                    validation_bail!(
15212                        InvalidFormat,
15213                        format!("agents.{alias}.workspace.read_memory_from[{i}]"),
15214                        "agents.{alias}.workspace.read_memory_from[{i}] points at agents.{target_str} which uses memory backend {target_backend:?}, but agents.{alias} uses {agent_backend:?}; the allowlist must point at same-backend siblings only",
15215                    );
15216                }
15217            }
15218        }
15219
15220        // Peer groups: every member alias must exist as a configured
15221        // agent, and the group's channel must be in each member's
15222        // channels list. Mutual opt-in resolution happens at runtime;
15223        // this cross-reference check keeps misconfigured group
15224        // members from looking like real peer relationships at load
15225        // time.
15226        let mut peer_group_names: Vec<&String> = self.peer_groups.keys().collect();
15227        peer_group_names.sort();
15228        for group_name in peer_group_names {
15229            let group = &self.peer_groups[group_name];
15230            let group_channel = group.channel.trim();
15231            if group_channel.is_empty() {
15232                validation_bail!(
15233                    RequiredFieldEmpty,
15234                    format!("peer_groups.{group_name}.channel"),
15235                    "peer_groups.{group_name}.channel must name a channel type (e.g. \"discord\") or dotted alias (e.g. \"discord.work\")",
15236                );
15237            }
15238            // `get_map_keys` stores section names in kebab form (the schema
15239            // macro converts snake idents via `snake_to_kebab`); convert
15240            // before the lookup so underscored channel types like
15241            // `nextcloud_talk` resolve correctly.
15242            let (group_channel_type, group_channel_alias) = match group_channel.split_once('.') {
15243                Some((ty, al)) => (ty, Some(al)),
15244                None => (group_channel, None),
15245            };
15246            let group_channel_type_kebab = group_channel_type.replace('_', "-");
15247            let channel_aliases =
15248                self.get_map_keys(&format!("channels.{group_channel_type_kebab}"));
15249            if channel_aliases.is_none() {
15250                validation_bail!(
15251                    DanglingReference,
15252                    format!("peer_groups.{group_name}.channel"),
15253                    "peer_groups.{group_name}.channel = {group_channel:?} but no [channels.{group_channel_type}.*] block is configured",
15254                );
15255            }
15256            if let Some(alias) = group_channel_alias {
15257                let exists = channel_aliases
15258                    .as_ref()
15259                    .is_some_and(|keys| keys.iter().any(|k| k == alias));
15260                if !exists {
15261                    validation_bail!(
15262                        DanglingReference,
15263                        format!("peer_groups.{group_name}.channel"),
15264                        "peer_groups.{group_name}.channel = {group_channel:?} but [channels.{group_channel_type}.{alias}] is not configured",
15265                    );
15266                }
15267            }
15268            for (i, member) in group.agents.iter().enumerate() {
15269                let member_str = member.as_str();
15270                let Some(member_agent) = self.agents.get(member_str) else {
15271                    validation_bail!(
15272                        DanglingReference,
15273                        format!("peer_groups.{group_name}.agents[{i}]"),
15274                        "peer_groups.{group_name}.agents[{i}] = {member_str:?} but agents.{member_str} is not configured",
15275                    );
15276                };
15277                let has_channel_match = member_agent.channels.iter().any(|ch| {
15278                    let ch_str = ch.as_str();
15279                    match group_channel_alias {
15280                        Some(alias) => ch_str == format!("{group_channel_type}.{alias}"),
15281                        None => ch_str.starts_with(&format!("{group_channel_type}.")),
15282                    }
15283                });
15284                if !has_channel_match {
15285                    let needs_msg = match group_channel_alias {
15286                        Some(alias) => format!("entry for {group_channel_type}.{alias}"),
15287                        None => format!("entry of type {group_channel_type:?}"),
15288                    };
15289                    validation_bail!(
15290                        InvalidFormat,
15291                        format!("peer_groups.{group_name}.agents[{i}]"),
15292                        "peer_groups.{group_name}.agents[{i}] = {member_str:?} but agents.{member_str}.channels has no {needs_msg}",
15293                    );
15294                }
15295            }
15296        }
15297
15298        Ok(())
15299    }
15300
15301    pub fn mark_dirty(&mut self, path: &str) {
15302        self.dirty_paths.insert(path.to_string());
15303    }
15304
15305    pub fn clear_dirty(&mut self) {
15306        self.dirty_paths.clear();
15307    }
15308
15309    pub fn set_prop_persistent(&mut self, name: &str, value_str: &str) -> Result<()> {
15310        self.set_prop(name, value_str)?;
15311        self.mark_dirty(name);
15312        Ok(())
15313    }
15314
15315    pub fn set_secret_persistent(&mut self, name: &str, value: String) -> Result<()> {
15316        self.set_secret(name, value)?;
15317        self.mark_dirty(name);
15318        Ok(())
15319    }
15320
15321    async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
15322        if self
15323            .config_path
15324            .parent()
15325            .is_some_and(|parent| !parent.as_os_str().is_empty())
15326        {
15327            return Ok(self.config_path.clone());
15328        }
15329
15330        let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_data_dirs()?;
15331        let (zeroclaw_dir, _workspace_dir, source) =
15332            resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
15333        let file_name = self
15334            .config_path
15335            .file_name()
15336            .filter(|name| !name.is_empty())
15337            .unwrap_or_else(|| std::ffi::OsStr::new("config.toml"));
15338        let resolved = zeroclaw_dir.join(file_name);
15339        ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"path": self.config_path.display().to_string(), "resolved": resolved.display().to_string(), "source": source.as_str()})), "Config path missing parent directory; resolving from runtime environment");
15340        Ok(resolved)
15341    }
15342
15343    pub async fn save(&self) -> Result<()> {
15344        // Encrypt secrets before serialization
15345        let mut config_to_save = self.clone();
15346        let config_path = self.resolve_config_path_for_save().await?;
15347        let zeroclaw_dir = config_path
15348            .parent()
15349            .context("Config path must have a parent directory")?;
15350        let store = crate::secrets::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
15351
15352        // Restore env-overridden paths to their pre-override snapshots before
15353        // encryption, so values supplied via `ZEROCLAW_*` env vars never reach
15354        // disk. Snapshots were captured at apply time from the post-decrypt
15355        // in-memory state, so secrets carry the original plaintext that
15356        // `encrypt_secrets()` will re-encrypt to fresh ciphertext that
15357        // decrypts back to the same value.
15358        if !self.pre_override_snapshots.is_empty() {
15359            crate::env_overrides::mask_env_overrides_for_save(
15360                &mut config_to_save,
15361                &self.pre_override_snapshots,
15362            )?;
15363        }
15364
15365        // Encrypt all #[secret]-annotated fields via Configurable derive
15366        config_to_save.encrypt_secrets(&store)?;
15367
15368        // Serialize, then prune fields whose values match
15369        // `Config::default()` so the on-disk config carries only the
15370        // operator's actual choices (no hundreds of lines of struct
15371        // defaults the operator never touched). The schema's
15372        // `#[serde(default = "...")]` annotations re-supply the
15373        // defaults on load, so the pruned file round-trips identically.
15374        let mut new_table: toml::Table = toml::Value::try_from(&config_to_save)
15375            .context("Failed to serialize config to TOML value")?
15376            .try_into()
15377            .context("Serialized config is not a TOML table")?;
15378        let default_table: toml::Table = toml::Value::try_from(Config::default())
15379            .ok()
15380            .and_then(|v| v.try_into().ok())
15381            .unwrap_or_default();
15382        prune_default_values(&mut new_table, &default_table);
15383        let new_toml = ensure_blank_line_before_sections(
15384            &toml::to_string_pretty(&new_table).context("Failed to serialize pruned config")?,
15385        );
15386
15387        // If an existing config file is present, sync the new values onto it
15388        // to preserve comments and formatting. Otherwise, use the fresh serialization.
15389        let toml_str = if config_path.exists() {
15390            let existing = fs::read_to_string(&config_path).await.unwrap_or_default();
15391            if existing.is_empty() {
15392                new_toml
15393            } else {
15394                let mut doc: toml_edit::DocumentMut = existing
15395                    .parse()
15396                    .context("Failed to parse existing config for comment preservation")?;
15397                crate::migration::sync_table(doc.as_table_mut(), &new_table);
15398                // sync_table preserves existing decor verbatim, so newly
15399                // inserted sections lack the blank-line gap before their
15400                // header until the post-processor runs.
15401                ensure_blank_line_before_sections(&doc.to_string())
15402            }
15403        } else {
15404            new_toml
15405        };
15406
15407        write_config_atomically(&config_path, &toml_str).await
15408    }
15409
15410    /// Incremental save: only the paths in `self.dirty_paths` are written
15411    /// against the existing on-disk file. Non-dirty entries (including
15412    /// secret ciphertext) are left untouched; dirty paths whose value
15413    /// equals the schema default are removed from the doc instead of
15414    /// written. Falls back to a full `save()` when the file doesn't
15415    /// exist yet. Clears the dirty set on success.
15416    pub async fn save_dirty(&mut self) -> Result<()> {
15417        if self.dirty_paths.is_empty() {
15418            return Ok(());
15419        }
15420
15421        let config_path = self.resolve_config_path_for_save().await?;
15422        if !config_path.exists() {
15423            let result = self.save().await;
15424            if result.is_ok() {
15425                self.clear_dirty();
15426            }
15427            return result;
15428        }
15429
15430        let mut config_to_save = self.clone();
15431        let zeroclaw_dir = config_path
15432            .parent()
15433            .context("Config path must have a parent directory")?;
15434        let store = crate::secrets::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
15435
15436        if !self.pre_override_snapshots.is_empty() {
15437            crate::env_overrides::mask_env_overrides_for_save(
15438                &mut config_to_save,
15439                &self.pre_override_snapshots,
15440            )?;
15441        }
15442        config_to_save.encrypt_secrets(&store)?;
15443
15444        let full_table: toml::Table = toml::Value::try_from(&config_to_save)
15445            .context("Failed to serialize config to TOML value")?
15446            .try_into()
15447            .context("Serialized config is not a TOML table")?;
15448        let default_table: toml::Table = toml::Value::try_from(Config::default())
15449            .ok()
15450            .and_then(|v| v.try_into().ok())
15451            .unwrap_or_default();
15452
15453        let existing = fs::read_to_string(&config_path).await.with_context(|| {
15454            format!(
15455                "Failed to read existing config for incremental save: {}",
15456                config_path.display()
15457            )
15458        })?;
15459        let mut doc: toml_edit::DocumentMut = existing
15460            .parse()
15461            .context("Failed to parse existing config for incremental save")?;
15462
15463        for path in &self.dirty_paths {
15464            apply_dirty_path(doc.as_table_mut(), path, &full_table, &default_table);
15465        }
15466
15467        let toml_str = ensure_blank_line_before_sections(&doc.to_string());
15468
15469        write_config_atomically(&config_path, &toml_str).await?;
15470        self.clear_dirty();
15471        Ok(())
15472    }
15473}
15474
15475/// Atomic write shared by `save()` and `save_dirty()`.
15476async fn write_config_atomically(config_path: &Path, toml_str: &str) -> Result<()> {
15477    let parent_dir = config_path
15478        .parent()
15479        .context("Config path must have a parent directory")?;
15480
15481    fs::create_dir_all(parent_dir).await.with_context(|| {
15482        format!(
15483            "Failed to create config directory: {}",
15484            parent_dir.display()
15485        )
15486    })?;
15487
15488    let file_name = config_path
15489        .file_name()
15490        .and_then(|v| v.to_str())
15491        .unwrap_or("config.toml");
15492    let temp_path = parent_dir.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
15493    let backup_path = parent_dir.join(format!("{file_name}.bak"));
15494
15495    let mut temp_file = OpenOptions::new()
15496        .create_new(true)
15497        .write(true)
15498        .open(&temp_path)
15499        .await
15500        .with_context(|| {
15501            format!(
15502                "Failed to create temporary config file: {}",
15503                temp_path.display()
15504            )
15505        })?;
15506    temp_file
15507        .write_all(toml_str.as_bytes())
15508        .await
15509        .context("Failed to write temporary config contents")?;
15510    temp_file
15511        .sync_all()
15512        .await
15513        .context("Failed to fsync temporary config file")?;
15514    drop(temp_file);
15515
15516    let had_existing_config = config_path.exists();
15517    if had_existing_config {
15518        fs::copy(config_path, &backup_path).await.with_context(|| {
15519            format!(
15520                "Failed to create config backup before atomic replace: {}",
15521                backup_path.display()
15522            )
15523        })?;
15524    }
15525
15526    if let Err(e) = fs::rename(&temp_path, config_path).await {
15527        let _ = fs::remove_file(&temp_path).await;
15528        if had_existing_config && backup_path.exists() {
15529            fs::copy(&backup_path, config_path)
15530                .await
15531                .context("Failed to restore config backup")?;
15532        }
15533        anyhow::bail!("Failed to atomically replace config file: {e}");
15534    }
15535
15536    #[cfg(unix)]
15537    {
15538        use std::{fs::Permissions, os::unix::fs::PermissionsExt};
15539        if let Err(err) = fs::set_permissions(config_path, Permissions::from_mode(0o600)).await {
15540            ::zeroclaw_log::record!(
15541                WARN,
15542                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15543                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
15544                &format!(
15545                    "Failed to harden config permissions to 0600 at {}: {}",
15546                    config_path.display().to_string(),
15547                    err
15548                )
15549            );
15550        }
15551    }
15552
15553    sync_directory(parent_dir).await?;
15554
15555    if had_existing_config {
15556        let _ = fs::remove_file(&backup_path).await;
15557    }
15558
15559    Ok(())
15560}
15561
15562/// Write the in-memory value at `dotted` into the doc, or delete the leaf
15563/// when the value is absent or equals the schema default. Segments are
15564/// kebab→snake-translated; alias keys never carry hyphens (alias rule).
15565fn apply_dirty_path(
15566    root: &mut toml_edit::Table,
15567    dotted: &str,
15568    full_table: &toml::Table,
15569    default_table: &toml::Table,
15570) {
15571    let raw: Vec<&str> = dotted.split('.').collect();
15572    if raw.is_empty() {
15573        return;
15574    }
15575    // Resolve each segment against the in-memory table: struct fields
15576    // serialize as snake_case (so `input-per-mtok` → `input_per_mtok`), but
15577    // HashMap keys are preserved verbatim and may legitimately carry hyphens
15578    // (`claude-opus-4-7`, `tts-1-hd`). Blind `s.replace('-', "_")` mangles
15579    // those keys and lookup returns None, which apply_dirty_path treats as
15580    // "delete this path" — silently dropping every cost.rates save.
15581    let segments: Vec<String> = resolve_dirty_segments(full_table, &raw);
15582    let segs: Vec<&str> = segments.iter().map(String::as_str).collect();
15583
15584    let mem_val = lookup_path_in_table(full_table, &segs);
15585    let default_val = lookup_path_in_table(default_table, &segs);
15586
15587    let should_delete = match (mem_val, default_val) {
15588        (None, _) => true,
15589        (Some(m), Some(d)) if m == d => true,
15590        _ => false,
15591    };
15592
15593    if should_delete {
15594        delete_path_in_doc(root, &segs);
15595    } else if let Some(value) = mem_val {
15596        let mut pruned = value.clone();
15597        prune_empty_leaves(&mut pruned);
15598        set_path_in_doc(root, &segs, &pruned);
15599    }
15600}
15601
15602/// Drop empty arrays / tables / strings from a value before writing it
15603/// to the doc. HashMap entries serialize every default field (no
15604/// `skip_serializing_if` on individual `Vec<String>` fields), so without
15605/// this pass an `mcp_bundles.<alias>` write produces `servers = []`,
15606/// `exclude = []`, etc. The pruned form round-trips identically because
15607/// each dropped field's serde default IS the dropped value.
15608fn prune_empty_leaves(value: &mut toml::Value) {
15609    match value {
15610        toml::Value::Table(t) => {
15611            let keys: Vec<String> = t.keys().cloned().collect();
15612            for key in keys {
15613                if let Some(inner) = t.get_mut(&key) {
15614                    prune_empty_leaves(inner);
15615                }
15616                let drop = match t.get(&key) {
15617                    Some(toml::Value::Array(arr)) => arr.is_empty(),
15618                    Some(toml::Value::Table(inner)) => inner.is_empty(),
15619                    Some(toml::Value::String(s)) => s.is_empty(),
15620                    _ => false,
15621                };
15622                if drop {
15623                    t.remove(&key);
15624                }
15625            }
15626        }
15627        toml::Value::Array(arr) => {
15628            for item in arr.iter_mut() {
15629                prune_empty_leaves(item);
15630            }
15631        }
15632        _ => {}
15633    }
15634}
15635
15636fn resolve_dirty_segments(root: &toml::Table, raw: &[&str]) -> Vec<String> {
15637    let mut out: Vec<String> = Vec::with_capacity(raw.len());
15638    let mut current: Option<&toml::Value> = None;
15639    for seg in raw {
15640        let table_opt: Option<&toml::Table> = if out.is_empty() {
15641            Some(root)
15642        } else {
15643            current.and_then(|v| v.as_table())
15644        };
15645        let resolved = match table_opt {
15646            Some(t) if t.contains_key(*seg) => (*seg).to_string(),
15647            Some(t) => {
15648                let snake = seg.replace('-', "_");
15649                if t.contains_key(&snake) {
15650                    snake
15651                } else {
15652                    (*seg).to_string()
15653                }
15654            }
15655            None => (*seg).to_string(),
15656        };
15657        current = table_opt.and_then(|t| t.get(&resolved));
15658        out.push(resolved);
15659    }
15660    out
15661}
15662
15663fn lookup_path_in_table<'a>(root: &'a toml::Table, segs: &[&str]) -> Option<&'a toml::Value> {
15664    let mut current: Option<&toml::Value> = None;
15665    for (i, seg) in segs.iter().enumerate() {
15666        let table = if i == 0 { root } else { current?.as_table()? };
15667        current = table.get(*seg);
15668    }
15669    current
15670}
15671
15672fn delete_path_in_doc(root: &mut toml_edit::Table, segs: &[&str]) {
15673    let Some((last, parents)) = segs.split_last() else {
15674        return;
15675    };
15676    let mut cursor: &mut toml_edit::Table = root;
15677    for seg in parents {
15678        cursor = match cursor.get_mut(seg).and_then(|i| i.as_table_mut()) {
15679            Some(t) => t,
15680            None => return,
15681        };
15682    }
15683    cursor.remove(last);
15684}
15685
15686fn set_path_in_doc(root: &mut toml_edit::Table, segs: &[&str], value: &toml::Value) {
15687    let Some((last, parents)) = segs.split_last() else {
15688        return;
15689    };
15690    let mut cursor: &mut toml_edit::Table = root;
15691    for seg in parents {
15692        if !cursor.contains_key(seg) {
15693            cursor.insert(seg, toml_edit::Item::Table(toml_edit::Table::new()));
15694        }
15695        cursor = match cursor.get_mut(seg).and_then(|i| i.as_table_mut()) {
15696            Some(t) => t,
15697            None => return,
15698        };
15699    }
15700    let new_item = crate::migration::toml_value_to_edit_item(value);
15701    cursor.insert(last, new_item);
15702}
15703
15704#[allow(clippy::unused_async)] // async needed on unix for tokio File I/O; no-op on other platforms
15705async fn sync_directory(path: &Path) -> Result<()> {
15706    #[cfg(unix)]
15707    {
15708        let dir = File::open(path).await.with_context(|| {
15709            format!(
15710                "Failed to open directory for fsync: {}",
15711                path.display().to_string()
15712            )
15713        })?;
15714        dir.sync_all().await.with_context(|| {
15715            format!(
15716                "Failed to fsync directory metadata: {}",
15717                path.display().to_string()
15718            )
15719        })?;
15720        Ok(())
15721    }
15722
15723    #[cfg(windows)]
15724    {
15725        use std::os::windows::fs::OpenOptionsExt;
15726        const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x02000000;
15727        let dir = std::fs::OpenOptions::new()
15728            .read(true)
15729            .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
15730            .open(path)
15731            .with_context(|| {
15732                format!(
15733                    "Failed to open directory for fsync: {}",
15734                    path.display().to_string()
15735                )
15736            })?;
15737        // FlushFileBuffers on directory handles returns ERROR_ACCESS_DENIED on
15738        // Windows (OS Error 5). This is expected — NTFS does not support
15739        // flushing directory metadata the same way Unix does. The individual
15740        // files have already been synced, so it is safe to ignore this error.
15741        if let Err(e) = dir.sync_all() {
15742            if e.raw_os_error() == Some(5) {
15743                ::zeroclaw_log::record!(
15744                    TRACE,
15745                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
15746                    &format!(
15747                        "Ignoring expected ACCESS_DENIED when fsyncing directory on Windows: {}",
15748                        path.display().to_string()
15749                    )
15750                );
15751            } else {
15752                return Err(e).with_context(|| {
15753                    format!(
15754                        "Failed to fsync directory metadata: {}",
15755                        path.display().to_string()
15756                    )
15757                });
15758            }
15759        }
15760        Ok(())
15761    }
15762
15763    #[cfg(not(any(unix, windows)))]
15764    {
15765        let _ = path;
15766        Ok(())
15767    }
15768}
15769
15770// ── SOP engine configuration ───────────────────────────────────
15771
15772/// Standard Operating Procedures engine configuration (`[sop]`).
15773///
15774/// The `default_execution_mode` field uses the `SopExecutionMode` type from
15775/// `sop::types` (re-exported via `sop::SopExecutionMode`). To avoid circular
15776/// module references, config stores it using the same enum definition.
15777#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
15778#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
15779#[prefix = "sop"]
15780pub struct SopConfig {
15781    /// Directory containing SOP definitions (subdirs with SOP.toml + SOP.md).
15782    /// Required to enable runtime SOP loading. When omitted, no SOPs are loaded
15783    /// at runtime; CLI commands (`sop list`, `sop validate`, `sop show`) still
15784    /// resolve the default `<workspace>/sops` for offline inspection.
15785    #[serde(default)]
15786    pub sops_dir: Option<String>,
15787
15788    /// Default execution mode for SOPs that omit `execution_mode`.
15789    /// Values: `auto`, `supervised` (default), `step_by_step`,
15790    /// `priority_based`, `deterministic`.
15791    #[serde(default = "default_sop_execution_mode")]
15792    pub default_execution_mode: String,
15793
15794    /// Maximum total concurrent SOP runs across all SOPs.
15795    #[serde(default = "default_sop_max_concurrent_total")]
15796    pub max_concurrent_total: usize,
15797
15798    /// Approval timeout in seconds. When a run waits for approval longer than
15799    /// this, Critical/High-priority SOPs auto-approve; others stay waiting.
15800    /// Set to 0 to disable timeout.
15801    #[serde(default = "default_sop_approval_timeout_secs")]
15802    pub approval_timeout_secs: u64,
15803
15804    /// Maximum number of finished runs kept in memory for status queries.
15805    /// Oldest runs are evicted when over capacity. 0 = unlimited.
15806    #[serde(default = "default_sop_max_finished_runs")]
15807    pub max_finished_runs: usize,
15808}
15809
15810fn default_sop_execution_mode() -> String {
15811    "supervised".to_string()
15812}
15813
15814fn default_sop_max_concurrent_total() -> usize {
15815    4
15816}
15817
15818fn default_sop_approval_timeout_secs() -> u64 {
15819    300
15820}
15821
15822fn default_sop_max_finished_runs() -> usize {
15823    100
15824}
15825
15826impl Default for SopConfig {
15827    fn default() -> Self {
15828        Self {
15829            sops_dir: None,
15830            default_execution_mode: default_sop_execution_mode(),
15831            max_concurrent_total: default_sop_max_concurrent_total(),
15832            approval_timeout_secs: default_sop_approval_timeout_secs(),
15833            max_finished_runs: default_sop_max_finished_runs(),
15834        }
15835    }
15836}
15837
15838// ── HasPropKind impls for config enums ──
15839// Scalars (bool, String, integers, floats) are covered by impl_prop_kind! in traits.rs.
15840// Config enums serialize as TOML strings and are classified as PropKind::Enum.
15841macro_rules! impl_enum_prop_kind {
15842    ($($ty:ty),+ $(,)?) => {
15843        $(impl HasPropKind for $ty { const PROP_KIND: PropKind = PropKind::Enum; })+
15844    };
15845}
15846impl_enum_prop_kind!(
15847    WireApi,
15848    HardwareTransport,
15849    McpTransport,
15850    ToolFilterGroupMode,
15851    SkillsPromptInjectionMode,
15852    FirecrawlMode,
15853    ProxyScope,
15854    SearchMode,
15855    CronScheduleDecl,
15856    StreamMode,
15857    WhatsAppWebMode,
15858    WhatsAppChatPolicy,
15859    LineDmPolicy,
15860    LineGroupPolicy,
15861    LarkReceiveMode,
15862    OtpMethod,
15863    SandboxBackend,
15864    AutonomyLevel,
15865    AuthMode,
15866    OpenAIEndpoint,
15867    AzureEndpoint,
15868    AnthropicEndpoint,
15869    MoonshotEndpoint,
15870    QwenEndpoint,
15871    BedrockEndpoint,
15872    OpenRouterEndpoint,
15873    OllamaEndpoint,
15874    TogetherEndpoint,
15875    FireworksEndpoint,
15876    GroqEndpoint,
15877    MistralEndpoint,
15878    DeepseekEndpoint,
15879    CohereEndpoint,
15880    PerplexityEndpoint,
15881    XaiEndpoint,
15882    CerebrasEndpoint,
15883    SambanovaEndpoint,
15884    HyperbolicEndpoint,
15885    DeepinfraEndpoint,
15886    HuggingfaceEndpoint,
15887    Ai21Endpoint,
15888    RekaEndpoint,
15889    BasetenEndpoint,
15890    NscaleEndpoint,
15891    AnyscaleEndpoint,
15892    NebiusEndpoint,
15893    FriendliEndpoint,
15894    StepfunEndpoint,
15895    AihubmixEndpoint,
15896    SiliconflowEndpoint,
15897    AstraiEndpoint,
15898    AvianEndpoint,
15899    DeepmystEndpoint,
15900    VeniceEndpoint,
15901    NovitaEndpoint,
15902    NvidiaEndpoint,
15903    TelnyxEndpoint,
15904    VercelEndpoint,
15905    CloudflareEndpoint,
15906    OvhEndpoint,
15907    CopilotEndpoint,
15908    OpenAITtsEndpoint,
15909    ElevenLabsTtsEndpoint,
15910    GoogleTtsEndpoint,
15911    EdgeTtsEndpoint,
15912    PiperTtsEndpoint,
15913    GlmEndpoint,
15914    MinimaxEndpoint,
15915    ZaiEndpoint,
15916    DoubaoEndpoint,
15917    YiEndpoint,
15918    HunyuanEndpoint,
15919    QianfanEndpoint,
15920    BaichuanEndpoint,
15921    GeminiEndpoint,
15922    GeminiCliEndpoint,
15923    LmstudioEndpoint,
15924    LlamacppEndpoint,
15925    SglangEndpoint,
15926    VllmEndpoint,
15927    OsaurusEndpoint,
15928    LitellmEndpoint,
15929    LeptonEndpoint,
15930    SyntheticEndpoint,
15931    OpencodeEndpoint,
15932    KiloCliEndpoint,
15933    CustomEndpoint,
15934);
15935
15936impl HasPropKind for serde_json::Value {
15937    // `serde_json::Value` is an arbitrary JSON document, not an enum.
15938    // Classifying it as `Enum` previously made `enum_variants_for::<Value>()`
15939    // hand back the literal placeholder `"(unknown variants)"`, and the
15940    // dashboard form rendered fields like `model_providers.<key>.provider_extra`
15941    // as a single-option dropdown. `String` is the closest scalar kind —
15942    // the form renders a text input where the user pastes raw JSON.
15943    // Round-trip via `set_prop` stays correct: serde deserializes the TOML
15944    // string back into `Value::String(...)`. Power users editing complex
15945    // objects still use `zeroclaw config set --json` or hand-edit the
15946    // `config.toml`.
15947    const PROP_KIND: PropKind = PropKind::String;
15948}
15949
15950#[cfg(test)]
15951mod tests {
15952    use super::*;
15953    #[cfg(unix)]
15954    use std::os::unix::fs::PermissionsExt;
15955    use std::path::PathBuf;
15956    use tempfile::TempDir;
15957    use tokio::sync::MutexGuard;
15958    use tokio::test;
15959
15960    // ── Tilde expansion ───────────────────────────────────────
15961
15962    #[test]
15963    async fn expand_tilde_path_handles_absolute_path() {
15964        let path = expand_tilde_path("/absolute/path");
15965        assert_eq!(path, PathBuf::from("/absolute/path"));
15966    }
15967
15968    #[test]
15969    async fn expand_tilde_path_handles_relative_path() {
15970        let path = expand_tilde_path("relative/path");
15971        assert_eq!(path, PathBuf::from("relative/path"));
15972    }
15973
15974    #[test]
15975    async fn expand_tilde_path_expands_tilde_when_home_set() {
15976        // This test verifies that tilde expansion works when HOME is set.
15977        // In normal environments, HOME is set, so ~ should expand.
15978        let path = expand_tilde_path("~/.zeroclaw");
15979        // The path should not literally start with '~' if HOME is set
15980        // (it should be expanded to the actual home directory)
15981        if std::env::var("HOME").is_ok() {
15982            assert!(
15983                !path.to_string_lossy().starts_with('~'),
15984                "Tilde should be expanded when HOME is set"
15985            );
15986        }
15987    }
15988
15989    // ── Defaults ─────────────────────────────────────────────
15990
15991    fn has_test_table(raw: &str, table: &str) -> bool {
15992        let exact = format!("[{table}]");
15993        let nested = format!("[{table}.");
15994        raw.lines()
15995            .map(str::trim)
15996            .any(|line| line == exact || line.starts_with(&nested))
15997    }
15998
15999    fn parse_test_config(raw: &str) -> Config {
16000        let mut merged = raw.trim().to_string();
16001        for table in [
16002            "data_retention",
16003            "cloud_ops",
16004            "conversational_ai",
16005            "security",
16006            "security_ops",
16007        ] {
16008            if has_test_table(&merged, table) {
16009                continue;
16010            }
16011            if !merged.is_empty() {
16012                merged.push_str("\n\n");
16013            }
16014            merged.push('[');
16015            merged.push_str(table);
16016            merged.push(']');
16017        }
16018        merged.push('\n');
16019        // Schema-deserialization helper: parses TOML directly into Config
16020        // WITHOUT running migration transforms. Tests that need migration
16021        // behavior should use `migrate_to_current` directly. This helper
16022        // exists so V2-shaped inputs (e.g. flat `[autonomy]` blocks) can
16023        // be exercised against the typed deserializer without losing
16024        // sections that V2→V3 strips.
16025        let mut config: Config = toml::from_str(&merged).unwrap();
16026        config
16027            .risk_profiles
16028            .entry("default".to_string())
16029            .or_default()
16030            .ensure_default_auto_approve();
16031        config
16032    }
16033
16034    #[test]
16035    async fn http_request_config_default_has_correct_values() {
16036        let cfg = HttpRequestConfig::default();
16037        assert_eq!(cfg.timeout_secs, 30);
16038        assert_eq!(cfg.max_response_size, 1_000_000);
16039        assert!(cfg.enabled);
16040        assert_eq!(cfg.allowed_domains, vec!["*".to_string()]);
16041    }
16042
16043    #[test]
16044    async fn config_default_has_sane_values() {
16045        let c = Config::default();
16046        // No model_provider configured by default — set during onboarding.
16047        assert!(c.providers.models.is_empty());
16048        assert!(c.first_model_provider().is_none());
16049        assert!(!c.skills.open_skills_enabled);
16050        assert!(!c.skills.allow_scripts);
16051        assert!(!c.skills.install_suggestions.enabled);
16052        assert_eq!(
16053            c.skills.prompt_injection_mode,
16054            SkillsPromptInjectionMode::Full
16055        );
16056        assert!(c.data_dir.to_string_lossy().contains("data"));
16057        assert!(c.config_path.to_string_lossy().contains("config.toml"));
16058    }
16059
16060    #[test]
16061    async fn skills_install_suggestions_config_deserializes_enabled() {
16062        let c = parse_test_config(
16063            r#"
16064[skills.install_suggestions]
16065enabled = true
16066"#,
16067        );
16068
16069        assert!(c.skills.install_suggestions.enabled);
16070    }
16071
16072    #[test]
16073    async fn skills_install_suggestions_config_accepts_hyphen_alias() {
16074        let c = parse_test_config(
16075            r#"
16076[skills.install-suggestions]
16077enabled = true
16078"#,
16079        );
16080
16081        assert!(c.skills.install_suggestions.enabled);
16082    }
16083
16084    fn capture_log_events() -> tokio::sync::broadcast::Receiver<serde_json::Value> {
16085        ::zeroclaw_log::try_install_capture_subscriber();
16086        ::zeroclaw_log::subscribe_or_install()
16087    }
16088
16089    fn drain_captured(rx: &mut tokio::sync::broadcast::Receiver<serde_json::Value>) -> String {
16090        let mut buf = String::new();
16091        while let Ok(value) = rx.try_recv() {
16092            buf.push_str(&serde_json::to_string(&value).unwrap_or_default());
16093            buf.push('\n');
16094        }
16095        buf
16096    }
16097
16098    #[test]
16099    async fn config_dir_creation_error_mentions_openrc_and_path() {
16100        let msg = config_dir_creation_error(Path::new("/etc/zeroclaw"));
16101        assert!(msg.contains("/etc/zeroclaw"));
16102        assert!(msg.contains("OpenRC"));
16103        assert!(msg.contains("zeroclaw"));
16104    }
16105
16106    #[test]
16107    async fn config_schema_export_contains_expected_contract_shape() {
16108        #[cfg(feature = "schema-export")]
16109        let schema = schemars::schema_for!(Config);
16110        let schema_json = serde_json::to_value(&schema).expect("schema should serialize to json");
16111
16112        assert_eq!(
16113            schema_json
16114                .get("$schema")
16115                .and_then(serde_json::Value::as_str),
16116            Some("https://json-schema.org/draft/2020-12/schema")
16117        );
16118
16119        let properties = schema_json
16120            .get("properties")
16121            .and_then(serde_json::Value::as_object)
16122            .expect("schema should expose top-level properties");
16123
16124        assert!(properties.contains_key("providers"));
16125        assert!(properties.contains_key("skills"));
16126        assert!(properties.contains_key("gateway"));
16127        assert!(properties.contains_key("channels"));
16128        assert!(!properties.contains_key("workspace_dir"));
16129        assert!(!properties.contains_key("config_path"));
16130        assert!(!properties.contains_key("model_providers"));
16131        assert!(!properties.contains_key("tts_providers"));
16132        assert!(!properties.contains_key("transcription_providers"));
16133        // These fields are now #[serde(skip)] cache fields, not in schema.
16134        assert!(!properties.contains_key("default_model_provider"));
16135        assert!(!properties.contains_key("api_key"));
16136        assert!(!properties.contains_key("default_model"));
16137
16138        assert!(
16139            schema_json
16140                .get("$defs")
16141                .and_then(serde_json::Value::as_object)
16142                .is_some(),
16143            "schema should include reusable type definitions"
16144        );
16145    }
16146
16147    #[cfg(unix)]
16148    #[test]
16149    async fn save_sets_config_permissions_on_new_file() {
16150        let temp = TempDir::new().expect("temp dir");
16151        let config_path = temp.path().join("config.toml");
16152        let workspace_dir = temp.path().join("workspace");
16153
16154        let config = Config {
16155            config_path: config_path.clone(),
16156            data_dir: workspace_dir,
16157            ..Default::default()
16158        };
16159
16160        config.save().await.expect("save config");
16161
16162        let mode = std::fs::metadata(&config_path)
16163            .expect("config metadata")
16164            .permissions()
16165            .mode()
16166            & 0o777;
16167        assert_eq!(mode, 0o600);
16168    }
16169
16170    #[test]
16171    async fn observability_config_default() {
16172        let o = ObservabilityConfig::default();
16173        assert_eq!(o.backend, "none");
16174        assert_eq!(o.log_persistence, "rolling");
16175        assert_eq!(o.log_persistence_path, "state/runtime-trace.jsonl");
16176        assert_eq!(o.log_persistence_max_entries, 200);
16177        assert_eq!(o.log_tool_io, "redacted");
16178        assert_eq!(o.log_tool_io_truncate_bytes, 8192);
16179        assert!(o.log_tool_io_denylist.is_empty());
16180    }
16181
16182    #[test]
16183    async fn risk_profile_default_mirrors_v2_autonomy_safety_defaults() {
16184        let a = RiskProfileConfig::default();
16185        assert_eq!(a.level, AutonomyLevel::Supervised);
16186        assert!(a.workspace_only);
16187        assert!(a.allowed_commands.contains(&"git".to_string()));
16188        assert!(a.allowed_commands.contains(&"cargo".to_string()));
16189        assert!(
16190            !a.forbidden_paths.is_empty(),
16191            "default forbidden_paths must not be empty"
16192        );
16193        #[cfg(not(target_os = "windows"))]
16194        assert!(
16195            a.forbidden_paths.iter().any(|p| p == "/etc"),
16196            "Default forbidden_paths must include /etc on Unix"
16197        );
16198        #[cfg(target_os = "windows")]
16199        assert!(
16200            a.forbidden_paths.iter().any(|p| p == "C:\\Windows"),
16201            "Default forbidden_paths must include C:\\Windows on Windows"
16202        );
16203        assert!(
16204            a.forbidden_paths.contains(&"~/.ssh".to_string()),
16205            "Default forbidden_paths must include ~/.ssh"
16206        );
16207        assert!(a.require_approval_for_medium_risk);
16208        assert!(a.block_high_risk_commands);
16209        assert!(a.shell_env_passthrough.is_empty());
16210        assert!(a.allowed_tools.is_empty());
16211    }
16212
16213    #[test]
16214    async fn runtime_config_default() {
16215        let r = RuntimeConfig::default();
16216        assert_eq!(r.kind, "native");
16217        assert_eq!(r.docker.image, "alpine:3.20");
16218        assert_eq!(r.docker.network, "none");
16219        assert_eq!(r.docker.memory_limit_mb, Some(512));
16220        assert_eq!(r.docker.cpu_limit, Some(1.0));
16221        assert!(r.docker.read_only_rootfs);
16222        assert!(r.docker.mount_workspace);
16223    }
16224
16225    #[test]
16226    async fn heartbeat_config_default() {
16227        let h = HeartbeatConfig::default();
16228        // Heartbeat defaults to disabled. Enabling requires the user to
16229        // also bind it to a configured agent — there is no default agent
16230        // for heartbeat to fall through to.
16231        assert!(!h.enabled);
16232        assert!(h.agent.is_empty());
16233        assert_eq!(h.interval_minutes, 30);
16234        assert!(h.message.is_none());
16235        assert!(h.target.is_none());
16236        assert!(h.to.is_none());
16237    }
16238
16239    #[test]
16240    async fn heartbeat_config_parses_delivery_aliases() {
16241        let raw = r#"
16242enabled = true
16243interval_minutes = 10
16244message = "Ping"
16245channel = "telegram"
16246recipient = "42"
16247"#;
16248        let parsed: HeartbeatConfig = toml::from_str(raw).unwrap();
16249        assert!(parsed.enabled);
16250        assert_eq!(parsed.interval_minutes, 10);
16251        assert_eq!(parsed.message.as_deref(), Some("Ping"));
16252        assert_eq!(parsed.target.as_deref(), Some("telegram"));
16253        assert_eq!(parsed.to.as_deref(), Some("42"));
16254    }
16255
16256    #[test]
16257    async fn scheduler_config_default() {
16258        let s = SchedulerConfig::default();
16259        assert!(s.enabled);
16260        assert!(s.catch_up_on_startup);
16261        assert_eq!(s.max_run_history, 50);
16262    }
16263
16264    #[test]
16265    async fn scheduler_config_serde_roundtrip() {
16266        let s = SchedulerConfig {
16267            enabled: false,
16268            max_tasks: 16,
16269            max_concurrent: 2,
16270            catch_up_on_startup: false,
16271            max_run_history: 100,
16272        };
16273        let json = serde_json::to_string(&s).unwrap();
16274        let parsed: SchedulerConfig = serde_json::from_str(&json).unwrap();
16275        assert!(!parsed.enabled);
16276        assert!(!parsed.catch_up_on_startup);
16277        assert_eq!(parsed.max_run_history, 100);
16278    }
16279
16280    #[test]
16281    async fn config_defaults_scheduler_when_section_missing() {
16282        let toml_str = r#"
16283workspace_dir = "/tmp/workspace"
16284config_path = "/tmp/config.toml"
16285default_temperature = 0.7
16286"#;
16287
16288        let parsed = parse_test_config(toml_str);
16289        assert!(parsed.scheduler.enabled);
16290        assert!(parsed.scheduler.catch_up_on_startup);
16291        assert_eq!(parsed.scheduler.max_run_history, 50);
16292        assert!(parsed.cron.is_empty());
16293    }
16294
16295    #[test]
16296    async fn memory_config_default_hygiene_settings() {
16297        let m = MemoryConfig::default();
16298        assert_eq!(m.backend, "sqlite");
16299        assert!(m.auto_save);
16300        assert!(m.hygiene_enabled);
16301        assert_eq!(m.archive_after_days, 7);
16302        assert_eq!(m.purge_after_days, 30);
16303        assert_eq!(m.conversation_retention_days, 30);
16304        assert_eq!(m.search_mode, SearchMode::Hybrid);
16305    }
16306
16307    #[test]
16308    async fn search_mode_config_deserialization() {
16309        let toml_str = r#"
16310workspace_dir = "/tmp/workspace"
16311config_path = "/tmp/config.toml"
16312default_temperature = 0.7
16313
16314[memory]
16315backend = "sqlite"
16316auto_save = true
16317search_mode = "bm25"
16318"#;
16319        let parsed = parse_test_config(toml_str);
16320        assert_eq!(parsed.memory.search_mode, SearchMode::Bm25);
16321
16322        let toml_str_embedding = r#"
16323workspace_dir = "/tmp/workspace"
16324config_path = "/tmp/config.toml"
16325default_temperature = 0.7
16326
16327[memory]
16328backend = "sqlite"
16329auto_save = true
16330search_mode = "embedding"
16331"#;
16332        let parsed = parse_test_config(toml_str_embedding);
16333        assert_eq!(parsed.memory.search_mode, SearchMode::Embedding);
16334
16335        let toml_str_hybrid = r#"
16336workspace_dir = "/tmp/workspace"
16337config_path = "/tmp/config.toml"
16338default_temperature = 0.7
16339
16340[memory]
16341backend = "sqlite"
16342auto_save = true
16343search_mode = "hybrid"
16344"#;
16345        let parsed = parse_test_config(toml_str_hybrid);
16346        assert_eq!(parsed.memory.search_mode, SearchMode::Hybrid);
16347    }
16348
16349    #[test]
16350    async fn search_mode_defaults_to_hybrid_when_omitted() {
16351        let toml_str = r#"
16352workspace_dir = "/tmp/workspace"
16353config_path = "/tmp/config.toml"
16354default_temperature = 0.7
16355
16356[memory]
16357backend = "sqlite"
16358auto_save = true
16359"#;
16360        let parsed = parse_test_config(toml_str);
16361        assert_eq!(parsed.memory.search_mode, SearchMode::Hybrid);
16362    }
16363
16364    #[test]
16365    async fn search_mode_serde_roundtrip() {
16366        let json_bm25 = serde_json::to_string(&SearchMode::Bm25).unwrap();
16367        assert_eq!(json_bm25, "\"bm25\"");
16368        let parsed: SearchMode = serde_json::from_str(&json_bm25).unwrap();
16369        assert_eq!(parsed, SearchMode::Bm25);
16370
16371        let json_embedding = serde_json::to_string(&SearchMode::Embedding).unwrap();
16372        assert_eq!(json_embedding, "\"embedding\"");
16373        let parsed: SearchMode = serde_json::from_str(&json_embedding).unwrap();
16374        assert_eq!(parsed, SearchMode::Embedding);
16375
16376        let json_hybrid = serde_json::to_string(&SearchMode::Hybrid).unwrap();
16377        assert_eq!(json_hybrid, "\"hybrid\"");
16378        let parsed: SearchMode = serde_json::from_str(&json_hybrid).unwrap();
16379        assert_eq!(parsed, SearchMode::Hybrid);
16380    }
16381
16382    #[test]
16383    async fn storage_two_tier_defaults_empty() {
16384        let storage = StorageConfig::default();
16385        assert!(storage.sqlite.is_empty());
16386        assert!(storage.postgres.is_empty());
16387        assert!(storage.qdrant.is_empty());
16388        assert!(storage.markdown.is_empty());
16389        assert!(storage.lucid.is_empty());
16390    }
16391
16392    #[test]
16393    async fn storage_postgres_alias_pgvector_roundtrip() {
16394        let toml = r#"
16395            [postgres.default]
16396            db_url = "postgres://user:pw@host/db"
16397            vector_enabled = true
16398            vector_dimensions = 768
16399        "#;
16400        let parsed: StorageConfig = toml::from_str(toml).unwrap();
16401        let pg = parsed.postgres.get("default").expect("alias present");
16402        assert_eq!(pg.db_url.as_deref(), Some("postgres://user:pw@host/db"));
16403        assert!(pg.vector_enabled);
16404        assert_eq!(pg.vector_dimensions, 768);
16405    }
16406
16407    #[test]
16408    async fn storage_postgres_pgvector_defaults_when_omitted() {
16409        let toml = r#"
16410            [postgres.default]
16411        "#;
16412        let parsed: StorageConfig = toml::from_str(toml).unwrap();
16413        let pg = parsed.postgres.get("default").expect("alias present");
16414        assert!(!pg.vector_enabled);
16415        assert_eq!(pg.vector_dimensions, 1536);
16416        assert_eq!(pg.schema, "public");
16417        assert_eq!(pg.table, "memories");
16418    }
16419
16420    #[test]
16421    async fn ollama_alias_tuning_fields_roundtrip() {
16422        // Ollama-specific tuning lives on `OllamaModelProviderConfig`,
16423        // not on the generic `ModelProviderConfig` base. These knobs
16424        // ride alongside the flattened `base` so a TOML alias like
16425        // `[model_providers.ollama.local]` accepts them at the same
16426        // level as `model`, `api_key`, etc.
16427        let toml = r#"
16428            num_ctx = 16384
16429            num_predict = 4096
16430            temperature_override = 0.5
16431        "#;
16432        let parsed: OllamaModelProviderConfig = toml::from_str(toml).unwrap();
16433        assert_eq!(parsed.num_ctx, Some(16384));
16434        assert_eq!(parsed.num_predict, Some(4096));
16435        assert_eq!(parsed.temperature_override, Some(0.5));
16436
16437        let serialized = toml::to_string(&parsed).unwrap();
16438        let reparsed: OllamaModelProviderConfig = toml::from_str(&serialized).unwrap();
16439        assert_eq!(reparsed.num_ctx, Some(16384));
16440        assert_eq!(reparsed.num_predict, Some(4096));
16441        assert_eq!(reparsed.temperature_override, Some(0.5));
16442    }
16443
16444    #[test]
16445    async fn ollama_alias_tuning_fields_default_to_none() {
16446        let toml = r#"
16447            api_key = "sk-test"
16448        "#;
16449        let parsed: OllamaModelProviderConfig = toml::from_str(toml).unwrap();
16450        assert!(parsed.num_ctx.is_none());
16451        assert!(parsed.num_predict.is_none());
16452        assert!(parsed.temperature_override.is_none());
16453    }
16454
16455    #[test]
16456    async fn channels_default() {
16457        let c = ChannelsConfig::default();
16458        assert!(c.cli);
16459        assert!(c.telegram.is_empty());
16460        assert!(c.discord.is_empty());
16461        assert!(c.wecom_ws.is_empty());
16462        assert!(!c.show_tool_calls);
16463    }
16464
16465    #[test]
16466    async fn wecom_ws_config_serde_defaults_and_secret_metadata() {
16467        let toml = r#"
16468            enabled = true
16469            bot_id = "bot-123"
16470            secret = "sk-test"
16471            allowed_users = ["zeroclaw_user"]
16472            allowed_groups = ["zeroclaw_group"]
16473            bot_name = "danya"
16474            proxy_url = "http://127.0.0.1:7890"
16475        "#;
16476        let parsed: WeComWsConfig = toml::from_str(toml).unwrap();
16477
16478        assert!(parsed.enabled);
16479        assert_eq!(parsed.bot_id, "bot-123");
16480        assert_eq!(parsed.secret, "sk-test");
16481        assert_eq!(parsed.allowed_users, vec!["zeroclaw_user"]);
16482        assert_eq!(parsed.allowed_groups, vec!["zeroclaw_group"]);
16483        assert_eq!(parsed.bot_name.as_deref(), Some("danya"));
16484        assert_eq!(parsed.file_retention_days, 7);
16485        assert_eq!(parsed.max_file_size_mb, 20);
16486        assert_eq!(parsed.stream_mode, StreamMode::Partial);
16487        assert_eq!(parsed.proxy_url.as_deref(), Some("http://127.0.0.1:7890"));
16488        assert!(parsed.excluded_tools.is_empty());
16489        assert_eq!(WeComWsConfig::default().file_retention_days, 7);
16490        assert_eq!(WeComWsConfig::default().max_file_size_mb, 20);
16491        assert_eq!(WeComWsConfig::default().stream_mode, StreamMode::Partial);
16492        assert!(WeComWsConfig::default().bot_name.is_none());
16493        assert!(WeComWsConfig::default().proxy_url.is_none());
16494        assert!(WeComWsConfig::prop_is_secret("channels.wecom_ws.secret"));
16495    }
16496
16497    #[test]
16498    async fn config_parses_wecom_ws_separate_from_wecom_webhook() {
16499        let toml = r#"
16500            [channels.wecom.default]
16501            enabled = true
16502            webhook_key = "webhook-key"
16503
16504            [channels.wecom_ws.default]
16505            enabled = true
16506            bot_id = "bot-123"
16507            secret = "sk-test"
16508            allowed_users = ["zeroclaw_user"]
16509        "#;
16510        let parsed: Config = toml::from_str(toml).unwrap();
16511
16512        assert_eq!(
16513            parsed.channels.wecom.get("default").unwrap().webhook_key,
16514            "webhook-key"
16515        );
16516        let ws = parsed.channels.wecom_ws.get("default").unwrap();
16517        assert_eq!(ws.bot_id, "bot-123");
16518        assert_eq!(ws.allowed_users, vec!["zeroclaw_user"]);
16519        assert_eq!(ws.stream_mode, StreamMode::Partial);
16520    }
16521
16522    // ── Serde round-trip ─────────────────────────────────────
16523
16524    #[test]
16525    async fn config_toml_roundtrip() {
16526        let config = Config {
16527            schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
16528            providers: {
16529                let mut p = crate::providers::Providers::default();
16530                p.models.openrouter.insert(
16531                    "default".to_string(),
16532                    OpenRouterModelProviderConfig {
16533                        base: ModelProviderConfig {
16534                            api_key: Some("sk-test-key".into()),
16535                            model: Some("gpt-4o".into()),
16536                            temperature: Some(0.5),
16537                            timeout_secs: Some(120),
16538                            ..Default::default()
16539                        },
16540                    },
16541                );
16542                p
16543            },
16544            model_routes: Vec::new(),
16545            embedding_routes: Vec::new(),
16546            data_dir: PathBuf::from("/tmp/test/workspace"),
16547            config_path: PathBuf::from("/tmp/test/config.toml"),
16548            observability: ObservabilityConfig {
16549                backend: "log".into(),
16550                ..ObservabilityConfig::default()
16551            },
16552            risk_profiles: {
16553                let mut m = HashMap::new();
16554                m.insert(
16555                    "default".into(),
16556                    RiskProfileConfig {
16557                        level: AutonomyLevel::Full,
16558                        workspace_only: false,
16559                        allowed_commands: vec!["docker".into()],
16560                        forbidden_paths: vec!["/secret".into()],
16561                        require_approval_for_medium_risk: false,
16562                        block_high_risk_commands: true,
16563                        shell_env_passthrough: vec!["DATABASE_URL".into()],
16564                        auto_approve: vec!["file_read".into()],
16565                        always_ask: vec![],
16566                        allowed_roots: vec![],
16567                        allowed_tools: vec![],
16568                        excluded_tools: vec![],
16569                        ..RiskProfileConfig::default()
16570                    },
16571                );
16572                m
16573            },
16574            trust: crate::scattered_types::TrustConfig::default(),
16575            backup: BackupConfig::default(),
16576            data_retention: DataRetentionConfig::default(),
16577            cloud_ops: CloudOpsConfig::default(),
16578            conversational_ai: ConversationalAiConfig::default(),
16579            security: SecurityConfig::default(),
16580            security_ops: SecurityOpsConfig::default(),
16581            runtime: RuntimeConfig {
16582                kind: "docker".into(),
16583                ..RuntimeConfig::default()
16584            },
16585            reliability: ReliabilityConfig::default(),
16586            scheduler: SchedulerConfig::default(),
16587            skills: SkillsConfig::default(),
16588            pipeline: PipelineConfig::default(),
16589            query_classification: QueryClassificationConfig::default(),
16590            heartbeat: HeartbeatConfig {
16591                enabled: true,
16592                interval_minutes: 15,
16593                two_phase: true,
16594                message: Some("Check London time".into()),
16595                target: Some("telegram".into()),
16596                to: Some("123456".into()),
16597                ..HeartbeatConfig::default()
16598            },
16599            cron: HashMap::new(),
16600            acp: AcpConfig::default(),
16601            channels: ChannelsConfig {
16602                cli: true,
16603                telegram: HashMap::from([(
16604                    "default".to_string(),
16605                    TelegramConfig {
16606                        enabled: true,
16607                        bot_token: "123:ABC".into(),
16608                        stream_mode: StreamMode::default(),
16609                        draft_update_interval_ms: default_draft_update_interval_ms(),
16610                        interrupt_on_new_message: false,
16611                        mention_only: false,
16612                        ack_reactions: None,
16613                        proxy_url: None,
16614                        approval_timeout_secs: default_telegram_approval_timeout_secs(),
16615                        excluded_tools: vec![],
16616                        default_target: None,
16617                    },
16618                )]),
16619                discord: HashMap::new(),
16620                slack: HashMap::new(),
16621                mattermost: HashMap::new(),
16622                webhook: HashMap::new(),
16623                imessage: HashMap::new(),
16624                matrix: HashMap::new(),
16625                signal: HashMap::new(),
16626                whatsapp: HashMap::new(),
16627                linq: HashMap::new(),
16628                wati: HashMap::new(),
16629                nextcloud_talk: HashMap::new(),
16630                email: HashMap::new(),
16631                gmail_push: HashMap::new(),
16632                irc: HashMap::new(),
16633                lark: HashMap::new(),
16634                line: HashMap::new(),
16635                dingtalk: HashMap::new(),
16636                wecom: HashMap::new(),
16637                wecom_ws: HashMap::new(),
16638                wechat: HashMap::new(),
16639                qq: HashMap::new(),
16640                twitter: HashMap::new(),
16641                mochat: HashMap::new(),
16642                nostr: HashMap::new(),
16643                clawdtalk: HashMap::new(),
16644                reddit: HashMap::new(),
16645                bluesky: HashMap::new(),
16646                voice_call: HashMap::new(),
16647                voice_duplex: HashMap::new(),
16648                voice_wake: HashMap::new(),
16649                mqtt: HashMap::new(),
16650                message_timeout_secs: 300,
16651                ack_reactions: true,
16652                show_tool_calls: true,
16653                session_persistence: true,
16654                session_backend: default_session_backend(),
16655                session_ttl_hours: 0,
16656                debounce_ms: 0,
16657            },
16658            memory: MemoryConfig::default(),
16659            storage: StorageConfig::default(),
16660            tunnel: TunnelConfig::default(),
16661            gateway: GatewayConfig::default(),
16662            composio: ComposioConfig::default(),
16663            microsoft365: Microsoft365Config::default(),
16664            secrets: SecretsConfig::default(),
16665            browser: BrowserConfig::default(),
16666            browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
16667            http_request: HttpRequestConfig::default(),
16668            multimodal: MultimodalConfig::default(),
16669            media_pipeline: MediaPipelineConfig::default(),
16670            web_fetch: WebFetchConfig::default(),
16671            link_enricher: LinkEnricherConfig::default(),
16672            text_browser: TextBrowserConfig::default(),
16673            web_search: WebSearchConfig::default(),
16674            project_intel: ProjectIntelConfig::default(),
16675            google_workspace: GoogleWorkspaceConfig::default(),
16676            proxy: ProxyConfig::default(),
16677            pacing: PacingConfig::default(),
16678            cost: CostConfig::default(),
16679            peripherals: PeripheralsConfig::default(),
16680            delegate: DelegateToolConfig::default(),
16681            agents: HashMap::new(),
16682            runtime_profiles: HashMap::new(),
16683            skill_bundles: HashMap::new(),
16684            knowledge_bundles: HashMap::new(),
16685            mcp_bundles: HashMap::new(),
16686            peer_groups: HashMap::new(),
16687            hooks: HooksConfig::default(),
16688            hardware: HardwareConfig::default(),
16689            transcription: TranscriptionConfig::default(),
16690            tts: TtsConfig::default(),
16691            mcp: McpConfig::default(),
16692            nodes: NodesConfig::default(),
16693            onboard_state: OnboardStateConfig::default(),
16694            notion: NotionConfig::default(),
16695            jira: JiraConfig::default(),
16696            node_transport: NodeTransportConfig::default(),
16697            knowledge: KnowledgeConfig::default(),
16698            linkedin: LinkedInConfig::default(),
16699            image_gen: ImageGenConfig::default(),
16700            file_upload: FileUploadConfig::default(),
16701            file_upload_bundle: FileUploadBundleConfig::default(),
16702            file_download: FileDownloadConfig::default(),
16703            plugins: PluginsConfig::default(),
16704            locale: None,
16705            verifiable_intent: VerifiableIntentConfig::default(),
16706            claude_code: ClaudeCodeConfig::default(),
16707            claude_code_runner: ClaudeCodeRunnerConfig::default(),
16708            codex_cli: CodexCliConfig::default(),
16709            gemini_cli: GeminiCliConfig::default(),
16710            opencode_cli: OpenCodeCliConfig::default(),
16711            sop: SopConfig::default(),
16712            shell_tool: ShellToolConfig::default(),
16713            escalation: EscalationConfig::default(),
16714            env_overridden_paths: std::collections::HashSet::new(),
16715            pre_override_snapshots: std::collections::HashMap::new(),
16716            dirty_paths: std::collections::HashSet::new(),
16717        };
16718        // ModelProvider fields are now resolved directly — no cache needed.
16719
16720        let toml_str = toml::to_string_pretty(&config).unwrap();
16721        let parsed = parse_test_config(&toml_str);
16722
16723        assert_eq!(parsed.providers.models.len(), config.providers.models.len());
16724        assert_eq!(parsed.observability.backend, "log");
16725        assert_eq!(parsed.observability.log_persistence, "rolling");
16726        let default_profile = parsed.risk_profiles.get("default").unwrap();
16727        assert_eq!(default_profile.level, AutonomyLevel::Full);
16728        assert!(!default_profile.workspace_only);
16729        assert_eq!(parsed.runtime.kind, "docker");
16730        assert!(parsed.heartbeat.enabled);
16731        assert_eq!(parsed.heartbeat.interval_minutes, 15);
16732        assert_eq!(
16733            parsed.heartbeat.message.as_deref(),
16734            Some("Check London time")
16735        );
16736        assert_eq!(parsed.heartbeat.target.as_deref(), Some("telegram"));
16737        assert_eq!(parsed.heartbeat.to.as_deref(), Some("123456"));
16738        assert!(!parsed.channels.telegram.is_empty());
16739        assert_eq!(
16740            parsed.channels.telegram.get("default").unwrap().bot_token,
16741            "123:ABC"
16742        );
16743    }
16744
16745    #[test]
16746    async fn config_minimal_toml_uses_defaults() {
16747        let minimal = r#"
16748workspace_dir = "/tmp/ws"
16749config_path = "/tmp/config.toml"
16750default_temperature = 0.7
16751"#;
16752        let parsed = parse_test_config(minimal);
16753        assert!(
16754            parsed
16755                .first_model_provider()
16756                .and_then(|e| e.api_key.as_deref())
16757                .is_none()
16758        );
16759        assert_eq!(parsed.observability.backend, "none");
16760        assert_eq!(parsed.observability.log_persistence, "rolling");
16761        // Migration synthesizes risk_profiles.default from the legacy
16762        // [autonomy] block; assert against the named entry rather than a
16763        // global "active" profile (no such concept exists).
16764        assert_eq!(
16765            parsed
16766                .risk_profiles
16767                .get("default")
16768                .expect("migration synthesized risk_profiles.default")
16769                .level,
16770            AutonomyLevel::Supervised
16771        );
16772        assert_eq!(parsed.runtime.kind, "native");
16773        // Heartbeat defaults to disabled.
16774        assert!(!parsed.heartbeat.enabled);
16775        assert!(parsed.channels.cli);
16776        assert!(parsed.memory.hygiene_enabled);
16777        assert_eq!(parsed.memory.archive_after_days, 7);
16778        assert_eq!(parsed.memory.purge_after_days, 30);
16779        assert_eq!(parsed.memory.conversation_retention_days, 30);
16780        // Temperature migrated onto the primary model_provider entry
16781        assert!(
16782            (parsed
16783                .first_model_provider()
16784                .and_then(|e| e.temperature)
16785                .unwrap_or(0.7)
16786                - 0.7)
16787                .abs()
16788                < f64::EPSILON
16789        );
16790        assert_eq!(
16791            parsed
16792                .first_model_provider()
16793                .and_then(|e| e.timeout_secs)
16794                .unwrap_or(120),
16795            DEFAULT_DELEGATE_TIMEOUT_SECS
16796        );
16797    }
16798
16799    /// `[autonomy]` migrates onto `[risk_profiles.default]` via the V2→V3
16800    /// migration. The fields must round-trip without being silently dropped.
16801    #[test]
16802    async fn v2_autonomy_section_migrates_onto_risk_profiles_default() {
16803        let raw = r#"
16804schema_version = 2
16805default_temperature = 0.7
16806
16807[autonomy]
16808level = "full"
16809max_actions_per_hour = 99
16810auto_approve = ["file_read", "memory_recall", "http_request"]
16811"#;
16812        let parsed = crate::migration::migrate_to_current(raw).unwrap();
16813        let profile = parsed
16814            .risk_profiles
16815            .get("default")
16816            .expect("default profile");
16817        assert_eq!(profile.level, AutonomyLevel::Full);
16818        assert!(profile.auto_approve.contains(&"http_request".to_string()));
16819        let runtime = parsed
16820            .runtime_profiles
16821            .get("default")
16822            .expect("default runtime profile");
16823        assert_eq!(runtime.max_actions_per_hour, 99);
16824    }
16825
16826    /// Regression test for #4247: when a user provides a custom auto_approve
16827    /// list, the built-in defaults must still be present.
16828    #[test]
16829    async fn auto_approve_merges_user_entries_with_defaults() {
16830        let raw = r#"
16831default_temperature = 0.7
16832
16833[risk_profiles.default]
16834auto_approve = ["my_custom_tool", "another_tool"]
16835"#;
16836        let parsed = parse_test_config(raw);
16837        let profile = parsed.risk_profiles.get("default").unwrap();
16838        assert!(profile.auto_approve.contains(&"my_custom_tool".to_string()));
16839        assert!(profile.auto_approve.contains(&"another_tool".to_string()));
16840        for default_tool in &[
16841            "file_read",
16842            "memory_recall",
16843            "weather",
16844            "calculator",
16845            "web_fetch",
16846        ] {
16847            assert!(
16848                profile.auto_approve.contains(&String::from(*default_tool)),
16849                "default tool '{default_tool}' must be present"
16850            );
16851        }
16852    }
16853
16854    /// Regression test: empty auto_approve still gets defaults merged.
16855    #[test]
16856    async fn auto_approve_empty_list_gets_defaults() {
16857        let raw = r#"
16858default_temperature = 0.7
16859
16860[risk_profiles.default]
16861auto_approve = []
16862"#;
16863        let parsed = parse_test_config(raw);
16864        let profile = parsed.risk_profiles.get("default").unwrap();
16865        for tool in &default_auto_approve() {
16866            assert!(
16867                profile.auto_approve.contains(tool),
16868                "default tool '{tool}' must be present"
16869            );
16870        }
16871    }
16872
16873    /// When no risk_profiles section is provided, defaults are applied to the
16874    /// synthesized "default" profile.
16875    #[test]
16876    async fn auto_approve_defaults_when_no_risk_profile_section() {
16877        let raw = r#"
16878default_temperature = 0.7
16879"#;
16880        let parsed = parse_test_config(raw);
16881        let profile = parsed.risk_profiles.get("default").unwrap();
16882        for tool in &default_auto_approve() {
16883            assert!(
16884                profile.auto_approve.contains(tool),
16885                "default tool '{tool}' must be present"
16886            );
16887        }
16888    }
16889
16890    /// Duplicates are not introduced when ensure_default_auto_approve runs
16891    /// on a list that already contains the defaults.
16892    #[test]
16893    async fn auto_approve_no_duplicates() {
16894        let raw = r#"
16895default_temperature = 0.7
16896
16897[risk_profiles.default]
16898auto_approve = ["weather", "file_read"]
16899"#;
16900        let parsed = parse_test_config(raw);
16901        let profile = parsed.risk_profiles.get("default").unwrap();
16902        assert_eq!(
16903            profile
16904                .auto_approve
16905                .iter()
16906                .filter(|t| *t == "weather")
16907                .count(),
16908            1
16909        );
16910        assert_eq!(
16911            profile
16912                .auto_approve
16913                .iter()
16914                .filter(|t| *t == "file_read")
16915                .count(),
16916            1
16917        );
16918    }
16919
16920    #[test]
16921    async fn provider_timeout_secs_parses_from_toml() {
16922        // V1 top-level `provider_timeout_secs` is folded into the
16923        // synthesized model_provider entry's `timeout_secs`.
16924        let raw = r#"
16925default_temperature = 0.7
16926provider_timeout_secs = 300
16927"#;
16928        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
16929        assert_eq!(
16930            parsed
16931                .first_model_provider()
16932                .and_then(|e| e.timeout_secs)
16933                .unwrap_or(120),
16934            300
16935        );
16936    }
16937
16938    #[test]
16939    async fn extra_headers_parses_from_toml() {
16940        // V1 top-level `[extra_headers]` is folded into the synthesized
16941        // default model_provider entry's `extra_headers` map.
16942        let raw = r#"
16943default_temperature = 0.7
16944
16945[extra_headers]
16946User-Agent = "MyApp/1.0"
16947X-Title = "zeroclaw"
16948"#;
16949        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
16950        let headers = &parsed
16951            .first_model_provider()
16952            .expect("synthesized default model_provider")
16953            .extra_headers;
16954        assert_eq!(headers.len(), 2);
16955        assert_eq!(headers.get("User-Agent").unwrap(), "MyApp/1.0");
16956        assert_eq!(headers.get("X-Title").unwrap(), "zeroclaw");
16957    }
16958
16959    #[test]
16960    async fn extra_headers_defaults_to_empty() {
16961        let raw = r#"
16962default_temperature = 0.7
16963"#;
16964        let parsed = parse_test_config(raw);
16965        assert!(
16966            parsed
16967                .first_model_provider()
16968                .map(|e| e.extra_headers.is_empty())
16969                .unwrap_or(true)
16970        );
16971    }
16972
16973    #[test]
16974    async fn storage_postgres_dburl_alias_deserializes() {
16975        let raw = r#"
16976default_temperature = 0.7
16977
16978[storage.postgres.default]
16979dbURL = "postgres://user:pw@host/db"
16980schema = "public"
16981table = "memories"
16982connect_timeout_secs = 12
16983"#;
16984
16985        let parsed = parse_test_config(raw);
16986        let pg = parsed
16987            .storage
16988            .postgres
16989            .get("default")
16990            .expect("postgres.default present");
16991        assert_eq!(pg.db_url.as_deref(), Some("postgres://user:pw@host/db"));
16992        assert_eq!(pg.schema, "public");
16993        assert_eq!(pg.table, "memories");
16994        assert_eq!(pg.connect_timeout_secs, Some(12));
16995    }
16996
16997    #[test]
16998    async fn runtime_reasoning_enabled_deserializes() {
16999        let raw = r#"
17000default_temperature = 0.7
17001
17002[runtime]
17003reasoning_enabled = false
17004"#;
17005
17006        let parsed = parse_test_config(raw);
17007        assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
17008    }
17009
17010    #[test]
17011    async fn runtime_reasoning_effort_deserializes() {
17012        let raw = r#"
17013default_temperature = 0.7
17014
17015[runtime]
17016reasoning_effort = "HIGH"
17017"#;
17018
17019        let parsed: Config = toml::from_str(raw).unwrap();
17020        assert_eq!(parsed.runtime.reasoning_effort.as_deref(), Some("high"));
17021    }
17022
17023    #[test]
17024    async fn runtime_reasoning_effort_rejects_invalid_values() {
17025        let raw = r#"
17026default_temperature = 0.7
17027
17028[runtime]
17029reasoning_effort = "turbo"
17030"#;
17031
17032        let error = toml::from_str::<Config>(raw).expect_err("invalid value should fail");
17033        assert!(error.to_string().contains("reasoning_effort"));
17034    }
17035
17036    #[test]
17037    async fn agent_config_defaults() {
17038        let cfg = AliasedAgentConfig::default();
17039        assert!(cfg.compact_context);
17040        assert_eq!(cfg.max_tool_iterations, 10);
17041        assert_eq!(cfg.max_history_messages, 50);
17042        assert!(!cfg.parallel_tools);
17043        assert_eq!(cfg.tool_dispatcher, "auto");
17044        assert!(!cfg.strict_tool_parsing);
17045    }
17046
17047    #[test]
17048    async fn agent_config_deserializes() {
17049        let raw = r#"
17050default_temperature = 0.7
17051[agents.default]
17052compact_context = true
17053max_tool_iterations = 20
17054max_history_messages = 80
17055parallel_tools = true
17056tool_dispatcher = "xml"
17057strict_tool_parsing = true
17058"#;
17059        let parsed = parse_test_config(raw);
17060        let agent = parsed
17061            .agents
17062            .get("default")
17063            .expect("[agents.default] parses into agents map");
17064        assert!(agent.compact_context);
17065        assert_eq!(agent.max_tool_iterations, 20);
17066        assert_eq!(agent.max_history_messages, 80);
17067        assert!(agent.parallel_tools);
17068        assert_eq!(agent.tool_dispatcher, "xml");
17069        assert!(agent.strict_tool_parsing);
17070    }
17071
17072    #[test]
17073    async fn pacing_config_defaults_are_all_none_or_empty() {
17074        let cfg = PacingConfig::default();
17075        assert!(cfg.step_timeout_secs.is_none());
17076        assert!(cfg.loop_detection_min_elapsed_secs.is_none());
17077        assert!(cfg.loop_ignore_tools.is_empty());
17078        assert!(cfg.message_timeout_scale_max.is_none());
17079    }
17080
17081    #[test]
17082    async fn pacing_config_deserializes_from_toml() {
17083        let raw = r#"
17084default_temperature = 0.7
17085[pacing]
17086step_timeout_secs = 120
17087loop_detection_min_elapsed_secs = 60
17088loop_ignore_tools = ["browser_screenshot", "browser_navigate"]
17089message_timeout_scale_max = 8
17090"#;
17091        let parsed: Config = toml::from_str(raw).unwrap();
17092        assert_eq!(parsed.pacing.step_timeout_secs, Some(120));
17093        assert_eq!(parsed.pacing.loop_detection_min_elapsed_secs, Some(60));
17094        assert_eq!(
17095            parsed.pacing.loop_ignore_tools,
17096            vec!["browser_screenshot", "browser_navigate"]
17097        );
17098        assert_eq!(parsed.pacing.message_timeout_scale_max, Some(8));
17099    }
17100
17101    #[test]
17102    async fn pacing_config_absent_preserves_defaults() {
17103        let raw = r#"
17104default_temperature = 0.7
17105"#;
17106        let parsed: Config = toml::from_str(raw).unwrap();
17107        assert!(parsed.pacing.step_timeout_secs.is_none());
17108        assert!(parsed.pacing.loop_detection_min_elapsed_secs.is_none());
17109        assert!(parsed.pacing.loop_ignore_tools.is_empty());
17110        assert!(parsed.pacing.message_timeout_scale_max.is_none());
17111    }
17112
17113    #[tokio::test]
17114    async fn sync_directory_handles_existing_directory() {
17115        let dir = std::env::temp_dir().join(format!(
17116            "zeroclaw_test_sync_directory_{}",
17117            uuid::Uuid::new_v4()
17118        ));
17119        fs::create_dir_all(&dir).await.unwrap();
17120
17121        sync_directory(&dir).await.unwrap();
17122
17123        let _ = fs::remove_dir_all(&dir).await;
17124    }
17125
17126    #[tokio::test]
17127    async fn config_save_prunes_unchanged_default_blocks() {
17128        // Fresh-init config without any operator edits should write a
17129        // tiny config.toml — only `schema_version` and any operator-
17130        // touched fields. The hundreds of all-default blocks
17131        // (LinkedIn, memory, observability, etc.) must not appear.
17132        let dir =
17133            std::env::temp_dir().join(format!("zeroclaw_save_prune_test_{}", uuid::Uuid::new_v4()));
17134        fs::create_dir_all(&dir).await.unwrap();
17135        let config = Config {
17136            config_path: dir.join("config.toml"),
17137            data_dir: dir.join("data"),
17138            ..Default::default()
17139        };
17140        config.save().await.unwrap();
17141        let raw = fs::read_to_string(&config.config_path).await.unwrap();
17142
17143        // schema_version must always survive (migration detector
17144        // anchor); without it a re-load would mis-detect as V1.
17145        assert!(
17146            raw.contains("schema_version"),
17147            "schema_version must survive pruning"
17148        );
17149
17150        // Defaulted nested struct blocks must NOT appear in a fresh
17151        // save. Pick representative samples from across the schema:
17152        for block in [
17153            "[memory]",
17154            "[linkedin",
17155            "[observability]",
17156            "[gateway]",
17157            "[cost]",
17158        ] {
17159            assert!(
17160                !raw.contains(block),
17161                "pruned config.toml must not emit defaulted block {block}; got:\n{raw}",
17162            );
17163        }
17164
17165        // Round-trip: load the pruned config and verify it still
17166        // deserializes to a `Config` (schema defaults fill the gaps).
17167        let _reloaded: Config = toml::from_str(&raw).expect("pruned config round-trips");
17168
17169        let _ = fs::remove_dir_all(&dir).await;
17170    }
17171
17172    #[tokio::test]
17173    async fn config_save_keeps_operator_set_non_default_fields() {
17174        let dir =
17175            std::env::temp_dir().join(format!("zeroclaw_save_keep_test_{}", uuid::Uuid::new_v4()));
17176        fs::create_dir_all(&dir).await.unwrap();
17177        let mut config = Config {
17178            config_path: dir.join("config.toml"),
17179            data_dir: dir.join("data"),
17180            ..Default::default()
17181        };
17182        // Operator picked a non-default locale + provider entry.
17183        config.locale = Some("ja-JP".into());
17184        config.providers.models.anthropic.insert(
17185            "claude_default".into(),
17186            AnthropicModelProviderConfig {
17187                base: ModelProviderConfig {
17188                    model: Some("claude-sonnet-4".into()),
17189                    ..Default::default()
17190                },
17191            },
17192        );
17193        config.save().await.unwrap();
17194        let raw = fs::read_to_string(&config.config_path).await.unwrap();
17195
17196        assert!(
17197            raw.contains("ja-JP"),
17198            "operator-set locale must survive pruning; got:\n{raw}",
17199        );
17200        assert!(
17201            raw.contains("claude_default"),
17202            "operator-added provider alias must survive pruning; got:\n{raw}",
17203        );
17204        assert!(
17205            raw.contains("claude-sonnet-4"),
17206            "operator-set model must survive pruning; got:\n{raw}",
17207        );
17208
17209        let _ = fs::remove_dir_all(&dir).await;
17210    }
17211
17212    #[tokio::test]
17213    async fn config_save_and_load_tmpdir() {
17214        let dir = std::env::temp_dir().join("zeroclaw_test_config");
17215        let _ = fs::remove_dir_all(&dir).await;
17216        fs::create_dir_all(&dir).await.unwrap();
17217
17218        let config_path = dir.join("config.toml");
17219        let mut providers = crate::providers::Providers::default();
17220        providers.models.openrouter.insert(
17221            "default".to_string(),
17222            OpenRouterModelProviderConfig {
17223                base: ModelProviderConfig {
17224                    api_key: Some("sk-roundtrip".into()),
17225                    model: Some("test-model".into()),
17226                    temperature: Some(0.9),
17227                    timeout_secs: Some(120),
17228                    ..Default::default()
17229                },
17230            },
17231        );
17232        let config = Config {
17233            schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
17234            providers,
17235            model_routes: Vec::new(),
17236            embedding_routes: Vec::new(),
17237            data_dir: dir.join("workspace"),
17238            config_path: config_path.clone(),
17239            observability: ObservabilityConfig::default(),
17240            trust: crate::scattered_types::TrustConfig::default(),
17241            backup: BackupConfig::default(),
17242            data_retention: DataRetentionConfig::default(),
17243            cloud_ops: CloudOpsConfig::default(),
17244            conversational_ai: ConversationalAiConfig::default(),
17245            security: SecurityConfig::default(),
17246            security_ops: SecurityOpsConfig::default(),
17247            runtime: RuntimeConfig::default(),
17248            reliability: ReliabilityConfig::default(),
17249            scheduler: SchedulerConfig::default(),
17250            skills: SkillsConfig::default(),
17251            pipeline: PipelineConfig::default(),
17252            query_classification: QueryClassificationConfig::default(),
17253            heartbeat: HeartbeatConfig::default(),
17254            cron: HashMap::new(),
17255            acp: AcpConfig::default(),
17256            channels: ChannelsConfig::default(),
17257            memory: MemoryConfig::default(),
17258            storage: StorageConfig::default(),
17259            tunnel: TunnelConfig::default(),
17260            gateway: GatewayConfig::default(),
17261            composio: ComposioConfig::default(),
17262            microsoft365: Microsoft365Config::default(),
17263            secrets: SecretsConfig::default(),
17264            browser: BrowserConfig::default(),
17265            browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
17266            http_request: HttpRequestConfig::default(),
17267            multimodal: MultimodalConfig::default(),
17268            media_pipeline: MediaPipelineConfig::default(),
17269            web_fetch: WebFetchConfig::default(),
17270            link_enricher: LinkEnricherConfig::default(),
17271            text_browser: TextBrowserConfig::default(),
17272            web_search: WebSearchConfig::default(),
17273            project_intel: ProjectIntelConfig::default(),
17274            google_workspace: GoogleWorkspaceConfig::default(),
17275            proxy: ProxyConfig::default(),
17276            pacing: PacingConfig::default(),
17277            cost: CostConfig::default(),
17278            peripherals: PeripheralsConfig::default(),
17279            delegate: DelegateToolConfig::default(),
17280            agents: HashMap::new(),
17281            risk_profiles: HashMap::new(),
17282            runtime_profiles: HashMap::new(),
17283            skill_bundles: HashMap::new(),
17284            knowledge_bundles: HashMap::new(),
17285            mcp_bundles: HashMap::new(),
17286            peer_groups: HashMap::new(),
17287            hooks: HooksConfig::default(),
17288            hardware: HardwareConfig::default(),
17289            transcription: TranscriptionConfig::default(),
17290            tts: TtsConfig::default(),
17291            mcp: McpConfig::default(),
17292            nodes: NodesConfig::default(),
17293            onboard_state: OnboardStateConfig::default(),
17294            notion: NotionConfig::default(),
17295            jira: JiraConfig::default(),
17296            node_transport: NodeTransportConfig::default(),
17297            knowledge: KnowledgeConfig::default(),
17298            linkedin: LinkedInConfig::default(),
17299            image_gen: ImageGenConfig::default(),
17300            file_upload: FileUploadConfig::default(),
17301            file_upload_bundle: FileUploadBundleConfig::default(),
17302            file_download: FileDownloadConfig::default(),
17303            plugins: PluginsConfig::default(),
17304            locale: None,
17305            verifiable_intent: VerifiableIntentConfig::default(),
17306            claude_code: ClaudeCodeConfig::default(),
17307            claude_code_runner: ClaudeCodeRunnerConfig::default(),
17308            codex_cli: CodexCliConfig::default(),
17309            gemini_cli: GeminiCliConfig::default(),
17310            opencode_cli: OpenCodeCliConfig::default(),
17311            sop: SopConfig::default(),
17312            shell_tool: ShellToolConfig::default(),
17313            escalation: EscalationConfig::default(),
17314            env_overridden_paths: std::collections::HashSet::new(),
17315            pre_override_snapshots: std::collections::HashMap::new(),
17316            dirty_paths: std::collections::HashSet::new(),
17317        };
17318
17319        // ModelProvider fields are now resolved directly — no cache needed.
17320        config.save().await.unwrap();
17321        assert!(config_path.exists());
17322
17323        let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
17324        let loaded = crate::migration::migrate_to_current(&contents).unwrap();
17325        let entry = &loaded
17326            .providers
17327            .models
17328            .find("openrouter", "default")
17329            .expect("entry exists");
17330        assert!(
17331            entry
17332                .api_key
17333                .as_deref()
17334                .is_some_and(crate::secrets::SecretStore::is_encrypted)
17335        );
17336        let store = crate::secrets::SecretStore::new(&dir, true);
17337        let decrypted = store.decrypt(entry.api_key.as_deref().unwrap()).unwrap();
17338        assert_eq!(decrypted, "sk-roundtrip");
17339        assert_eq!(entry.model.as_deref(), Some("test-model"));
17340        assert!(
17341            entry
17342                .temperature
17343                .is_some_and(|t| (t - 0.9).abs() < f64::EPSILON)
17344        );
17345
17346        let _ = fs::remove_dir_all(&dir).await;
17347    }
17348
17349    #[tokio::test]
17350    async fn config_save_encrypts_nested_credentials() {
17351        let dir = std::env::temp_dir().join(format!(
17352            "zeroclaw_test_nested_credentials_{}",
17353            uuid::Uuid::new_v4()
17354        ));
17355        fs::create_dir_all(&dir).await.unwrap();
17356
17357        let mut config = Config {
17358            data_dir: dir.join("workspace"),
17359            config_path: dir.join("config.toml"),
17360            ..Default::default()
17361        };
17362        config.providers.models.anthropic.insert(
17363            "default".to_string(),
17364            AnthropicModelProviderConfig {
17365                base: ModelProviderConfig {
17366                    api_key: Some("root-credential".into()),
17367                    ..Default::default()
17368                },
17369            },
17370        );
17371        // ModelProvider fields are now resolved directly — no cache needed.
17372        config.composio.api_key = Some("composio-credential".into());
17373        config.browser.computer_use.api_key = Some("browser-credential".into());
17374        config.web_search.brave_api_key = Some("brave-credential".into());
17375        config.web_search.tavily_api_key = Some("tavily-credential".into());
17376        config.storage.postgres.insert(
17377            "default".to_string(),
17378            PostgresStorageConfig {
17379                db_url: Some("postgres://user:pw@host/db".into()),
17380                ..PostgresStorageConfig::default()
17381            },
17382        );
17383        config.channels.lark.insert(
17384            "feishu".to_string(),
17385            LarkConfig {
17386                enabled: true,
17387                app_id: "cli_feishu_123".into(),
17388                app_secret: "feishu-secret".into(),
17389                encrypt_key: Some("feishu-encrypt".into()),
17390                verification_token: Some("feishu-verify".into()),
17391                mention_only: false,
17392                use_feishu: true,
17393                receive_mode: LarkReceiveMode::Websocket,
17394                port: None,
17395                proxy_url: None,
17396                excluded_tools: vec![],
17397                default_target: None,
17398            },
17399        );
17400
17401        config.providers.models.openrouter.insert(
17402            "worker".into(),
17403            crate::schema::OpenRouterModelProviderConfig {
17404                base: ModelProviderConfig {
17405                    api_key: Some("agent-credential".into()),
17406                    model: Some("model-test".into()),
17407                    ..Default::default()
17408                },
17409            },
17410        );
17411        config.agents.insert(
17412            "worker".into(),
17413            AliasedAgentConfig {
17414                model_provider: "openrouter.worker".into(),
17415                ..Default::default()
17416            },
17417        );
17418
17419        // Webhook channel: auth_header carries a Bearer token; must be
17420        // encrypted alongside the existing webhook `secret` field.
17421        config.channels.webhook.insert(
17422            "primary".into(),
17423            WebhookConfig {
17424                enabled: true,
17425                port: 8080,
17426                auth_header: Some("Bearer webhook-cred".into()),
17427                secret: Some("webhook-shared-secret".into()),
17428                ..Default::default()
17429            },
17430        );
17431
17432        // MCP server: HTTP headers map carries an Authorization Bearer
17433        // token; the new `#[secret]` on `HashMap<String, String>` must
17434        // encrypt every value (and only every value — keys stay plain).
17435        config.mcp.servers.push(McpServerConfig {
17436            name: "primary".into(),
17437            transport: McpTransport::Sse,
17438            url: Some("https://mcp.example.invalid/sse".into()),
17439            headers: HashMap::from([
17440                ("Authorization".to_string(), "Bearer mcp-cred".to_string()),
17441                ("X-Tenant".to_string(), "tenant-42".to_string()),
17442            ]),
17443            ..Default::default()
17444        });
17445
17446        config.save().await.unwrap();
17447
17448        let contents = tokio::fs::read_to_string(config.config_path.clone())
17449            .await
17450            .unwrap();
17451        let stored: Config = crate::migration::migrate_to_current(&contents).unwrap();
17452        let store = crate::secrets::SecretStore::new(&dir, true);
17453
17454        let root_encrypted = stored
17455            .providers
17456            .models
17457            .find("anthropic", "default")
17458            .and_then(|e| e.api_key.as_deref())
17459            .unwrap();
17460        assert!(crate::secrets::SecretStore::is_encrypted(root_encrypted));
17461        assert_eq!(store.decrypt(root_encrypted).unwrap(), "root-credential");
17462
17463        let composio_encrypted = stored.composio.api_key.as_deref().unwrap();
17464        assert!(crate::secrets::SecretStore::is_encrypted(
17465            composio_encrypted
17466        ));
17467        assert_eq!(
17468            store.decrypt(composio_encrypted).unwrap(),
17469            "composio-credential"
17470        );
17471
17472        let browser_encrypted = stored.browser.computer_use.api_key.as_deref().unwrap();
17473        assert!(crate::secrets::SecretStore::is_encrypted(browser_encrypted));
17474        assert_eq!(
17475            store.decrypt(browser_encrypted).unwrap(),
17476            "browser-credential"
17477        );
17478
17479        let web_search_encrypted = stored.web_search.brave_api_key.as_deref().unwrap();
17480        assert!(crate::secrets::SecretStore::is_encrypted(
17481            web_search_encrypted
17482        ));
17483        assert_eq!(
17484            store.decrypt(web_search_encrypted).unwrap(),
17485            "brave-credential"
17486        );
17487
17488        let tavily_encrypted = stored.web_search.tavily_api_key.as_deref().unwrap();
17489        assert!(crate::secrets::SecretStore::is_encrypted(tavily_encrypted));
17490        assert_eq!(
17491            store.decrypt(tavily_encrypted).unwrap(),
17492            "tavily-credential"
17493        );
17494
17495        let worker_provider = stored
17496            .providers
17497            .models
17498            .find("openrouter", "worker")
17499            .unwrap();
17500        let worker_encrypted = worker_provider.api_key.as_deref().unwrap();
17501        assert!(crate::secrets::SecretStore::is_encrypted(worker_encrypted));
17502        assert_eq!(store.decrypt(worker_encrypted).unwrap(), "agent-credential");
17503
17504        let storage_db_url = stored
17505            .storage
17506            .postgres
17507            .get("default")
17508            .and_then(|p| p.db_url.as_deref())
17509            .unwrap();
17510        assert!(crate::secrets::SecretStore::is_encrypted(storage_db_url));
17511        assert_eq!(
17512            store.decrypt(storage_db_url).unwrap(),
17513            "postgres://user:pw@host/db"
17514        );
17515
17516        let feishu = stored.channels.lark.get("feishu").unwrap();
17517        assert!(crate::secrets::SecretStore::is_encrypted(
17518            &feishu.app_secret
17519        ));
17520        assert_eq!(store.decrypt(&feishu.app_secret).unwrap(), "feishu-secret");
17521        assert!(
17522            feishu
17523                .encrypt_key
17524                .as_deref()
17525                .is_some_and(crate::secrets::SecretStore::is_encrypted)
17526        );
17527        assert_eq!(
17528            store
17529                .decrypt(feishu.encrypt_key.as_deref().unwrap())
17530                .unwrap(),
17531            "feishu-encrypt"
17532        );
17533        assert!(
17534            feishu
17535                .verification_token
17536                .as_deref()
17537                .is_some_and(crate::secrets::SecretStore::is_encrypted)
17538        );
17539        assert_eq!(
17540            store
17541                .decrypt(feishu.verification_token.as_deref().unwrap())
17542                .unwrap(),
17543            "feishu-verify"
17544        );
17545
17546        // Webhook auth_header — newly tagged `#[secret]`.
17547        let webhook = stored.channels.webhook.get("primary").unwrap();
17548        let webhook_auth = webhook.auth_header.as_deref().unwrap();
17549        assert!(
17550            crate::secrets::SecretStore::is_encrypted(webhook_auth),
17551            "webhook auth_header must be encrypted on save"
17552        );
17553        assert_eq!(store.decrypt(webhook_auth).unwrap(), "Bearer webhook-cred");
17554        // The pre-existing webhook `secret` field stays encrypted too —
17555        // sanity check that the refactor didn't regress it.
17556        let webhook_secret = webhook.secret.as_deref().unwrap();
17557        assert!(crate::secrets::SecretStore::is_encrypted(webhook_secret));
17558        assert_eq!(
17559            store.decrypt(webhook_secret).unwrap(),
17560            "webhook-shared-secret"
17561        );
17562
17563        // MCP server headers — every value must be encrypted; the keys
17564        // stay plaintext (TOML table headers are not secret).
17565        let mcp_server = stored
17566            .mcp
17567            .servers
17568            .iter()
17569            .find(|s| s.name == "primary")
17570            .expect("mcp server `primary` round-trips through save");
17571        for (key, value) in &mcp_server.headers {
17572            assert!(
17573                crate::secrets::SecretStore::is_encrypted(value),
17574                "mcp.servers.primary.headers.{key} must be encrypted on save"
17575            );
17576        }
17577        let auth = mcp_server.headers.get("Authorization").unwrap();
17578        let tenant = mcp_server.headers.get("X-Tenant").unwrap();
17579        assert_eq!(store.decrypt(auth).unwrap(), "Bearer mcp-cred");
17580        assert_eq!(store.decrypt(tenant).unwrap(), "tenant-42");
17581
17582        let _ = fs::remove_dir_all(&dir).await;
17583    }
17584
17585    #[tokio::test]
17586    async fn config_save_atomic_cleanup() {
17587        let dir =
17588            std::env::temp_dir().join(format!("zeroclaw_test_config_{}", uuid::Uuid::new_v4()));
17589        fs::create_dir_all(&dir).await.unwrap();
17590
17591        let config_path = dir.join("config.toml");
17592        let mut config = Config {
17593            data_dir: dir.join("workspace"),
17594            config_path: config_path.clone(),
17595            ..Default::default()
17596        };
17597        config.providers.models.openrouter.insert(
17598            "default".to_string(),
17599            OpenRouterModelProviderConfig {
17600                base: ModelProviderConfig {
17601                    model: Some("model-a".into()),
17602                    ..Default::default()
17603                },
17604            },
17605        );
17606        config.save().await.unwrap();
17607        assert!(config_path.exists());
17608
17609        config
17610            .providers
17611            .models
17612            .ensure("openrouter", "default")
17613            .unwrap()
17614            .model = Some("model-b".into());
17615        config.save().await.unwrap();
17616
17617        let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
17618        assert!(contents.contains("model-b"));
17619
17620        let mut names: Vec<String> = Vec::new();
17621        let mut read_dir = fs::read_dir(&dir).await.unwrap();
17622        while let Some(entry) = read_dir.next_entry().await.unwrap() {
17623            names.push(entry.file_name().to_string_lossy().to_string());
17624        }
17625        assert!(!names.iter().any(|name| name.contains(".tmp-")));
17626        assert!(!names.iter().any(|name| name.ends_with(".bak")));
17627
17628        let _ = fs::remove_dir_all(&dir).await;
17629    }
17630
17631    // ── Telegram / Discord config ────────────────────────────
17632
17633    #[test]
17634    async fn telegram_config_serde() {
17635        let tc = TelegramConfig {
17636            enabled: true,
17637            bot_token: "123:XYZ".into(),
17638            stream_mode: StreamMode::Partial,
17639            draft_update_interval_ms: 500,
17640            interrupt_on_new_message: true,
17641            mention_only: false,
17642            ack_reactions: None,
17643            proxy_url: None,
17644            approval_timeout_secs: 120,
17645            excluded_tools: vec![],
17646            default_target: None,
17647        };
17648        let json = serde_json::to_string(&tc).unwrap();
17649        let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
17650        assert_eq!(parsed.bot_token, "123:XYZ");
17651        assert_eq!(parsed.stream_mode, StreamMode::Partial);
17652        assert_eq!(parsed.draft_update_interval_ms, 500);
17653        assert!(parsed.interrupt_on_new_message);
17654    }
17655
17656    #[test]
17657    async fn telegram_config_defaults_stream_off() {
17658        let json = r#"{"bot_token":"tok","allowed_users":[]}"#;
17659        let parsed: TelegramConfig = serde_json::from_str(json).unwrap();
17660        assert_eq!(parsed.stream_mode, StreamMode::Off);
17661        assert_eq!(parsed.draft_update_interval_ms, 1000);
17662        assert!(!parsed.interrupt_on_new_message);
17663    }
17664
17665    #[test]
17666    async fn discord_config_serde() {
17667        let dc = DiscordConfig {
17668            enabled: true,
17669            bot_token: "discord-token".into(),
17670            guild_ids: vec!["12345".into()],
17671            channel_ids: vec![],
17672            archive: false,
17673            listen_to_bots: false,
17674            interrupt_on_new_message: false,
17675            mention_only: false,
17676            proxy_url: None,
17677            stream_mode: StreamMode::default(),
17678            draft_update_interval_ms: 1000,
17679            multi_message_delay_ms: 800,
17680            stall_timeout_secs: 0,
17681            approval_timeout_secs: 300,
17682            excluded_tools: vec![],
17683            default_target: None,
17684        };
17685        let json = serde_json::to_string(&dc).unwrap();
17686        let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
17687        assert_eq!(parsed.bot_token, "discord-token");
17688        assert_eq!(parsed.guild_ids, vec!["12345".to_string()]);
17689    }
17690
17691    #[test]
17692    async fn discord_config_empty_guild_ids() {
17693        let dc = DiscordConfig {
17694            enabled: true,
17695            bot_token: "tok".into(),
17696            guild_ids: Vec::new(),
17697            channel_ids: vec![],
17698            archive: false,
17699            listen_to_bots: false,
17700            interrupt_on_new_message: false,
17701            mention_only: false,
17702            proxy_url: None,
17703            stream_mode: StreamMode::default(),
17704            draft_update_interval_ms: 1000,
17705            multi_message_delay_ms: 800,
17706            stall_timeout_secs: 0,
17707            approval_timeout_secs: 300,
17708            excluded_tools: vec![],
17709            default_target: None,
17710        };
17711        let json = serde_json::to_string(&dc).unwrap();
17712        let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
17713        assert!(parsed.guild_ids.is_empty());
17714    }
17715
17716    // ── iMessage / Matrix config ────────────────────────────
17717
17718    // iMessage `allowed_contacts` was lifted out of `IMessageConfig` in V3;
17719    // inbound peer authorization lives in `Config::peer_groups`. The
17720    // round-trip of contact-list values from a V2 TOML is exercised by
17721    // `imessage_v2_allowed_contacts_fold_into_peer_groups` below; per-field
17722    // struct serde for `allowed_contacts` no longer applies.
17723
17724    #[test]
17725    async fn imessage_v2_allowed_contacts_fold_into_peer_groups() {
17726        // V2 TOML with `allowed_contacts` on the channel must be folded
17727        // into a synthesized `peer_groups.imessage_default` group with
17728        // each contact as an external peer.
17729        let raw = r#"
17730schema_version = 2
17731
17732[channels.imessage]
17733enabled = true
17734allowed_contacts = ["+1234567890", "user@icloud.com"]
17735"#;
17736        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
17737        let group = parsed
17738            .peer_groups
17739            .get("imessage_default")
17740            .expect("V2 imessage.allowed_contacts must fold into peer_groups.imessage_default");
17741        assert_eq!(group.channel, "imessage");
17742        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
17743        assert_eq!(usernames, vec!["+1234567890", "user@icloud.com"]);
17744    }
17745
17746    #[test]
17747    async fn matrix_config_serde() {
17748        let mc = MatrixConfig {
17749            enabled: true,
17750            homeserver: "https://matrix.org".into(),
17751            access_token: Some("syt_token_abc".into()),
17752            user_id: Some("@bot:matrix.org".into()),
17753            device_id: Some("DEVICE123".into()),
17754            allowed_rooms: vec!["!room123:matrix.org".into()],
17755            interrupt_on_new_message: false,
17756            stream_mode: StreamMode::default(),
17757            draft_update_interval_ms: 1500,
17758            multi_message_delay_ms: 800,
17759            recovery_key: None,
17760            mention_only: false,
17761            password: None,
17762            approval_timeout_secs: 300,
17763            reply_in_thread: true,
17764            ack_reactions: Some(true),
17765            excluded_tools: vec![],
17766            default_target: None,
17767        };
17768        let json = serde_json::to_string(&mc).unwrap();
17769        let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
17770        assert_eq!(parsed.homeserver, "https://matrix.org");
17771        assert_eq!(parsed.access_token.as_deref(), Some("syt_token_abc"));
17772        assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org"));
17773        assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123"));
17774        assert_eq!(
17775            parsed.allowed_rooms.first().map(|s| s.as_str()),
17776            Some("!room123:matrix.org")
17777        );
17778    }
17779
17780    #[test]
17781    async fn matrix_config_toml_roundtrip() {
17782        let mc = MatrixConfig {
17783            enabled: true,
17784            homeserver: "https://synapse.local:8448".into(),
17785            access_token: Some("tok".into()),
17786            user_id: None,
17787            device_id: None,
17788            allowed_rooms: vec!["!abc:synapse.local".into()],
17789            interrupt_on_new_message: false,
17790            stream_mode: StreamMode::default(),
17791            draft_update_interval_ms: 1500,
17792            multi_message_delay_ms: 800,
17793            recovery_key: None,
17794            mention_only: false,
17795            password: None,
17796            approval_timeout_secs: 300,
17797            reply_in_thread: true,
17798            ack_reactions: Some(true),
17799            excluded_tools: vec![],
17800            default_target: None,
17801        };
17802        let toml_str = toml::to_string(&mc).unwrap();
17803        let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
17804        assert_eq!(parsed.homeserver, "https://synapse.local:8448");
17805        assert_eq!(parsed.allowed_rooms.len(), 1);
17806    }
17807
17808    #[test]
17809    async fn matrix_config_backward_compatible_without_session_hints() {
17810        // room_id in TOML is now migrated by prepare_table at the top level;
17811        // a bare MatrixConfig parse just ignores unknown keys.
17812        let toml = r#"
17813homeserver = "https://matrix.org"
17814access_token = "tok"
17815allowed_users = ["@ops:matrix.org"]
17816allowed_rooms = ["!ops:matrix.org"]
17817"#;
17818
17819        let parsed: MatrixConfig = toml::from_str(toml).unwrap();
17820        assert_eq!(parsed.homeserver, "https://matrix.org");
17821        assert!(parsed.user_id.is_none());
17822        assert!(parsed.device_id.is_none());
17823        assert_eq!(parsed.allowed_rooms, vec!["!ops:matrix.org"]);
17824    }
17825
17826    #[test]
17827    async fn matrix_config_reply_in_thread_defaults_to_true() {
17828        let toml = r#"
17829homeserver = "https://matrix.org"
17830access_token = "tok"
17831allowed_users = ["@u:matrix.org"]
17832"#;
17833        let parsed: MatrixConfig = toml::from_str(toml).unwrap();
17834        assert!(parsed.reply_in_thread);
17835    }
17836
17837    #[test]
17838    async fn signal_config_serde() {
17839        let sc = SignalConfig {
17840            enabled: true,
17841            http_url: "http://127.0.0.1:8686".into(),
17842            account: "+1234567890".into(),
17843            group_ids: vec!["group123".into()],
17844            dm_only: false,
17845            ignore_attachments: true,
17846            ignore_stories: false,
17847            proxy_url: None,
17848            approval_timeout_secs: 300,
17849            excluded_tools: vec![],
17850            default_target: None,
17851        };
17852        let json = serde_json::to_string(&sc).unwrap();
17853        let parsed: SignalConfig = serde_json::from_str(&json).unwrap();
17854        assert_eq!(parsed.http_url, "http://127.0.0.1:8686");
17855        assert_eq!(parsed.account, "+1234567890");
17856        assert_eq!(parsed.group_ids, vec!["group123".to_string()]);
17857        assert!(!parsed.dm_only);
17858        assert!(parsed.ignore_attachments);
17859        assert!(!parsed.ignore_stories);
17860    }
17861
17862    #[test]
17863    async fn signal_config_toml_roundtrip() {
17864        let sc = SignalConfig {
17865            enabled: true,
17866            http_url: "http://localhost:8080".into(),
17867            account: "+9876543210".into(),
17868            group_ids: Vec::new(),
17869            dm_only: true,
17870            ignore_attachments: false,
17871            ignore_stories: true,
17872            proxy_url: None,
17873            approval_timeout_secs: 300,
17874            excluded_tools: vec![],
17875            default_target: None,
17876        };
17877        let toml_str = toml::to_string(&sc).unwrap();
17878        let parsed: SignalConfig = toml::from_str(&toml_str).unwrap();
17879        assert_eq!(parsed.http_url, "http://localhost:8080");
17880        assert_eq!(parsed.account, "+9876543210");
17881        assert!(parsed.group_ids.is_empty());
17882        assert!(parsed.dm_only);
17883        assert!(parsed.ignore_stories);
17884    }
17885
17886    #[test]
17887    async fn signal_config_defaults() {
17888        let json = r#"{"http_url":"http://127.0.0.1:8686","account":"+1234567890"}"#;
17889        let parsed: SignalConfig = serde_json::from_str(json).unwrap();
17890        assert!(parsed.group_ids.is_empty());
17891        assert!(!parsed.dm_only);
17892        assert!(!parsed.ignore_attachments);
17893        assert!(!parsed.ignore_stories);
17894    }
17895
17896    #[test]
17897    async fn channels_with_imessage_and_matrix() {
17898        let c = ChannelsConfig {
17899            cli: true,
17900            telegram: HashMap::new(),
17901            discord: HashMap::new(),
17902            slack: HashMap::new(),
17903            mattermost: HashMap::new(),
17904            webhook: HashMap::new(),
17905            imessage: HashMap::from([(
17906                "default".to_string(),
17907                IMessageConfig {
17908                    enabled: true,
17909                    excluded_tools: vec![],
17910                },
17911            )]),
17912            matrix: HashMap::from([(
17913                "default".to_string(),
17914                MatrixConfig {
17915                    enabled: true,
17916                    homeserver: "https://m.org".into(),
17917                    access_token: Some("tok".into()),
17918                    user_id: None,
17919                    device_id: None,
17920                    allowed_rooms: vec!["!r:m".into()],
17921                    interrupt_on_new_message: false,
17922                    stream_mode: StreamMode::default(),
17923                    draft_update_interval_ms: 1500,
17924                    multi_message_delay_ms: 800,
17925                    recovery_key: None,
17926                    mention_only: false,
17927                    password: None,
17928                    approval_timeout_secs: 300,
17929                    reply_in_thread: true,
17930                    ack_reactions: Some(true),
17931                    excluded_tools: vec![],
17932                    default_target: None,
17933                },
17934            )]),
17935            signal: HashMap::new(),
17936            whatsapp: HashMap::new(),
17937            linq: HashMap::new(),
17938            wati: HashMap::new(),
17939            nextcloud_talk: HashMap::new(),
17940            email: HashMap::new(),
17941            gmail_push: HashMap::new(),
17942            irc: HashMap::new(),
17943            lark: HashMap::new(),
17944            line: HashMap::new(),
17945            dingtalk: HashMap::new(),
17946            wecom: HashMap::new(),
17947            wecom_ws: HashMap::new(),
17948            wechat: HashMap::new(),
17949            qq: HashMap::new(),
17950            twitter: HashMap::new(),
17951            mochat: HashMap::new(),
17952            nostr: HashMap::new(),
17953            clawdtalk: HashMap::new(),
17954            reddit: HashMap::new(),
17955            bluesky: HashMap::new(),
17956            voice_call: HashMap::new(),
17957            voice_duplex: HashMap::new(),
17958            voice_wake: HashMap::new(),
17959            mqtt: HashMap::new(),
17960            message_timeout_secs: 300,
17961            ack_reactions: true,
17962            show_tool_calls: true,
17963            session_persistence: true,
17964            session_backend: default_session_backend(),
17965            session_ttl_hours: 0,
17966            debounce_ms: 0,
17967        };
17968        let toml_str = toml::to_string_pretty(&c).unwrap();
17969        let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
17970        assert!(!parsed.imessage.is_empty());
17971        assert!(!parsed.matrix.is_empty());
17972        assert_eq!(
17973            parsed.matrix.get("default").unwrap().homeserver,
17974            "https://m.org"
17975        );
17976    }
17977
17978    #[test]
17979    async fn channels_default_has_no_imessage_matrix() {
17980        let c = ChannelsConfig::default();
17981        assert!(c.imessage.is_empty());
17982        assert!(c.matrix.is_empty());
17983    }
17984
17985    // ── Edge cases: serde(default) for non-secret optional fields ─────
17986    // The legacy `allowed_users` field is no longer carried on channel
17987    // configs (V3 moved inbound peer authorization into
17988    // `Config::peer_groups`); V2 TOMLs with `allowed_users` are folded
17989    // by `migrate_to_current` into `[peer_groups.<type>_<alias>]`. See
17990    // `discord_v2_allowed_users_fold_into_peer_groups` below.
17991
17992    #[test]
17993    async fn discord_v2_allowed_users_fold_into_peer_groups() {
17994        let raw = r#"
17995schema_version = 2
17996
17997[channels.discord]
17998enabled = true
17999bot_token = "tok"
18000guild_id = "123"
18001allowed_users = ["111", "222"]
18002"#;
18003        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
18004        let group = parsed
18005            .peer_groups
18006            .get("discord_default")
18007            .expect("V2 discord.allowed_users must fold into peer_groups.discord_default");
18008        assert_eq!(group.channel, "discord");
18009        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
18010        assert_eq!(usernames, vec!["111", "222"]);
18011    }
18012
18013    #[test]
18014    async fn slack_v2_allowed_users_fold_into_peer_groups() {
18015        let raw = r#"
18016schema_version = 2
18017
18018[channels.slack]
18019enabled = true
18020bot_token = "xoxb-tok"
18021allowed_users = ["U111"]
18022"#;
18023        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
18024        let group = parsed
18025            .peer_groups
18026            .get("slack_default")
18027            .expect("V2 slack.allowed_users must fold into peer_groups.slack_default");
18028        assert_eq!(group.channel, "slack");
18029        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
18030        assert_eq!(usernames, vec!["U111"]);
18031    }
18032
18033    #[test]
18034    async fn slack_config_deserializes_with_channel_ids() {
18035        let json = r#"{"bot_token":"xoxb-tok","channel_ids":["C111","D222"]}"#;
18036        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
18037        assert_eq!(parsed.channel_ids, vec!["C111", "D222"]);
18038        assert!(!parsed.interrupt_on_new_message);
18039        assert_eq!(parsed.thread_replies, None);
18040        assert!(!parsed.mention_only);
18041    }
18042
18043    #[test]
18044    async fn slack_config_deserializes_with_mention_only() {
18045        let json = r#"{"bot_token":"xoxb-tok","mention_only":true}"#;
18046        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
18047        assert!(parsed.mention_only);
18048        assert!(!parsed.interrupt_on_new_message);
18049        assert_eq!(parsed.thread_replies, None);
18050    }
18051
18052    #[test]
18053    async fn slack_config_deserializes_interrupt_on_new_message() {
18054        let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
18055        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
18056        assert!(parsed.interrupt_on_new_message);
18057        assert_eq!(parsed.thread_replies, None);
18058        assert!(!parsed.mention_only);
18059    }
18060
18061    #[test]
18062    async fn slack_config_deserializes_thread_replies() {
18063        let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#;
18064        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
18065        assert_eq!(parsed.thread_replies, Some(false));
18066        assert!(!parsed.interrupt_on_new_message);
18067        assert!(!parsed.mention_only);
18068    }
18069
18070    #[test]
18071    async fn discord_config_default_interrupt_on_new_message_is_false() {
18072        let json = r#"{"bot_token":"tok"}"#;
18073        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
18074        assert!(!parsed.interrupt_on_new_message);
18075    }
18076
18077    #[test]
18078    async fn discord_config_deserializes_interrupt_on_new_message_true() {
18079        let json = r#"{"bot_token":"tok","interrupt_on_new_message":true}"#;
18080        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
18081        assert!(parsed.interrupt_on_new_message);
18082    }
18083
18084    #[test]
18085    async fn discord_config_toml_backward_compat() {
18086        let toml_str = r#"
18087bot_token = "tok"
18088guild_id = "123"
18089"#;
18090        let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
18091        assert_eq!(parsed.bot_token, "tok");
18092    }
18093
18094    #[test]
18095    async fn slack_config_toml_with_channel_ids() {
18096        let toml_str = r#"
18097bot_token = "xoxb-tok"
18098channel_ids = ["C123", "D456"]
18099"#;
18100        let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
18101        assert_eq!(parsed.channel_ids, vec!["C123", "D456"]);
18102        assert!(!parsed.interrupt_on_new_message);
18103        assert_eq!(parsed.thread_replies, None);
18104        assert!(!parsed.mention_only);
18105    }
18106
18107    #[test]
18108    async fn slack_config_toml_without_channel_ids_defaults_empty() {
18109        let toml_str = r#"
18110bot_token = "xoxb-tok"
18111"#;
18112        let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
18113        assert!(parsed.channel_ids.is_empty());
18114    }
18115
18116    #[test]
18117    async fn mattermost_config_default_interrupt_on_new_message_is_false() {
18118        let json = r#"{"url":"https://mm.example.com","bot_token":"tok"}"#;
18119        let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
18120        assert!(!parsed.interrupt_on_new_message);
18121    }
18122
18123    #[test]
18124    async fn mattermost_config_deserializes_interrupt_on_new_message_true() {
18125        let json =
18126            r#"{"url":"https://mm.example.com","bot_token":"tok","interrupt_on_new_message":true}"#;
18127        let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
18128        assert!(parsed.interrupt_on_new_message);
18129    }
18130
18131    #[test]
18132    async fn webhook_config_with_secret() {
18133        let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
18134        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
18135        assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
18136    }
18137
18138    #[test]
18139    async fn webhook_config_without_secret() {
18140        let json = r#"{"port":8080}"#;
18141        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
18142        assert!(parsed.secret.is_none());
18143        assert_eq!(parsed.port, 8080);
18144    }
18145
18146    #[test]
18147    async fn webhook_config_retry_fields_default_to_none() {
18148        let json = r#"{"port":8080}"#;
18149        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
18150        assert!(parsed.max_retries.is_none());
18151        assert!(parsed.retry_base_delay_ms.is_none());
18152        assert!(parsed.retry_max_delay_ms.is_none());
18153    }
18154
18155    #[test]
18156    async fn webhook_config_retry_fields_roundtrip() {
18157        let wc = WebhookConfig {
18158            enabled: true,
18159            port: 8080,
18160            listen_path: None,
18161            send_url: Some("https://example.com/cb".into()),
18162            send_method: None,
18163            auth_header: None,
18164            secret: None,
18165            excluded_tools: vec![],
18166            max_retries: Some(5),
18167            retry_base_delay_ms: Some(250),
18168            retry_max_delay_ms: Some(10_000),
18169        };
18170
18171        let json = serde_json::to_string(&wc).unwrap();
18172        let parsed: WebhookConfig = serde_json::from_str(&json).unwrap();
18173        assert_eq!(parsed.max_retries, Some(5));
18174        assert_eq!(parsed.retry_base_delay_ms, Some(250));
18175        assert_eq!(parsed.retry_max_delay_ms, Some(10_000));
18176
18177        let toml_str = toml::to_string(&wc).unwrap();
18178        let parsed: WebhookConfig = toml::from_str(&toml_str).unwrap();
18179        assert_eq!(parsed.max_retries, Some(5));
18180        assert_eq!(parsed.retry_base_delay_ms, Some(250));
18181        assert_eq!(parsed.retry_max_delay_ms, Some(10_000));
18182    }
18183
18184    // ── WhatsApp config ──────────────────────────────────────
18185
18186    #[test]
18187    async fn whatsapp_config_serde() {
18188        let wc = WhatsAppConfig {
18189            enabled: true,
18190            access_token: Some("EAABx...".into()),
18191            phone_number_id: Some("123456789".into()),
18192            verify_token: Some("my-verify-token".into()),
18193            app_secret: None,
18194            session_path: None,
18195            pair_phone: None,
18196            pair_code: None,
18197            ws_url: None,
18198            mention_only: false,
18199            mode: WhatsAppWebMode::default(),
18200            dm_policy: WhatsAppChatPolicy::default(),
18201            group_policy: WhatsAppChatPolicy::default(),
18202            self_chat_mode: false,
18203            dm_mention_patterns: vec![],
18204            group_mention_patterns: vec![],
18205            proxy_url: None,
18206            approval_timeout_secs: 300,
18207            excluded_tools: vec![],
18208            default_target: None,
18209        };
18210        let json = serde_json::to_string(&wc).unwrap();
18211        let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
18212        assert_eq!(parsed.access_token, Some("EAABx...".into()));
18213        assert_eq!(parsed.phone_number_id, Some("123456789".into()));
18214        assert_eq!(parsed.verify_token, Some("my-verify-token".into()));
18215    }
18216
18217    #[test]
18218    async fn whatsapp_config_toml_roundtrip() {
18219        let wc = WhatsAppConfig {
18220            enabled: true,
18221            access_token: Some("tok".into()),
18222            phone_number_id: Some("12345".into()),
18223            verify_token: Some("verify".into()),
18224            app_secret: Some("secret123".into()),
18225            session_path: None,
18226            pair_phone: None,
18227            pair_code: None,
18228            ws_url: None,
18229            mention_only: false,
18230            mode: WhatsAppWebMode::default(),
18231            dm_policy: WhatsAppChatPolicy::default(),
18232            group_policy: WhatsAppChatPolicy::default(),
18233            self_chat_mode: false,
18234            dm_mention_patterns: vec![],
18235            group_mention_patterns: vec![],
18236            proxy_url: None,
18237            approval_timeout_secs: 300,
18238            excluded_tools: vec![],
18239            default_target: None,
18240        };
18241        let toml_str = toml::to_string(&wc).unwrap();
18242        let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
18243        assert_eq!(parsed.phone_number_id, Some("12345".into()));
18244    }
18245
18246    #[test]
18247    async fn whatsapp_v2_allowed_numbers_fold_into_peer_groups() {
18248        // V2 `allowed_numbers` on a WhatsApp channel migrates to a
18249        // synthesized `peer_groups.whatsapp_default` group. The wildcard
18250        // `*` is dropped at synthesis; concrete numbers round-trip.
18251        let raw = r#"
18252schema_version = 2
18253
18254[channels.whatsapp]
18255enabled = true
18256access_token = "tok"
18257phone_number_id = "123"
18258verify_token = "ver"
18259allowed_numbers = ["+1", "+2"]
18260"#;
18261        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
18262        let group = parsed
18263            .peer_groups
18264            .get("whatsapp_default")
18265            .expect("V2 whatsapp.allowed_numbers must fold into peer_groups.whatsapp_default");
18266        assert_eq!(group.channel, "whatsapp");
18267        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
18268        assert_eq!(usernames, vec!["+1", "+2"]);
18269    }
18270
18271    #[test]
18272    async fn whatsapp_config_backend_type_cloud_precedence_when_ambiguous() {
18273        let wc = WhatsAppConfig {
18274            enabled: true,
18275            access_token: Some("tok".into()),
18276            phone_number_id: Some("123".into()),
18277            verify_token: Some("ver".into()),
18278            app_secret: None,
18279            session_path: Some("~/.zeroclaw/state/whatsapp-web/session.db".into()),
18280            pair_phone: None,
18281            pair_code: None,
18282            ws_url: None,
18283            mention_only: false,
18284            mode: WhatsAppWebMode::default(),
18285            dm_policy: WhatsAppChatPolicy::default(),
18286            group_policy: WhatsAppChatPolicy::default(),
18287            self_chat_mode: false,
18288            dm_mention_patterns: vec![],
18289            group_mention_patterns: vec![],
18290            proxy_url: None,
18291            approval_timeout_secs: 300,
18292            excluded_tools: vec![],
18293            default_target: None,
18294        };
18295        assert!(wc.is_ambiguous_config());
18296        assert_eq!(wc.backend_type(), "cloud");
18297    }
18298
18299    #[test]
18300    async fn whatsapp_config_backend_type_web() {
18301        let wc = WhatsAppConfig {
18302            enabled: true,
18303            access_token: None,
18304            phone_number_id: None,
18305            verify_token: None,
18306            app_secret: None,
18307            session_path: Some("~/.zeroclaw/state/whatsapp-web/session.db".into()),
18308            pair_phone: None,
18309            pair_code: None,
18310            ws_url: None,
18311            mention_only: false,
18312            mode: WhatsAppWebMode::default(),
18313            dm_policy: WhatsAppChatPolicy::default(),
18314            group_policy: WhatsAppChatPolicy::default(),
18315            self_chat_mode: false,
18316            dm_mention_patterns: vec![],
18317            group_mention_patterns: vec![],
18318            proxy_url: None,
18319            approval_timeout_secs: 300,
18320            excluded_tools: vec![],
18321            default_target: None,
18322        };
18323        assert!(!wc.is_ambiguous_config());
18324        assert_eq!(wc.backend_type(), "web");
18325    }
18326
18327    #[test]
18328    async fn channels_with_whatsapp() {
18329        let c = ChannelsConfig {
18330            cli: true,
18331            telegram: HashMap::new(),
18332            discord: HashMap::new(),
18333            slack: HashMap::new(),
18334            mattermost: HashMap::new(),
18335            webhook: HashMap::new(),
18336            imessage: HashMap::new(),
18337            matrix: HashMap::new(),
18338            signal: HashMap::new(),
18339            whatsapp: HashMap::from([(
18340                "default".to_string(),
18341                WhatsAppConfig {
18342                    enabled: true,
18343                    access_token: Some("tok".into()),
18344                    phone_number_id: Some("123".into()),
18345                    verify_token: Some("ver".into()),
18346                    app_secret: None,
18347                    session_path: None,
18348                    pair_phone: None,
18349                    pair_code: None,
18350                    ws_url: None,
18351                    mention_only: false,
18352                    mode: WhatsAppWebMode::default(),
18353                    dm_policy: WhatsAppChatPolicy::default(),
18354                    group_policy: WhatsAppChatPolicy::default(),
18355                    self_chat_mode: false,
18356                    dm_mention_patterns: vec![],
18357                    group_mention_patterns: vec![],
18358                    proxy_url: None,
18359                    approval_timeout_secs: 300,
18360                    excluded_tools: vec![],
18361                    default_target: None,
18362                },
18363            )]),
18364            linq: HashMap::new(),
18365            wati: HashMap::new(),
18366            nextcloud_talk: HashMap::new(),
18367            email: HashMap::new(),
18368            gmail_push: HashMap::new(),
18369            irc: HashMap::new(),
18370            lark: HashMap::new(),
18371            line: HashMap::new(),
18372            dingtalk: HashMap::new(),
18373            wecom: HashMap::new(),
18374            wecom_ws: HashMap::new(),
18375            wechat: HashMap::new(),
18376            qq: HashMap::new(),
18377            twitter: HashMap::new(),
18378            mochat: HashMap::new(),
18379            nostr: HashMap::new(),
18380            clawdtalk: HashMap::new(),
18381            reddit: HashMap::new(),
18382            bluesky: HashMap::new(),
18383            voice_call: HashMap::new(),
18384            voice_duplex: HashMap::new(),
18385            voice_wake: HashMap::new(),
18386            mqtt: HashMap::new(),
18387            message_timeout_secs: 300,
18388            ack_reactions: true,
18389            show_tool_calls: true,
18390            session_persistence: true,
18391            session_backend: default_session_backend(),
18392            session_ttl_hours: 0,
18393            debounce_ms: 0,
18394        };
18395        let toml_str = toml::to_string_pretty(&c).unwrap();
18396        let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
18397        assert!(!parsed.whatsapp.is_empty());
18398        let wa = parsed.whatsapp.get("default").unwrap();
18399        assert_eq!(wa.phone_number_id, Some("123".into()));
18400    }
18401
18402    #[test]
18403    async fn channels_default_has_no_whatsapp() {
18404        let c = ChannelsConfig::default();
18405        assert!(c.whatsapp.is_empty());
18406    }
18407
18408    #[test]
18409    async fn channels_default_has_no_nextcloud_talk() {
18410        let c = ChannelsConfig::default();
18411        assert!(c.nextcloud_talk.is_empty());
18412    }
18413
18414    // ══════════════════════════════════════════════════════════
18415    // SECURITY CHECKLIST TESTS — Gateway config
18416    // ══════════════════════════════════════════════════════════
18417
18418    #[test]
18419    async fn checklist_gateway_default_requires_pairing() {
18420        let g = GatewayConfig::default();
18421        assert!(g.require_pairing, "Pairing must be required by default");
18422    }
18423
18424    #[test]
18425    async fn checklist_gateway_default_blocks_public_bind() {
18426        let g = GatewayConfig::default();
18427        assert!(
18428            !g.allow_public_bind,
18429            "Public bind must be blocked by default"
18430        );
18431    }
18432
18433    #[test]
18434    async fn checklist_gateway_default_no_tokens() {
18435        let g = GatewayConfig::default();
18436        assert!(
18437            g.paired_tokens.is_empty(),
18438            "No pre-paired tokens by default"
18439        );
18440        assert_eq!(g.pair_rate_limit_per_minute, 10);
18441        assert_eq!(g.webhook_rate_limit_per_minute, 60);
18442        assert!(!g.trust_forwarded_headers);
18443        assert_eq!(g.rate_limit_max_keys, 10_000);
18444        assert_eq!(g.idempotency_ttl_secs, 300);
18445        assert_eq!(g.idempotency_max_keys, 10_000);
18446    }
18447
18448    #[test]
18449    async fn checklist_gateway_cli_default_host_is_localhost() {
18450        // The CLI default for --host is 127.0.0.1 (checked in main.rs)
18451        // Here we verify the config default matches
18452        let c = Config::default();
18453        assert!(
18454            c.gateway.require_pairing,
18455            "Config default must require pairing"
18456        );
18457        assert!(
18458            !c.gateway.allow_public_bind,
18459            "Config default must block public bind"
18460        );
18461    }
18462
18463    #[test]
18464    async fn checklist_gateway_serde_roundtrip() {
18465        let g = GatewayConfig {
18466            port: 42617,
18467            host: "127.0.0.1".into(),
18468            require_pairing: true,
18469            allow_public_bind: false,
18470            paired_tokens: vec!["zc_test_token".into()],
18471            pair_rate_limit_per_minute: 12,
18472            webhook_rate_limit_per_minute: 80,
18473            trust_forwarded_headers: true,
18474            path_prefix: Some("/zeroclaw".into()),
18475            rate_limit_max_keys: 2048,
18476            idempotency_ttl_secs: 600,
18477            idempotency_max_keys: 4096,
18478            session_persistence: true,
18479            session_ttl_hours: 0,
18480            pairing_dashboard: PairingDashboardConfig::default(),
18481            web_dist_dir: None,
18482            tls: None,
18483            request_timeout_secs: 30,
18484            long_running_request_timeout_secs: 600,
18485        };
18486        let toml_str = toml::to_string(&g).unwrap();
18487        let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
18488        assert!(parsed.require_pairing);
18489        assert!(parsed.session_persistence);
18490        assert_eq!(parsed.session_ttl_hours, 0);
18491        assert!(!parsed.allow_public_bind);
18492        assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
18493        assert_eq!(parsed.pair_rate_limit_per_minute, 12);
18494        assert_eq!(parsed.webhook_rate_limit_per_minute, 80);
18495        assert!(parsed.trust_forwarded_headers);
18496        assert_eq!(parsed.path_prefix.as_deref(), Some("/zeroclaw"));
18497        assert_eq!(parsed.rate_limit_max_keys, 2048);
18498        assert_eq!(parsed.idempotency_ttl_secs, 600);
18499        assert_eq!(parsed.idempotency_max_keys, 4096);
18500    }
18501
18502    #[test]
18503    async fn checklist_gateway_backward_compat_no_gateway_section() {
18504        // Old configs without [gateway] should get secure defaults
18505        let minimal = r#"
18506workspace_dir = "/tmp/ws"
18507config_path = "/tmp/config.toml"
18508default_temperature = 0.7
18509"#;
18510        let parsed = parse_test_config(minimal);
18511        assert!(
18512            parsed.gateway.require_pairing,
18513            "Missing [gateway] must default to require_pairing=true"
18514        );
18515        assert!(
18516            !parsed.gateway.allow_public_bind,
18517            "Missing [gateway] must default to allow_public_bind=false"
18518        );
18519    }
18520
18521    #[test]
18522    async fn checklist_risk_profile_default_is_workspace_scoped() {
18523        let a = RiskProfileConfig::default();
18524        assert!(a.workspace_only, "Default profile must be workspace_only");
18525        assert!(
18526            !a.forbidden_paths.is_empty(),
18527            "Default forbidden_paths must not be empty"
18528        );
18529        #[cfg(not(target_os = "windows"))]
18530        {
18531            assert!(
18532                a.forbidden_paths.iter().any(|p| p == "/etc"),
18533                "Must block /etc on Unix"
18534            );
18535            assert!(
18536                a.forbidden_paths.iter().any(|p| p == "/proc"),
18537                "Must block /proc on Unix"
18538            );
18539        }
18540        #[cfg(target_os = "windows")]
18541        {
18542            assert!(
18543                a.forbidden_paths.iter().any(|p| p == "C:\\Windows"),
18544                "Must block C:\\Windows on Windows"
18545            );
18546            assert!(
18547                a.forbidden_paths.iter().any(|p| p == "C:\\Program Files"),
18548                "Must block C:\\Program Files on Windows"
18549            );
18550        }
18551        assert!(
18552            a.forbidden_paths.contains(&"~/.ssh".to_string()),
18553            "Must block ~/.ssh"
18554        );
18555    }
18556
18557    // ══════════════════════════════════════════════════════════
18558    // COMPOSIO CONFIG TESTS
18559    // ══════════════════════════════════════════════════════════
18560
18561    #[test]
18562    async fn composio_config_default_disabled() {
18563        let c = ComposioConfig::default();
18564        assert!(!c.enabled, "Composio must be disabled by default");
18565        assert!(c.api_key.is_none(), "No API key by default");
18566        assert_eq!(c.entity_id, "default");
18567    }
18568
18569    #[test]
18570    async fn composio_config_serde_roundtrip() {
18571        let c = ComposioConfig {
18572            enabled: true,
18573            api_key: Some("comp-key-123".into()),
18574            entity_id: "user42".into(),
18575        };
18576        let toml_str = toml::to_string(&c).unwrap();
18577        let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
18578        assert!(parsed.enabled);
18579        assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
18580        assert_eq!(parsed.entity_id, "user42");
18581    }
18582
18583    #[test]
18584    async fn composio_config_backward_compat_missing_section() {
18585        let minimal = r#"
18586workspace_dir = "/tmp/ws"
18587config_path = "/tmp/config.toml"
18588default_temperature = 0.7
18589"#;
18590        let parsed = parse_test_config(minimal);
18591        assert!(
18592            !parsed.composio.enabled,
18593            "Missing [composio] must default to disabled"
18594        );
18595        assert!(parsed.composio.api_key.is_none());
18596    }
18597
18598    #[test]
18599    async fn composio_config_partial_toml() {
18600        let toml_str = r"
18601enabled = true
18602";
18603        let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
18604        assert!(parsed.enabled);
18605        assert!(parsed.api_key.is_none());
18606        assert_eq!(parsed.entity_id, "default");
18607    }
18608
18609    #[test]
18610    async fn composio_config_enable_alias_supported() {
18611        let toml_str = r"
18612enable = true
18613";
18614        let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
18615        assert!(parsed.enabled);
18616        assert!(parsed.api_key.is_none());
18617        assert_eq!(parsed.entity_id, "default");
18618    }
18619
18620    // ══════════════════════════════════════════════════════════
18621    // SECRETS CONFIG TESTS
18622    // ══════════════════════════════════════════════════════════
18623
18624    #[test]
18625    async fn secrets_config_default_encrypts() {
18626        let s = SecretsConfig::default();
18627        assert!(s.encrypt, "Encryption must be enabled by default");
18628    }
18629
18630    #[test]
18631    async fn secrets_config_serde_roundtrip() {
18632        let s = SecretsConfig { encrypt: false };
18633        let toml_str = toml::to_string(&s).unwrap();
18634        let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
18635        assert!(!parsed.encrypt);
18636    }
18637
18638    #[test]
18639    async fn secrets_config_backward_compat_missing_section() {
18640        let minimal = r#"
18641workspace_dir = "/tmp/ws"
18642config_path = "/tmp/config.toml"
18643default_temperature = 0.7
18644"#;
18645        let parsed = parse_test_config(minimal);
18646        assert!(
18647            parsed.secrets.encrypt,
18648            "Missing [secrets] must default to encrypt=true"
18649        );
18650    }
18651
18652    #[test]
18653    async fn config_default_has_composio_and_secrets() {
18654        let c = Config::default();
18655        assert!(!c.composio.enabled);
18656        assert!(c.composio.api_key.is_none());
18657        assert!(c.secrets.encrypt);
18658        assert!(c.browser.enabled);
18659        assert_eq!(c.browser.allowed_domains, vec!["*".to_string()]);
18660    }
18661
18662    #[test]
18663    async fn browser_config_default_enabled() {
18664        let b = BrowserConfig::default();
18665        assert!(b.enabled);
18666        assert_eq!(b.allowed_domains, vec!["*".to_string()]);
18667        assert_eq!(b.backend, "agent_browser");
18668        assert_eq!(b.headed, None);
18669        assert!(b.native_headless);
18670        assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
18671        assert!(b.native_chrome_path.is_none());
18672        assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions");
18673        assert_eq!(b.computer_use.timeout_ms, 15_000);
18674        assert!(!b.computer_use.allow_remote_endpoint);
18675        assert!(b.computer_use.window_allowlist.is_empty());
18676        assert!(b.computer_use.max_coordinate_x.is_none());
18677        assert!(b.computer_use.max_coordinate_y.is_none());
18678    }
18679
18680    #[test]
18681    async fn browser_config_serde_roundtrip() {
18682        let b = BrowserConfig {
18683            enabled: true,
18684            allowed_domains: vec!["example.com".into(), "docs.example.com".into()],
18685            session_name: None,
18686            backend: "auto".into(),
18687            headed: Some(true),
18688            native_headless: false,
18689            native_webdriver_url: "http://localhost:4444".into(),
18690            native_chrome_path: Some("/usr/bin/chromium".into()),
18691            computer_use: BrowserComputerUseConfig {
18692                endpoint: "https://computer-use.example.com/v1/actions".into(),
18693                api_key: Some("test-token".into()),
18694                timeout_ms: 8_000,
18695                allow_remote_endpoint: true,
18696                window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()],
18697                max_coordinate_x: Some(3840),
18698                max_coordinate_y: Some(2160),
18699            },
18700        };
18701        let toml_str = toml::to_string(&b).unwrap();
18702        let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap();
18703        assert!(parsed.enabled);
18704        assert_eq!(parsed.allowed_domains.len(), 2);
18705        assert_eq!(parsed.allowed_domains[0], "example.com");
18706        assert_eq!(parsed.backend, "auto");
18707        assert_eq!(parsed.headed, Some(true));
18708        assert!(!parsed.native_headless);
18709        assert_eq!(parsed.native_webdriver_url, "http://localhost:4444");
18710        assert_eq!(
18711            parsed.native_chrome_path.as_deref(),
18712            Some("/usr/bin/chromium")
18713        );
18714        assert_eq!(
18715            parsed.computer_use.endpoint,
18716            "https://computer-use.example.com/v1/actions"
18717        );
18718        assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token"));
18719        assert_eq!(parsed.computer_use.timeout_ms, 8_000);
18720        assert!(parsed.computer_use.allow_remote_endpoint);
18721        assert_eq!(parsed.computer_use.window_allowlist.len(), 2);
18722        assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840));
18723        assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160));
18724    }
18725
18726    #[test]
18727    async fn browser_config_parses_headed_true() {
18728        let parsed: BrowserConfig = toml::from_str(
18729            r#"
18730backend = "agent_browser"
18731headed = true
18732"#,
18733        )
18734        .unwrap();
18735
18736        assert_eq!(parsed.backend, "agent_browser");
18737        assert_eq!(parsed.headed, Some(true));
18738        assert!(parsed.native_headless);
18739    }
18740
18741    #[test]
18742    async fn browser_config_backward_compat_missing_section() {
18743        let minimal = r#"
18744workspace_dir = "/tmp/ws"
18745config_path = "/tmp/config.toml"
18746default_temperature = 0.7
18747"#;
18748        let parsed = parse_test_config(minimal);
18749        assert!(parsed.browser.enabled);
18750        assert_eq!(parsed.browser.allowed_domains, vec!["*".to_string()]);
18751    }
18752
18753    async fn env_override_lock() -> MutexGuard<'static, ()> {
18754        // Delegate to the crate-shared lock so env-mutating tests in this
18755        // module serialize against `env_overrides::tests` too. Without
18756        // this, tests across the two modules race on `ZEROCLAW_*` vars.
18757        crate::env_overrides::env_test_lock().await
18758    }
18759
18760    #[test]
18761    async fn v1_known_provider_migrates_with_globals_folded_onto_typed_slot() {
18762        // Top-level `model_provider` + `model` + `default_temperature` flow
18763        // onto the migrated typed-slot entry. Vendor-canonical names like
18764        // `openai` map straight to their typed slot; `wire_api` and
18765        // `requires_openai_auth` survive the move.
18766        //
18767        // (Unknown V1 names like `sub2api` are intentionally silent-dropped
18768        // by the V2→V3 migration — see the `Unknown/passthrough` arm of
18769        // `normalize_provider_type` in schema/v2.rs.)
18770        let raw = r#"
18771default_temperature = 0.7
18772model_provider = "openai"
18773model = "gpt-5.3-codex"
18774
18775[model_providers.openai]
18776api_key = "sk-test"
18777uri = "https://api.openai.com/v1"
18778wire_api = "responses"
18779requires_openai_auth = true
18780"#;
18781
18782        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
18783        assert!(
18784            parsed
18785                .providers
18786                .models
18787                .contains_model_provider_type("openai"),
18788            "vendor-canonical V1 provider should land in its typed slot",
18789        );
18790        let profile = parsed
18791            .providers
18792            .models
18793            .find("openai", "default")
18794            .expect("openai.default entry");
18795        assert_eq!(profile.api_key.as_deref(), Some("sk-test"));
18796        assert_eq!(profile.uri.as_deref(), Some("https://api.openai.com/v1"));
18797        assert_eq!(profile.model.as_deref(), Some("gpt-5.3-codex"));
18798        assert_eq!(profile.wire_api, Some(WireApi::Responses));
18799        assert!(profile.requires_openai_auth);
18800    }
18801
18802    #[test]
18803    async fn typed_custom_slot_routes_uri_through_find() {
18804        let _env_guard = env_override_lock().await;
18805        let mut config = Config::default();
18806        config.providers.models.custom.insert(
18807            "default".to_string(),
18808            CustomModelProviderConfig {
18809                base: ModelProviderConfig {
18810                    uri: Some("https://api.tonsof.blue/v1".to_string()),
18811                    ..Default::default()
18812                },
18813            },
18814        );
18815
18816        assert_eq!(
18817            config
18818                .providers
18819                .models
18820                .find("custom", "default")
18821                .and_then(|e| e.uri.as_deref()),
18822            Some("https://api.tonsof.blue/v1")
18823        );
18824        assert!(config.first_model_provider().is_some());
18825    }
18826
18827    #[test]
18828    async fn openai_codex_alias_carries_responses_wire_api_and_requires_openai_auth() {
18829        let _env_guard = env_override_lock().await;
18830        let mut config = Config::default();
18831        config.providers.models.openai.insert(
18832            "codex".to_string(),
18833            OpenAIModelProviderConfig {
18834                base: ModelProviderConfig {
18835                    uri: Some("https://api.tonsof.blue".to_string()),
18836                    wire_api: Some(WireApi::Responses),
18837                    requires_openai_auth: true,
18838                    ..Default::default()
18839                },
18840            },
18841        );
18842
18843        let entry = config
18844            .providers
18845            .models
18846            .find("openai", "codex")
18847            .expect("openai.codex entry");
18848        assert_eq!(entry.uri.as_deref(), Some("https://api.tonsof.blue"));
18849        assert_eq!(entry.wire_api, Some(WireApi::Responses));
18850        assert!(entry.requires_openai_auth);
18851    }
18852
18853    /// Round-trip test for the config CLI: a TOML file with a typed-family
18854    /// model entry must deserialize, find via the typed accessor, and
18855    /// re-serialize without losing any field.
18856    #[test]
18857    async fn provider_models_round_trips_through_load_apply_serialize() {
18858        let _env_guard = env_override_lock().await;
18859        let toml_in = r#"
18860schema_version = 3
18861
18862[providers.models.openrouter.default]
18863uri = "https://example.invalid/v1"
18864model = "primary-model"
18865"#;
18866
18867        let config: Config = toml::from_str(toml_in).expect("parse toml");
18868
18869        assert_eq!(
18870            config
18871                .providers
18872                .models
18873                .find("openrouter", "default")
18874                .and_then(|e| e.model.as_deref()),
18875            Some("primary-model"),
18876        );
18877
18878        // What `config save` would write back to disk.
18879        let toml_out = toml::to_string(&config).expect("serialize toml");
18880        assert!(
18881            toml_out.contains("primary-model"),
18882            "serialized config must keep model value; got:\n{toml_out}",
18883        );
18884    }
18885
18886    /// `resolve_default_model` returns the first available `models.*` entry's
18887    /// model. Returning `None` is reserved for "no model_provider has any model
18888    /// configured", which callers must surface as a configuration error
18889    /// rather than silently substituting a vendor default.
18890    #[test]
18891    async fn resolve_default_model_picks_first_available() {
18892        let _env_guard = env_override_lock().await;
18893        let mut config = Config::default();
18894        // Empty config: no model anywhere -> None (caller errors loudly).
18895        assert_eq!(config.resolve_default_model(), None);
18896
18897        // Add an entry without a model -> still None.
18898        config
18899            .providers
18900            .models
18901            .anthropic
18902            .insert("default".into(), AnthropicModelProviderConfig::default());
18903        assert_eq!(config.resolve_default_model(), None);
18904
18905        // Add an entry with a model -> first-available wins.
18906        config.providers.models.together.insert(
18907            "default".to_string(),
18908            TogetherModelProviderConfig {
18909                base: ModelProviderConfig {
18910                    model: Some("tertiary-model".to_string()),
18911                    ..Default::default()
18912                },
18913            },
18914        );
18915        assert_eq!(
18916            config.resolve_default_model().as_deref(),
18917            Some("tertiary-model"),
18918        );
18919
18920        // Add a model_provider with a model — resolve_default_model finds it.
18921        config.providers.models.openrouter.insert(
18922            "default".to_string(),
18923            OpenRouterModelProviderConfig {
18924                base: ModelProviderConfig {
18925                    model: Some("primary-model".to_string()),
18926                    ..Default::default()
18927                },
18928            },
18929        );
18930        // resolve_default_model returns the first non-empty model across all model_providers.
18931        assert!(config.resolve_default_model().is_some());
18932    }
18933
18934    #[test]
18935    async fn save_repairs_bare_config_filename_using_runtime_resolution() {
18936        let _env_guard = env_override_lock().await;
18937        let temp_home =
18938            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
18939        let workspace_dir = temp_home.join("workspace");
18940        let resolved_config_path = temp_home.join(".zeroclaw").join("config.toml");
18941
18942        let original_home = std::env::var("HOME").ok();
18943        // SAFETY: test-only, single-threaded test runner.
18944        unsafe { std::env::set_var("HOME", &temp_home) };
18945        // SAFETY: test-only, single-threaded test runner.
18946        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
18947
18948        let mut config = Config {
18949            data_dir: workspace_dir,
18950            config_path: PathBuf::from("config.toml"),
18951            ..Default::default()
18952        };
18953        config.providers.models.anthropic.insert(
18954            "default".to_string(),
18955            AnthropicModelProviderConfig {
18956                base: ModelProviderConfig {
18957                    temperature: Some(0.5),
18958                    ..Default::default()
18959                },
18960            },
18961        );
18962        // ModelProvider fields are now resolved directly — no cache needed.
18963        config.save().await.unwrap();
18964
18965        assert!(resolved_config_path.exists());
18966        let saved = tokio::fs::read_to_string(&resolved_config_path)
18967            .await
18968            .unwrap();
18969        let parsed = parse_test_config(&saved);
18970        assert!(
18971            (parsed
18972                .providers
18973                .models
18974                .find("anthropic", "default")
18975                .and_then(|e| e.temperature)
18976                .unwrap_or(0.7)
18977                - 0.5)
18978                .abs()
18979                < f64::EPSILON
18980        );
18981
18982        // SAFETY: test-only, single-threaded test runner.
18983        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
18984        if let Some(home) = original_home {
18985            // SAFETY: test-only, single-threaded test runner.
18986            unsafe { std::env::set_var("HOME", home) };
18987        } else {
18988            // SAFETY: test-only, single-threaded test runner.
18989            unsafe { std::env::remove_var("HOME") };
18990        }
18991        let _ = tokio::fs::remove_dir_all(temp_home).await;
18992    }
18993
18994    #[test]
18995    async fn validate_ollama_cloud_model_requires_remote_api_url() {
18996        let _env_guard = env_override_lock().await;
18997        let mut config = Config::default();
18998        config.providers.models.ollama.insert(
18999            "default".to_string(),
19000            OllamaModelProviderConfig {
19001                base: ModelProviderConfig {
19002                    model: Some("glm-5:cloud".to_string()),
19003                    uri: None,
19004                    api_key: Some("ollama-key".to_string()),
19005                    ..Default::default()
19006                },
19007                ..OllamaModelProviderConfig::default()
19008            },
19009        );
19010
19011        let error = config.validate().expect_err("expected validation to fail");
19012        assert!(error.to_string().contains(
19013            "providers.models.ollama.default.model uses ':cloud', but uri is local or unset"
19014        ));
19015    }
19016
19017    #[test]
19018    async fn validate_ollama_cloud_model_accepts_private_remote_without_api_key() {
19019        let _env_guard = env_override_lock().await;
19020        let mut config = Config::default();
19021        config.providers.models.ollama.insert(
19022            "default".to_string(),
19023            OllamaModelProviderConfig {
19024                base: ModelProviderConfig {
19025                    model: Some("glm-5:cloud".to_string()),
19026                    uri: Some("http://192.168.1.100:11434".to_string()),
19027                    api_key: None,
19028                    ..Default::default()
19029                },
19030                ..OllamaModelProviderConfig::default()
19031            },
19032        );
19033
19034        let result = config.validate();
19035        assert!(result.is_ok(), "expected validation to pass: {result:?}");
19036    }
19037
19038    #[test]
19039    async fn validate_ollama_cloud_model_requires_api_key_for_official_endpoint() {
19040        let _env_guard = env_override_lock().await;
19041        let mut config = Config::default();
19042        config.providers.models.ollama.insert(
19043            "default".to_string(),
19044            OllamaModelProviderConfig {
19045                base: ModelProviderConfig {
19046                    model: Some("glm-5:cloud".to_string()),
19047                    uri: Some("https://ollama.com/api".to_string()),
19048                    api_key: None,
19049                    ..Default::default()
19050                },
19051                ..OllamaModelProviderConfig::default()
19052            },
19053        );
19054
19055        let error = config.validate().expect_err("expected validation to fail");
19056        assert!(error.to_string().contains(
19057            "providers.models.ollama.default.model uses ':cloud', but no API key is configured"
19058        ));
19059    }
19060
19061    #[test]
19062    async fn validate_ollama_cloud_model_accepts_remote_endpoint_with_typed_api_key() {
19063        // V0.8.0: env-var fallback (`OLLAMA_API_KEY`) eradicated.
19064        // Operators set the credential on the typed alias.
19065        let _env_guard = env_override_lock().await;
19066        let mut config = Config::default();
19067        config.providers.models.ollama.insert(
19068            "default".to_string(),
19069            OllamaModelProviderConfig {
19070                base: ModelProviderConfig {
19071                    model: Some("glm-5:cloud".to_string()),
19072                    uri: Some("https://ollama.com/api".to_string()),
19073                    api_key: Some("ollama-typed-key".to_string()),
19074                    ..Default::default()
19075                },
19076                ..OllamaModelProviderConfig::default()
19077            },
19078        );
19079
19080        let result = config.validate();
19081        assert!(result.is_ok(), "expected validation to pass: {result:?}");
19082    }
19083
19084    #[test]
19085    async fn validate_ollama_cloud_model_checks_each_alias_for_official_key() {
19086        let _env_guard = env_override_lock().await;
19087        let mut config = Config::default();
19088        config.providers.models.ollama.insert(
19089            "local".to_string(),
19090            OllamaModelProviderConfig {
19091                base: ModelProviderConfig {
19092                    model: Some("llama3".to_string()),
19093                    uri: Some("http://192.168.1.100:11434".to_string()),
19094                    ..Default::default()
19095                },
19096                ..OllamaModelProviderConfig::default()
19097            },
19098        );
19099        config.providers.models.ollama.insert(
19100            "cloud".to_string(),
19101            OllamaModelProviderConfig {
19102                base: ModelProviderConfig {
19103                    model: Some("glm-5:cloud".to_string()),
19104                    uri: Some("https://ollama.com/api".to_string()),
19105                    api_key: None,
19106                    ..Default::default()
19107                },
19108                ..OllamaModelProviderConfig::default()
19109            },
19110        );
19111
19112        let error = config.validate().expect_err("expected validation to fail");
19113        assert!(error.to_string().contains(
19114            "providers.models.ollama.cloud.model uses ':cloud', but no API key is configured"
19115        ));
19116    }
19117
19118    #[test]
19119    async fn deserialize_rejects_unknown_model_provider_wire_api() {
19120        let toml = r#"
19121schema_version = 3
19122
19123[providers.models.openrouter.default]
19124uri = "https://api.tonsof.blue/v1"
19125wire_api = "ws"
19126"#;
19127        let err = toml::from_str::<Config>(toml).expect_err("expected deserialize failure");
19128        let msg = err.to_string();
19129        assert!(
19130            msg.contains("wire_api") || msg.contains("ws"),
19131            "error should reference the invalid wire_api value, got: {msg}"
19132        );
19133    }
19134
19135    #[test]
19136    async fn resolve_runtime_config_dirs_accepts_legacy_zeroclaw_workspace() {
19137        let _env_guard = env_override_lock().await;
19138        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
19139        let default_workspace_dir = default_config_dir.join("workspace");
19140        let workspace_dir = default_config_dir.join("profile-a");
19141
19142        // SAFETY: test-only, single-threaded test runner.
19143        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
19144        let (config_dir, resolved_workspace_dir, source) =
19145            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
19146                .await
19147                .unwrap();
19148
19149        // ZEROCLAW_WORKSPACE is the deprecated alias for ZEROCLAW_DATA_DIR.
19150        // Resolution treats the path as the config root and derives the data
19151        // sub-dir from it; the source label reflects the deprecated entry.
19152        assert_eq!(source, ConfigResolutionSource::EnvWorkspaceLegacy);
19153        assert_eq!(config_dir, workspace_dir);
19154        assert_eq!(resolved_workspace_dir, workspace_dir.join("data"));
19155
19156        // SAFETY: test-only, single-threaded test runner.
19157        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19158        let _ = fs::remove_dir_all(default_config_dir).await;
19159    }
19160
19161    #[test]
19162    async fn resolve_runtime_config_dirs_uses_env_config_dir_first() {
19163        let _env_guard = env_override_lock().await;
19164        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
19165        let default_workspace_dir = default_config_dir.join("workspace");
19166        let explicit_config_dir = default_config_dir.join("explicit-config");
19167
19168        fs::create_dir_all(&default_config_dir).await.unwrap();
19169
19170        // SAFETY: test-only, single-threaded test runner.
19171        unsafe { std::env::set_var("ZEROCLAW_CONFIG_DIR", &explicit_config_dir) };
19172        // SAFETY: test-only, single-threaded test runner.
19173        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19174
19175        let (config_dir, resolved_workspace_dir, source) =
19176            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
19177                .await
19178                .unwrap();
19179
19180        assert_eq!(source, ConfigResolutionSource::EnvConfigDir);
19181        assert_eq!(config_dir, explicit_config_dir);
19182        assert_eq!(resolved_workspace_dir, explicit_config_dir.join("data"));
19183
19184        // SAFETY: test-only, single-threaded test runner.
19185        unsafe { std::env::remove_var("ZEROCLAW_CONFIG_DIR") };
19186        let _ = fs::remove_dir_all(default_config_dir).await;
19187    }
19188
19189    #[test]
19190    async fn resolve_runtime_config_dirs_falls_back_to_default_layout() {
19191        let _env_guard = env_override_lock().await;
19192        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
19193        let default_workspace_dir = default_config_dir.join("workspace");
19194
19195        // SAFETY: test-only, single-threaded test runner.
19196        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19197        let (config_dir, resolved_workspace_dir, source) =
19198            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
19199                .await
19200                .unwrap();
19201
19202        assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
19203        assert_eq!(config_dir, default_config_dir);
19204        assert_eq!(resolved_workspace_dir, default_workspace_dir);
19205
19206        let _ = fs::remove_dir_all(default_config_dir).await;
19207    }
19208
19209    async fn create_homebrew_prefix() -> TempDir {
19210        let prefix = TempDir::new().expect("homebrew prefix temp dir");
19211        fs::create_dir_all(prefix.path().join("Cellar"))
19212            .await
19213            .expect("create Cellar marker");
19214        prefix
19215    }
19216
19217    #[test]
19218    async fn try_resolve_macos_homebrew_config_dir_detects_cellar_layout() {
19219        let prefix = create_homebrew_prefix().await;
19220        let exe = prefix
19221            .path()
19222            .join("Cellar")
19223            .join("zeroclaw")
19224            .join("0.7.0")
19225            .join("bin")
19226            .join("zeroclaw");
19227
19228        let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
19229            .await
19230            .expect("expected Homebrew layout");
19231
19232        assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
19233    }
19234
19235    #[test]
19236    async fn try_resolve_macos_homebrew_config_dir_detects_prefix_bin_layout() {
19237        let prefix = create_homebrew_prefix().await;
19238        let exe = prefix.path().join("bin").join("zeroclaw");
19239
19240        let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
19241            .await
19242            .expect("expected Homebrew layout");
19243
19244        assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
19245    }
19246
19247    #[test]
19248    async fn try_resolve_macos_homebrew_config_dir_detects_opt_bin_layout() {
19249        let prefix = create_homebrew_prefix().await;
19250        let exe = prefix
19251            .path()
19252            .join("opt")
19253            .join("zeroclaw")
19254            .join("bin")
19255            .join("zeroclaw");
19256
19257        let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
19258            .await
19259            .expect("expected Homebrew layout");
19260
19261        assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
19262    }
19263
19264    #[test]
19265    async fn try_resolve_macos_homebrew_config_dir_rejects_non_homebrew_layout() {
19266        let prefix = TempDir::new().expect("non-homebrew temp dir");
19267        let exe = prefix.path().join("bin").join("zeroclaw");
19268
19269        assert!(try_resolve_macos_homebrew_config_dir(&exe).await.is_none());
19270    }
19271
19272    #[test]
19273    async fn default_path_under_config_dir_respects_zeroclaw_config_dir() {
19274        let _env_guard = env_override_lock().await;
19275        let custom_dir = std::env::temp_dir().join("zeroclaw-test-profile");
19276        // SAFETY: test-only, single-threaded test runner.
19277        unsafe { std::env::set_var("ZEROCLAW_CONFIG_DIR", &custom_dir) };
19278
19279        let result = default_path_under_config_dir("knowledge.db");
19280
19281        // SAFETY: test-only, single-threaded test runner.
19282        unsafe { std::env::remove_var("ZEROCLAW_CONFIG_DIR") };
19283
19284        assert_eq!(
19285            result,
19286            custom_dir.join("knowledge.db").to_string_lossy().as_ref(),
19287            "expected path under ZEROCLAW_CONFIG_DIR, got: {result}"
19288        );
19289    }
19290
19291    #[test]
19292    async fn load_or_init_workspace_override_uses_workspace_root_for_config() {
19293        let _env_guard = env_override_lock().await;
19294        let temp_home =
19295            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
19296        let workspace_dir = temp_home.join("profile-a");
19297
19298        let original_home = std::env::var("HOME").ok();
19299        // SAFETY: test-only, single-threaded test runner.
19300        unsafe { std::env::set_var("HOME", &temp_home) };
19301        // SAFETY: test-only, single-threaded test runner.
19302        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
19303
19304        let config = Box::pin(Config::load_or_init()).await.unwrap();
19305
19306        // V3 fresh init: `config.data_dir` lives at `<install>/data/`
19307        // (the shared databases root); the install root holds
19308        // `config.toml`. No synthesized `agents/default/workspace/` is
19309        // created at boot — `default` is migration-only, and per-agent
19310        // workspaces are created lazily at agent-loop entry.
19311        assert_eq!(config.data_dir, workspace_dir.join("data"));
19312        assert_eq!(config.config_path, workspace_dir.join("config.toml"));
19313        assert!(workspace_dir.join("config.toml").exists());
19314        assert!(
19315            !workspace_dir.join("agents").exists(),
19316            "fresh init must not create agents/ tree"
19317        );
19318
19319        // SAFETY: test-only, single-threaded test runner.
19320        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19321        if let Some(home) = original_home {
19322            // SAFETY: test-only, single-threaded test runner.
19323            unsafe { std::env::set_var("HOME", home) };
19324        } else {
19325            // SAFETY: test-only, single-threaded test runner.
19326            unsafe { std::env::remove_var("HOME") };
19327        }
19328        let _ = fs::remove_dir_all(temp_home).await;
19329    }
19330
19331    #[test]
19332    async fn load_or_init_workspace_suffix_uses_legacy_config_layout() {
19333        let _env_guard = env_override_lock().await;
19334        let temp_home =
19335            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
19336        let workspace_dir = temp_home.join("workspace");
19337        let legacy_config_dir = temp_home.join(".zeroclaw");
19338        let legacy_config_path = legacy_config_dir.join("config.toml");
19339
19340        let original_home = std::env::var("HOME").ok();
19341        // SAFETY: test-only, single-threaded test runner.
19342        unsafe { std::env::set_var("HOME", &temp_home) };
19343        // SAFETY: test-only, single-threaded test runner.
19344        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
19345
19346        let config = Box::pin(Config::load_or_init()).await.unwrap();
19347
19348        // V3: `config.data_dir` lives at `<install>/data/`. The
19349        // ZEROCLAW_WORKSPACE env var (deprecated alias) resolved to the
19350        // legacy config layout where the install root is the parent of
19351        // the env-var path; data sits at `<install>/data/`.
19352        assert_eq!(config.data_dir, legacy_config_dir.join("data"));
19353        assert_eq!(config.config_path, legacy_config_path);
19354        assert!(config.config_path.exists());
19355
19356        // SAFETY: test-only, single-threaded test runner.
19357        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19358        if let Some(home) = original_home {
19359            // SAFETY: test-only, single-threaded test runner.
19360            unsafe { std::env::set_var("HOME", home) };
19361        } else {
19362            // SAFETY: test-only, single-threaded test runner.
19363            unsafe { std::env::remove_var("HOME") };
19364        }
19365        let _ = fs::remove_dir_all(temp_home).await;
19366    }
19367
19368    #[test]
19369    async fn load_or_init_workspace_override_keeps_existing_legacy_config() {
19370        let _env_guard = env_override_lock().await;
19371        let temp_home =
19372            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
19373        let workspace_dir = temp_home.join("custom-workspace");
19374        let legacy_config_dir = temp_home.join(".zeroclaw");
19375        let legacy_config_path = legacy_config_dir.join("config.toml");
19376
19377        fs::create_dir_all(&legacy_config_dir).await.unwrap();
19378        fs::write(
19379            &legacy_config_path,
19380            r#"default_temperature = 0.7
19381default_model = "legacy-model"
19382"#,
19383        )
19384        .await
19385        .unwrap();
19386
19387        let original_home = std::env::var("HOME").ok();
19388        // SAFETY: test-only, single-threaded test runner.
19389        unsafe { std::env::set_var("HOME", &temp_home) };
19390        // SAFETY: test-only, single-threaded test runner.
19391        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
19392
19393        let config = Box::pin(Config::load_or_init()).await.unwrap();
19394
19395        // V3: `config.data_dir` resolves to `<install>/data/` under
19396        // the install root (the directory holding the existing
19397        // `config.toml`), regardless of the ZEROCLAW_WORKSPACE
19398        // (deprecated) override.
19399        assert_eq!(config.data_dir, legacy_config_dir.join("data"));
19400        assert_eq!(config.config_path, legacy_config_path);
19401        assert_eq!(
19402            config
19403                .first_model_provider()
19404                .and_then(|e| e.model.as_deref()),
19405            Some("legacy-model")
19406        );
19407
19408        // SAFETY: test-only, single-threaded test runner.
19409        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19410        if let Some(home) = original_home {
19411            // SAFETY: test-only, single-threaded test runner.
19412            unsafe { std::env::set_var("HOME", home) };
19413        } else {
19414            // SAFETY: test-only, single-threaded test runner.
19415            unsafe { std::env::remove_var("HOME") };
19416        }
19417        let _ = fs::remove_dir_all(temp_home).await;
19418    }
19419
19420    #[test]
19421    async fn load_or_init_decrypts_feishu_channel_secrets() {
19422        let _env_guard = env_override_lock().await;
19423        let temp_home =
19424            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
19425        let config_dir = temp_home.join(".zeroclaw");
19426        let config_path = config_dir.join("config.toml");
19427
19428        fs::create_dir_all(&config_dir).await.unwrap();
19429
19430        let original_home = std::env::var("HOME").ok();
19431        // SAFETY: test-only, single-threaded test runner.
19432        unsafe { std::env::set_var("HOME", &temp_home) };
19433        // SAFETY: test-only, single-threaded test runner.
19434        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19435
19436        let mut config = Config {
19437            config_path: config_path.clone(),
19438            data_dir: config_dir.join("workspace"),
19439            ..Default::default()
19440        };
19441        config.secrets.encrypt = true;
19442        config.channels.lark.insert(
19443            "feishu".to_string(),
19444            LarkConfig {
19445                enabled: true,
19446                app_id: "cli_feishu_123".into(),
19447                app_secret: "feishu-secret".into(),
19448                encrypt_key: Some("feishu-encrypt".into()),
19449                verification_token: Some("feishu-verify".into()),
19450                mention_only: false,
19451                use_feishu: true,
19452                receive_mode: LarkReceiveMode::Websocket,
19453                port: None,
19454                proxy_url: None,
19455                excluded_tools: vec![],
19456                default_target: None,
19457            },
19458        );
19459        config.save().await.unwrap();
19460
19461        let loaded = Box::pin(Config::load_or_init()).await.unwrap();
19462        let feishu = loaded.channels.lark.get("feishu").unwrap();
19463        assert_eq!(feishu.app_secret, "feishu-secret");
19464        assert_eq!(feishu.encrypt_key.as_deref(), Some("feishu-encrypt"));
19465        assert_eq!(feishu.verification_token.as_deref(), Some("feishu-verify"));
19466
19467        if let Some(home) = original_home {
19468            // SAFETY: test-only, single-threaded test runner.
19469            unsafe { std::env::set_var("HOME", home) };
19470        } else {
19471            // SAFETY: test-only, single-threaded test runner.
19472            unsafe { std::env::remove_var("HOME") };
19473        }
19474        let _ = fs::remove_dir_all(temp_home).await;
19475    }
19476
19477    #[test]
19478    #[allow(clippy::large_futures)]
19479    async fn load_or_init_logs_existing_config_as_initialized() {
19480        let _env_guard = env_override_lock().await;
19481        let temp_home =
19482            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
19483        let workspace_dir = temp_home.join("profile-a");
19484        let config_path = workspace_dir.join("config.toml");
19485
19486        fs::create_dir_all(&workspace_dir).await.unwrap();
19487        fs::write(
19488            &config_path,
19489            r#"default_temperature = 0.7
19490default_model = "persisted-profile"
19491"#,
19492        )
19493        .await
19494        .unwrap();
19495
19496        let original_home = std::env::var("HOME").ok();
19497        // SAFETY: test-only, single-threaded test runner.
19498        unsafe { std::env::set_var("HOME", &temp_home) };
19499        // SAFETY: test-only, single-threaded test runner.
19500        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
19501
19502        let mut rx = capture_log_events();
19503
19504        let config = Box::pin(Config::load_or_init()).await.unwrap();
19505
19506        let logs = drain_captured(&mut rx);
19507
19508        // V3: shared databases live at `<install>/data/`, per-agent
19509        // identity at `<install>/agents/<alias>/workspace/`. The
19510        // ZEROCLAW_WORKSPACE env var (deprecated alias for
19511        // ZEROCLAW_DATA_DIR) pinned the install root, so data_dir is
19512        // `<install>/data/` derived from the resolved root.
19513        assert_eq!(config.data_dir, workspace_dir.join("data"));
19514        assert_eq!(config.config_path, config_path);
19515        assert_eq!(
19516            config
19517                .first_model_provider()
19518                .and_then(|e| e.model.as_deref()),
19519            Some("persisted-profile")
19520        );
19521        assert!(logs.contains("Config loaded"), "{logs}");
19522        assert!(logs.contains("\"initialized\":true"), "{logs}");
19523        assert!(!logs.contains("\"initialized\":false"), "{logs}");
19524
19525        // SAFETY: test-only, single-threaded test runner.
19526        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
19527        if let Some(home) = original_home {
19528            // SAFETY: test-only, single-threaded test runner.
19529            unsafe { std::env::set_var("HOME", home) };
19530        } else {
19531            // SAFETY: test-only, single-threaded test runner.
19532            unsafe { std::env::remove_var("HOME") };
19533        }
19534        let _ = fs::remove_dir_all(temp_home).await;
19535    }
19536
19537    #[test]
19538    async fn validate_rejects_out_of_range_temperature() {
19539        let mut config = Config::default();
19540        config.providers.models.openrouter.insert(
19541            "default".to_string(),
19542            OpenRouterModelProviderConfig {
19543                base: ModelProviderConfig {
19544                    api_key: Some("sk-test".into()),
19545                    temperature: Some(99.0),
19546                    ..Default::default()
19547                },
19548            },
19549        );
19550        let err = config.validate().unwrap_err();
19551        assert!(
19552            err.to_string().contains("temperature"),
19553            "expected temperature validation error, got: {err}"
19554        );
19555    }
19556
19557    #[test]
19558    async fn validate_rejects_negative_temperature() {
19559        let mut config = Config::default();
19560        config.providers.models.openrouter.insert(
19561            "default".to_string(),
19562            OpenRouterModelProviderConfig {
19563                base: ModelProviderConfig {
19564                    api_key: Some("sk-test".into()),
19565                    temperature: Some(-0.5),
19566                    ..Default::default()
19567                },
19568            },
19569        );
19570        let err = config.validate().unwrap_err();
19571        assert!(
19572            err.to_string().contains("temperature"),
19573            "expected temperature validation error, got: {err}"
19574        );
19575    }
19576
19577    #[test]
19578    async fn validate_accepts_valid_temperature() {
19579        let mut config = Config::default();
19580        config.providers.models.openrouter.insert(
19581            "default".to_string(),
19582            OpenRouterModelProviderConfig {
19583                base: ModelProviderConfig {
19584                    temperature: Some(0.7),
19585                    ..Default::default()
19586                },
19587            },
19588        );
19589        assert!(config.validate().is_ok());
19590    }
19591
19592    #[test]
19593    async fn validate_rejects_unknown_jira_actions() {
19594        for action in ["delete_ticket", "drop_database", ""] {
19595            let mut config = Config::default();
19596            config.jira.enabled = true;
19597            config.jira.base_url = "https://jira.example.test".into();
19598            config.jira.api_token = "token".into();
19599            config.jira.allowed_actions = vec![action.into()];
19600
19601            let err = config
19602                .validate()
19603                .expect_err("unknown Jira action should be rejected")
19604                .to_string();
19605            assert!(
19606                err.contains("jira.allowed_actions contains unknown action"),
19607                "expected Jira allowed action error for {action:?}, got: {err}"
19608            );
19609        }
19610    }
19611
19612    #[test]
19613    async fn validate_accepts_all_published_jira_actions() {
19614        for action in [
19615            "get_ticket",
19616            "search_tickets",
19617            "comment_ticket",
19618            "list_projects",
19619            "myself",
19620            "list_transitions",
19621            "transition_ticket",
19622            "create_ticket",
19623        ] {
19624            let mut config = Config::default();
19625            config.jira.enabled = true;
19626            config.jira.base_url = "https://jira.example.test".into();
19627            config.jira.api_token = "token".into();
19628            config.jira.allowed_actions = vec![action.into()];
19629
19630            assert!(
19631                config.validate().is_ok(),
19632                "published Jira action {action:?} should validate"
19633            );
19634        }
19635    }
19636
19637    #[test]
19638    async fn jira_email_empty_string_deserializes_as_none() {
19639        // Legacy configs round-tripped `email = ""` to disk because the
19640        // pre-rename `email: String` lacked `skip_serializing_if`. The
19641        // current `Option<String>` would otherwise deserialize `""` as
19642        // `Some("")`, and JiraTool would attempt Basic auth with empty
19643        // username (the dropped email-required validation no longer
19644        // catches this). Defense-in-depth: empty strings deserialize as
19645        // None.
19646        let toml_input = r#"
19647enabled = true
19648base_url = "https://jira.example.test"
19649email = ""
19650api_token = "tok"
19651"#;
19652        let cfg: JiraConfig = toml::from_str(toml_input).expect("parses with empty email");
19653        assert!(
19654            cfg.email.is_none(),
19655            "empty `email = \"\"` must deserialize as None, got {:?}",
19656            cfg.email
19657        );
19658        // Whitespace-only is also normalized to None.
19659        let toml_input_ws = r#"
19660enabled = true
19661base_url = "https://jira.example.test"
19662email = "   "
19663api_token = "tok"
19664"#;
19665        let cfg_ws: JiraConfig =
19666            toml::from_str(toml_input_ws).expect("parses with whitespace email");
19667        assert!(
19668            cfg_ws.email.is_none(),
19669            "whitespace-only email must deserialize as None, got {:?}",
19670            cfg_ws.email
19671        );
19672        // A real email still survives.
19673        let toml_input_real = r#"
19674enabled = true
19675base_url = "https://jira.example.test"
19676email = "ops@example.com"
19677api_token = "tok"
19678"#;
19679        let cfg_real: JiraConfig = toml::from_str(toml_input_real).expect("parses with real email");
19680        assert_eq!(
19681            cfg_real.email.as_deref(),
19682            Some("ops@example.com"),
19683            "non-empty email must round-trip unchanged"
19684        );
19685    }
19686
19687    #[test]
19688    async fn proxy_config_scope_services_requires_entries_when_enabled() {
19689        let proxy = ProxyConfig {
19690            enabled: true,
19691            http_proxy: Some("http://127.0.0.1:7890".into()),
19692            https_proxy: None,
19693            all_proxy: None,
19694            no_proxy: Vec::new(),
19695            scope: ProxyScope::Services,
19696            services: Vec::new(),
19697        };
19698
19699        let error = proxy.validate().unwrap_err().to_string();
19700        assert!(error.contains("proxy.scope='services'"));
19701    }
19702
19703    #[test]
19704    async fn google_workspace_allowed_operations_require_methods() {
19705        let mut config = Config::default();
19706        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
19707            service: "gmail".into(),
19708            resource: "users".into(),
19709            sub_resource: Some("drafts".into()),
19710            methods: Vec::new(),
19711        }];
19712
19713        let err = config.validate().unwrap_err().to_string();
19714        assert!(err.contains("google_workspace.allowed_operations[0].methods"));
19715    }
19716
19717    #[test]
19718    async fn google_workspace_allowed_operations_reject_duplicate_service_resource_sub_resource_entries()
19719     {
19720        let mut config = Config::default();
19721        config.google_workspace.allowed_operations = vec![
19722            GoogleWorkspaceAllowedOperation {
19723                service: "gmail".into(),
19724                resource: "users".into(),
19725                sub_resource: Some("drafts".into()),
19726                methods: vec!["create".into()],
19727            },
19728            GoogleWorkspaceAllowedOperation {
19729                service: "gmail".into(),
19730                resource: "users".into(),
19731                sub_resource: Some("drafts".into()),
19732                methods: vec!["update".into()],
19733            },
19734        ];
19735
19736        let err = config.validate().unwrap_err().to_string();
19737        assert!(err.contains("duplicate service/resource/sub_resource entry"));
19738    }
19739
19740    #[test]
19741    async fn google_workspace_allowed_operations_allow_same_resource_different_sub_resource() {
19742        let mut config = Config::default();
19743        config.google_workspace.allowed_operations = vec![
19744            GoogleWorkspaceAllowedOperation {
19745                service: "gmail".into(),
19746                resource: "users".into(),
19747                sub_resource: Some("messages".into()),
19748                methods: vec!["list".into(), "get".into()],
19749            },
19750            GoogleWorkspaceAllowedOperation {
19751                service: "gmail".into(),
19752                resource: "users".into(),
19753                sub_resource: Some("drafts".into()),
19754                methods: vec!["create".into(), "update".into()],
19755            },
19756        ];
19757
19758        assert!(config.validate().is_ok());
19759    }
19760
19761    #[test]
19762    async fn google_workspace_allowed_operations_reject_duplicate_methods_within_entry() {
19763        let mut config = Config::default();
19764        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
19765            service: "gmail".into(),
19766            resource: "users".into(),
19767            sub_resource: Some("drafts".into()),
19768            methods: vec!["create".into(), "create".into()],
19769        }];
19770
19771        let err = config.validate().unwrap_err().to_string();
19772        assert!(
19773            err.contains("duplicate entry"),
19774            "expected duplicate entry error, got: {err}"
19775        );
19776    }
19777
19778    #[test]
19779    async fn google_workspace_allowed_operations_accept_valid_entries() {
19780        let mut config = Config::default();
19781        config.google_workspace.allowed_operations = vec![
19782            GoogleWorkspaceAllowedOperation {
19783                service: "gmail".into(),
19784                resource: "users".into(),
19785                sub_resource: Some("messages".into()),
19786                methods: vec!["list".into(), "get".into()],
19787            },
19788            GoogleWorkspaceAllowedOperation {
19789                service: "drive".into(),
19790                resource: "files".into(),
19791                sub_resource: None,
19792                methods: vec!["list".into(), "get".into()],
19793            },
19794        ];
19795
19796        assert!(config.validate().is_ok());
19797    }
19798
19799    #[test]
19800    async fn google_workspace_allowed_operations_reject_invalid_sub_resource_characters() {
19801        let mut config = Config::default();
19802        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
19803            service: "gmail".into(),
19804            resource: "users".into(),
19805            sub_resource: Some("bad resource!".into()),
19806            methods: vec!["list".into()],
19807        }];
19808
19809        let err = config.validate().unwrap_err().to_string();
19810        assert!(err.contains("sub_resource contains invalid characters"));
19811    }
19812
19813    fn runtime_proxy_cache_contains(cache_key: &str) -> bool {
19814        match runtime_proxy_client_cache().read() {
19815            Ok(guard) => guard.contains_key(cache_key),
19816            Err(poisoned) => poisoned.into_inner().contains_key(cache_key),
19817        }
19818    }
19819
19820    #[test]
19821    async fn runtime_proxy_client_cache_reuses_default_profile_key() {
19822        let service_key = format!(
19823            "model_provider.cache_test.{}",
19824            std::time::SystemTime::now()
19825                .duration_since(std::time::UNIX_EPOCH)
19826                .expect("system clock should be after unix epoch")
19827                .as_nanos()
19828        );
19829        let cache_key = runtime_proxy_cache_key(&service_key, None, None);
19830
19831        clear_runtime_proxy_client_cache();
19832        assert!(!runtime_proxy_cache_contains(&cache_key));
19833
19834        let _ = build_runtime_proxy_client(&service_key);
19835        assert!(runtime_proxy_cache_contains(&cache_key));
19836
19837        let _ = build_runtime_proxy_client(&service_key);
19838        assert!(runtime_proxy_cache_contains(&cache_key));
19839    }
19840
19841    #[test]
19842    async fn set_runtime_proxy_config_clears_runtime_proxy_client_cache() {
19843        let service_key = format!(
19844            "model_provider.cache_timeout_test.{}",
19845            std::time::SystemTime::now()
19846                .duration_since(std::time::UNIX_EPOCH)
19847                .expect("system clock should be after unix epoch")
19848                .as_nanos()
19849        );
19850        let cache_key = runtime_proxy_cache_key(&service_key, Some(30), Some(5));
19851
19852        clear_runtime_proxy_client_cache();
19853        let _ = build_runtime_proxy_client_with_timeouts(&service_key, 30, 5);
19854        assert!(runtime_proxy_cache_contains(&cache_key));
19855
19856        set_runtime_proxy_config(ProxyConfig::default());
19857        assert!(!runtime_proxy_cache_contains(&cache_key));
19858    }
19859
19860    #[test]
19861    async fn gateway_config_default_values() {
19862        let g = GatewayConfig::default();
19863        assert_eq!(g.port, 42617);
19864        assert_eq!(g.host, "127.0.0.1");
19865        assert!(g.require_pairing);
19866        assert!(!g.allow_public_bind);
19867        assert!(g.paired_tokens.is_empty());
19868        assert!(!g.trust_forwarded_headers);
19869        assert_eq!(g.rate_limit_max_keys, 10_000);
19870        assert_eq!(g.idempotency_max_keys, 10_000);
19871    }
19872
19873    // ── Peripherals config ───────────────────────────────────────
19874
19875    #[test]
19876    async fn peripherals_config_default_disabled() {
19877        let p = PeripheralsConfig::default();
19878        assert!(!p.enabled);
19879        assert!(p.boards.is_empty());
19880    }
19881
19882    #[test]
19883    async fn peripheral_board_config_defaults() {
19884        let b = PeripheralBoardConfig::default();
19885        assert!(b.board.is_empty());
19886        assert_eq!(b.transport, "serial");
19887        assert!(b.path.is_none());
19888        assert_eq!(b.baud, 115_200);
19889    }
19890
19891    #[test]
19892    async fn peripherals_config_toml_roundtrip() {
19893        let p = PeripheralsConfig {
19894            enabled: true,
19895            boards: vec![PeripheralBoardConfig {
19896                board: "nucleo-f401re".into(),
19897                transport: "serial".into(),
19898                path: Some("/dev/ttyACM0".into()),
19899                baud: 115_200,
19900            }],
19901            datasheet_dir: None,
19902        };
19903        let toml_str = toml::to_string(&p).unwrap();
19904        let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
19905        assert!(parsed.enabled);
19906        assert_eq!(parsed.boards.len(), 1);
19907        assert_eq!(parsed.boards[0].board, "nucleo-f401re");
19908        assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
19909    }
19910
19911    #[test]
19912    async fn lark_config_serde() {
19913        let lc = LarkConfig {
19914            enabled: true,
19915            app_id: "cli_123456".into(),
19916            app_secret: "secret_abc".into(),
19917            encrypt_key: Some("encrypt_key".into()),
19918            verification_token: Some("verify_token".into()),
19919            mention_only: false,
19920            use_feishu: true,
19921            receive_mode: LarkReceiveMode::Websocket,
19922            port: None,
19923            proxy_url: None,
19924            excluded_tools: vec![],
19925            default_target: None,
19926        };
19927        let json = serde_json::to_string(&lc).unwrap();
19928        let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
19929        assert_eq!(parsed.app_id, "cli_123456");
19930        assert_eq!(parsed.app_secret, "secret_abc");
19931        assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
19932        assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
19933        assert!(parsed.use_feishu);
19934    }
19935
19936    #[test]
19937    async fn lark_config_toml_roundtrip() {
19938        let lc = LarkConfig {
19939            enabled: true,
19940            app_id: "cli_123456".into(),
19941            app_secret: "secret_abc".into(),
19942            encrypt_key: Some("encrypt_key".into()),
19943            verification_token: Some("verify_token".into()),
19944            mention_only: false,
19945            use_feishu: false,
19946            receive_mode: LarkReceiveMode::Webhook,
19947            port: Some(9898),
19948            proxy_url: None,
19949            excluded_tools: vec![],
19950            default_target: None,
19951        };
19952        let toml_str = toml::to_string(&lc).unwrap();
19953        let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
19954        assert_eq!(parsed.app_id, "cli_123456");
19955        assert_eq!(parsed.app_secret, "secret_abc");
19956        assert!(!parsed.use_feishu);
19957    }
19958
19959    #[test]
19960    async fn lark_config_deserializes_without_optional_fields() {
19961        let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
19962        let parsed: LarkConfig = serde_json::from_str(json).unwrap();
19963        assert!(parsed.encrypt_key.is_none());
19964        assert!(parsed.verification_token.is_none());
19965        assert!(!parsed.mention_only);
19966        assert!(!parsed.use_feishu);
19967    }
19968
19969    #[test]
19970    async fn lark_config_defaults_to_lark_endpoint() {
19971        let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
19972        let parsed: LarkConfig = serde_json::from_str(json).unwrap();
19973        assert!(
19974            !parsed.use_feishu,
19975            "use_feishu should default to false (Lark)"
19976        );
19977    }
19978
19979    #[test]
19980    async fn lark_v2_allowed_users_fold_into_peer_groups() {
19981        // V2 `allowed_users` on a Lark channel migrates to a synthesized
19982        // `peer_groups.lark_default` group. The wildcard `*` is dropped at
19983        // synthesis (operator-explicit lists only); concrete user IDs
19984        // round-trip through.
19985        let raw = r#"
19986schema_version = 2
19987
19988[channels.lark]
19989enabled = true
19990app_id = "cli_123"
19991app_secret = "secret"
19992allowed_users = ["user_alpha", "user_beta"]
19993"#;
19994        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
19995        let group = parsed
19996            .peer_groups
19997            .get("lark_default")
19998            .expect("V2 lark.allowed_users must fold into peer_groups.lark_default");
19999        assert_eq!(group.channel, "lark");
20000        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
20001        assert_eq!(usernames, vec!["user_alpha", "user_beta"]);
20002    }
20003
20004    // ── LINE ──────────────────────────────────────────────────
20005
20006    #[test]
20007    async fn line_config_toml_roundtrip() {
20008        // Full [channels.line] TOML block — covers every user-facing field.
20009        //
20010        // channel_access_token and channel_secret can be omitted here and
20011        // supplied via LINE_CHANNEL_ACCESS_TOKEN / LINE_CHANNEL_SECRET env vars
20012        // instead; both fields default to "" when absent.
20013        let toml = r#"
20014[channels_config.line.default]
20015enabled = true
20016channel_access_token = "ChannelAccessToken=="
20017channel_secret = "abc123secret"
20018dm_policy = "pairing"
20019group_policy = "mention"
20020allowed_users = []
20021webhook_port = 8443
20022"#;
20023        let config: Config = toml::from_str(toml).unwrap();
20024        let ln = config.channels.line.get("default").unwrap();
20025        assert_eq!(ln.channel_access_token, "ChannelAccessToken==");
20026        assert_eq!(ln.channel_secret, "abc123secret");
20027        assert_eq!(ln.dm_policy, LineDmPolicy::Pairing);
20028        assert_eq!(ln.group_policy, LineGroupPolicy::Mention);
20029        assert_eq!(ln.webhook_port, 8443);
20030        assert!(ln.proxy_url.is_none());
20031    }
20032
20033    #[test]
20034    async fn line_config_defaults() {
20035        // Minimal config — only the required secret fields are provided.
20036        // All optional fields should resolve to documented defaults.
20037        let toml = r#"
20038[channels_config.line.default]
20039channel_access_token = "tok"
20040channel_secret = "sec"
20041"#;
20042        let config: Config = toml::from_str(toml).unwrap();
20043        let ln = config.channels.line.get("default").unwrap();
20044        assert_eq!(
20045            ln.dm_policy,
20046            LineDmPolicy::Pairing,
20047            "dm_policy default is pairing"
20048        );
20049        assert_eq!(
20050            ln.group_policy,
20051            LineGroupPolicy::Mention,
20052            "group_policy default is mention"
20053        );
20054        assert_eq!(ln.webhook_port, 8443, "webhook_port default is 8443");
20055        assert!(ln.proxy_url.is_none());
20056    }
20057
20058    #[test]
20059    async fn line_config_allowlist_policy() {
20060        // dm_policy = allowlist; the user ID list itself now lives on the
20061        // V3 `peer_groups.line_default` group (synthesized from V2's
20062        // `allowed_users`), not on the LineConfig struct.
20063        let toml = r#"
20064schema_version = 2
20065
20066[channels.line]
20067enabled = true
20068channel_access_token = "tok"
20069channel_secret = "sec"
20070dm_policy = "allowlist"
20071allowed_users = ["Uabc123", "Udef456"]
20072"#;
20073        let config = crate::migration::migrate_to_current(toml).expect("migration succeeds");
20074        let ln = config.channels.line.get("default").unwrap();
20075        assert_eq!(ln.dm_policy, LineDmPolicy::Allowlist);
20076        let group = config
20077            .peer_groups
20078            .get("line_default")
20079            .expect("V2 line.allowed_users must fold into peer_groups.line_default");
20080        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
20081        assert_eq!(usernames, vec!["Uabc123", "Udef456"]);
20082    }
20083
20084    #[test]
20085    async fn line_config_open_policies() {
20086        // dm_policy = open + group_policy = open — most permissive combination.
20087        let toml = r#"
20088[channels_config.line.default]
20089channel_access_token = "tok"
20090channel_secret = "sec"
20091dm_policy = "open"
20092group_policy = "open"
20093"#;
20094        let config: Config = toml::from_str(toml).unwrap();
20095        let ln = config.channels.line.get("default").unwrap();
20096        assert_eq!(ln.dm_policy, LineDmPolicy::Open);
20097        assert_eq!(ln.group_policy, LineGroupPolicy::Open);
20098    }
20099
20100    #[test]
20101    async fn line_config_group_disabled() {
20102        // group_policy = disabled — bot ignores all group/room messages.
20103        let toml = r#"
20104[channels_config.line.default]
20105channel_access_token = "tok"
20106channel_secret = "sec"
20107group_policy = "disabled"
20108"#;
20109        let config: Config = toml::from_str(toml).unwrap();
20110        let ln = config.channels.line.get("default").unwrap();
20111        assert_eq!(ln.group_policy, LineGroupPolicy::Disabled);
20112    }
20113
20114    #[test]
20115    async fn nextcloud_talk_config_serde() {
20116        let nc = NextcloudTalkConfig {
20117            enabled: true,
20118            base_url: "https://cloud.example.com".into(),
20119            app_token: "app-token".into(),
20120            webhook_secret: Some("webhook-secret".into()),
20121            proxy_url: None,
20122            bot_name: None,
20123            excluded_tools: vec![],
20124            stream_mode: StreamMode::default(),
20125            draft_update_interval_ms: 1000,
20126        };
20127
20128        let json = serde_json::to_string(&nc).unwrap();
20129        let parsed: NextcloudTalkConfig = serde_json::from_str(&json).unwrap();
20130        assert_eq!(parsed.base_url, "https://cloud.example.com");
20131        assert_eq!(parsed.app_token, "app-token");
20132        assert_eq!(parsed.webhook_secret.as_deref(), Some("webhook-secret"));
20133    }
20134
20135    #[test]
20136    async fn nextcloud_talk_config_defaults_optional_fields() {
20137        let json = r#"{"base_url":"https://cloud.example.com","app_token":"app-token"}"#;
20138        let parsed: NextcloudTalkConfig = serde_json::from_str(json).unwrap();
20139        assert!(parsed.webhook_secret.is_none());
20140    }
20141
20142    // ── Config file permission hardening (Unix only) ───────────────
20143
20144    #[cfg(unix)]
20145    #[test]
20146    async fn new_config_file_has_restricted_permissions() {
20147        let tmp = tempfile::TempDir::new().unwrap();
20148        let config_path = tmp.path().join("config.toml");
20149
20150        // Create a config and save it
20151        let config = Config {
20152            config_path: config_path.clone(),
20153            ..Default::default()
20154        };
20155        config.save().await.unwrap();
20156
20157        let meta = fs::metadata(&config_path).await.unwrap();
20158        let mode = meta.permissions().mode() & 0o777;
20159        assert_eq!(
20160            mode, 0o600,
20161            "New config file should be owner-only (0600), got {mode:o}"
20162        );
20163    }
20164
20165    #[cfg(unix)]
20166    #[test]
20167    async fn save_restricts_existing_world_readable_config_to_owner_only() {
20168        let tmp = tempfile::TempDir::new().unwrap();
20169        let config_path = tmp.path().join("config.toml");
20170
20171        let mut config = Config {
20172            config_path: config_path.clone(),
20173            ..Default::default()
20174        };
20175        config.save().await.unwrap();
20176
20177        // Simulate the regression state observed in issue #1345.
20178        std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
20179        let loose_mode = std::fs::metadata(&config_path)
20180            .unwrap()
20181            .permissions()
20182            .mode()
20183            & 0o777;
20184        assert_eq!(
20185            loose_mode, 0o644,
20186            "test setup requires world-readable config"
20187        );
20188
20189        if let Some(entry) = config.first_model_provider_mut() {
20190            entry.temperature = Some(0.6);
20191        }
20192        config.save().await.unwrap();
20193
20194        let hardened_mode = std::fs::metadata(&config_path)
20195            .unwrap()
20196            .permissions()
20197            .mode()
20198            & 0o777;
20199        assert_eq!(
20200            hardened_mode, 0o600,
20201            "Saving config should restore owner-only permissions (0600)"
20202        );
20203    }
20204
20205    #[cfg(unix)]
20206    #[test]
20207    async fn world_readable_config_is_detectable() {
20208        use std::os::unix::fs::PermissionsExt;
20209
20210        let tmp = tempfile::TempDir::new().unwrap();
20211        let config_path = tmp.path().join("config.toml");
20212
20213        // Create a config file with intentionally loose permissions
20214        std::fs::write(&config_path, "# test config").unwrap();
20215        std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
20216
20217        let meta = std::fs::metadata(&config_path).unwrap();
20218        let mode = meta.permissions().mode();
20219        assert!(
20220            mode & 0o004 != 0,
20221            "Test setup: file should be world-readable (mode {mode:o})"
20222        );
20223    }
20224
20225    #[test]
20226    async fn transcription_config_defaults() {
20227        let tc = TranscriptionConfig::default();
20228        assert!(!tc.enabled);
20229        assert!(tc.api_url.contains("groq.com"));
20230        assert_eq!(tc.model, "whisper-large-v3-turbo");
20231        assert!(tc.language.is_none());
20232        assert_eq!(tc.max_duration_secs, 120);
20233        assert!(!tc.transcribe_non_ptt_audio);
20234    }
20235
20236    #[test]
20237    async fn config_roundtrip_with_transcription() {
20238        let mut config = Config::default();
20239        config.transcription.enabled = true;
20240        config.transcription.language = Some("en".into());
20241
20242        let toml_str = toml::to_string_pretty(&config).unwrap();
20243        let parsed = parse_test_config(&toml_str);
20244
20245        assert!(parsed.transcription.enabled);
20246        assert_eq!(parsed.transcription.language.as_deref(), Some("en"));
20247        assert_eq!(parsed.transcription.model, "whisper-large-v3-turbo");
20248    }
20249
20250    #[test]
20251    async fn config_without_transcription_uses_defaults() {
20252        let toml_str = r#"
20253            default_model_provider = "openrouter"
20254            default_model = "test-model"
20255            default_temperature = 0.7
20256        "#;
20257        let parsed = parse_test_config(toml_str);
20258        assert!(!parsed.transcription.enabled);
20259        assert_eq!(parsed.transcription.max_duration_secs, 120);
20260    }
20261
20262    #[test]
20263    async fn security_defaults_are_backward_compatible() {
20264        let parsed = parse_test_config(
20265            r#"
20266default_model_provider = "openrouter"
20267default_model = "anthropic/claude-sonnet-4.6"
20268default_temperature = 0.7
20269"#,
20270        );
20271
20272        assert!(!parsed.security.otp.enabled);
20273        assert_eq!(parsed.security.otp.method, OtpMethod::Totp);
20274        assert!(!parsed.security.estop.enabled);
20275        assert!(parsed.security.estop.require_otp_to_resume);
20276    }
20277
20278    #[test]
20279    async fn security_toml_parses_otp_and_estop_sections() {
20280        let parsed = parse_test_config(
20281            r#"
20282default_model_provider = "openrouter"
20283default_model = "anthropic/claude-sonnet-4.6"
20284default_temperature = 0.7
20285
20286[security.otp]
20287enabled = true
20288method = "totp"
20289token_ttl_secs = 30
20290cache_valid_secs = 120
20291gated_actions = ["shell", "browser_open"]
20292gated_domains = ["*.chase.com", "accounts.google.com"]
20293gated_domain_categories = ["banking"]
20294
20295[security.estop]
20296enabled = true
20297state_file = "~/.zeroclaw/estop-state.json"
20298require_otp_to_resume = true
20299"#,
20300        );
20301
20302        assert!(parsed.security.otp.enabled);
20303        assert!(parsed.security.estop.enabled);
20304        assert_eq!(parsed.security.otp.gated_actions.len(), 2);
20305        assert_eq!(parsed.security.otp.gated_domains.len(), 2);
20306        parsed.validate().unwrap();
20307    }
20308
20309    #[test]
20310    async fn security_validation_rejects_invalid_domain_glob() {
20311        let mut config = Config::default();
20312        config.security.otp.gated_domains = vec!["bad domain.com".into()];
20313
20314        let err = config.validate().expect_err("expected invalid domain glob");
20315        assert!(err.to_string().contains("gated_domains"));
20316    }
20317
20318    // The two `validate_*_transcription_default_provider` tests were removed
20319    // alongside the deleted `TranscriptionConfig.default_transcription_provider`
20320    // field in #6273. there is no global default-provider concept; the equivalent
20321    // dangling-reference enforcement now lives on the per-agent
20322    // `agent.transcription_provider` field (see
20323    // `Config::validate()` checks for `tts_provider` / `transcription_provider`).
20324
20325    #[tokio::test]
20326    async fn channel_secret_telegram_bot_token_roundtrip() {
20327        let dir = std::env::temp_dir().join(format!(
20328            "zeroclaw_test_tg_bot_token_{}",
20329            uuid::Uuid::new_v4()
20330        ));
20331        fs::create_dir_all(&dir).await.unwrap();
20332
20333        let plaintext_token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";
20334
20335        let mut config = Config {
20336            data_dir: dir.join("workspace"),
20337            config_path: dir.join("config.toml"),
20338            ..Default::default()
20339        };
20340        config.channels.telegram.insert(
20341            "default".to_string(),
20342            TelegramConfig {
20343                enabled: true,
20344                bot_token: plaintext_token.into(),
20345                stream_mode: StreamMode::default(),
20346                draft_update_interval_ms: default_draft_update_interval_ms(),
20347                interrupt_on_new_message: false,
20348                mention_only: false,
20349                ack_reactions: None,
20350                proxy_url: None,
20351                approval_timeout_secs: default_telegram_approval_timeout_secs(),
20352                excluded_tools: vec![],
20353                default_target: None,
20354            },
20355        );
20356
20357        // Save (triggers encryption)
20358        config.save().await.unwrap();
20359
20360        // Read raw TOML and verify plaintext token is NOT present
20361        let raw_toml = tokio::fs::read_to_string(&config.config_path)
20362            .await
20363            .unwrap();
20364        assert!(
20365            !raw_toml.contains(plaintext_token),
20366            "Saved TOML must not contain the plaintext bot_token"
20367        );
20368
20369        // Parse stored TOML and verify the value is encrypted
20370        let stored: Config = toml::from_str(&raw_toml).unwrap();
20371        let stored_token = &stored.channels.telegram.get("default").unwrap().bot_token;
20372        assert!(
20373            crate::secrets::SecretStore::is_encrypted(stored_token),
20374            "Stored bot_token must be marked as encrypted"
20375        );
20376
20377        // Decrypt and verify it matches the original plaintext
20378        let store = crate::secrets::SecretStore::new(&dir, true);
20379        assert_eq!(store.decrypt(stored_token).unwrap(), plaintext_token);
20380
20381        // Simulate a full load: deserialize then decrypt (mirrors load_or_init logic)
20382        let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
20383        loaded.config_path = dir.join("config.toml");
20384        let load_store = crate::secrets::SecretStore::new(&dir, loaded.secrets.encrypt);
20385        loaded.decrypt_secrets(&load_store).unwrap();
20386        assert_eq!(
20387            loaded.channels.telegram.get("default").unwrap().bot_token,
20388            plaintext_token,
20389            "Loaded bot_token must match the original plaintext after decryption"
20390        );
20391
20392        let _ = fs::remove_dir_all(&dir).await;
20393    }
20394
20395    #[test]
20396    async fn security_validation_rejects_unknown_domain_category() {
20397        let mut config = Config::default();
20398        config.security.otp.gated_domain_categories = vec!["not_real".into()];
20399
20400        let err = config
20401            .validate()
20402            .expect_err("expected unknown domain category");
20403        assert!(err.to_string().contains("gated_domain_categories"));
20404    }
20405
20406    #[test]
20407    async fn security_validation_rejects_zero_token_ttl() {
20408        let mut config = Config::default();
20409        config.security.otp.token_ttl_secs = 0;
20410
20411        let err = config
20412            .validate()
20413            .expect_err("expected ttl validation failure");
20414        assert!(err.to_string().contains("token_ttl_secs"));
20415    }
20416
20417    // ── MCP config validation ─────────────────────────────────────────────
20418
20419    fn stdio_server(name: &str, command: &str) -> McpServerConfig {
20420        McpServerConfig {
20421            name: name.to_string(),
20422            transport: McpTransport::Stdio,
20423            command: command.to_string(),
20424            ..Default::default()
20425        }
20426    }
20427
20428    fn http_server(name: &str, url: &str) -> McpServerConfig {
20429        McpServerConfig {
20430            name: name.to_string(),
20431            transport: McpTransport::Http,
20432            url: Some(url.to_string()),
20433            ..Default::default()
20434        }
20435    }
20436
20437    fn sse_server(name: &str, url: &str) -> McpServerConfig {
20438        McpServerConfig {
20439            name: name.to_string(),
20440            transport: McpTransport::Sse,
20441            url: Some(url.to_string()),
20442            ..Default::default()
20443        }
20444    }
20445
20446    #[test]
20447    async fn validate_mcp_config_empty_servers_ok() {
20448        let cfg = McpConfig::default();
20449        assert!(validate_mcp_config(&cfg).is_ok());
20450    }
20451
20452    #[test]
20453    async fn validate_mcp_config_valid_stdio_ok() {
20454        let cfg = McpConfig {
20455            enabled: true,
20456            servers: vec![stdio_server("fs", "/usr/bin/mcp-fs")],
20457            ..Default::default()
20458        };
20459        assert!(validate_mcp_config(&cfg).is_ok());
20460    }
20461
20462    #[test]
20463    async fn validate_mcp_config_valid_http_ok() {
20464        let cfg = McpConfig {
20465            enabled: true,
20466            servers: vec![http_server("svc", "http://localhost:8080/mcp")],
20467            ..Default::default()
20468        };
20469        assert!(validate_mcp_config(&cfg).is_ok());
20470    }
20471
20472    #[test]
20473    async fn validate_mcp_config_valid_sse_ok() {
20474        let cfg = McpConfig {
20475            enabled: true,
20476            servers: vec![sse_server("svc", "https://example.com/events")],
20477            ..Default::default()
20478        };
20479        assert!(validate_mcp_config(&cfg).is_ok());
20480    }
20481
20482    #[test]
20483    async fn validate_mcp_config_rejects_empty_name() {
20484        let cfg = McpConfig {
20485            enabled: true,
20486            servers: vec![stdio_server("", "/usr/bin/tool")],
20487            ..Default::default()
20488        };
20489        let err = validate_mcp_config(&cfg).expect_err("empty name should fail");
20490        assert!(
20491            err.to_string().contains("name must not be empty"),
20492            "got: {err}"
20493        );
20494    }
20495
20496    #[test]
20497    async fn validate_mcp_config_rejects_whitespace_name() {
20498        let cfg = McpConfig {
20499            enabled: true,
20500            servers: vec![stdio_server("   ", "/usr/bin/tool")],
20501            ..Default::default()
20502        };
20503        let err = validate_mcp_config(&cfg).expect_err("whitespace name should fail");
20504        assert!(
20505            err.to_string().contains("name must not be empty"),
20506            "got: {err}"
20507        );
20508    }
20509
20510    #[test]
20511    async fn validate_mcp_config_rejects_duplicate_names() {
20512        let cfg = McpConfig {
20513            enabled: true,
20514            servers: vec![
20515                stdio_server("fs", "/usr/bin/mcp-a"),
20516                stdio_server("fs", "/usr/bin/mcp-b"),
20517            ],
20518            ..Default::default()
20519        };
20520        let err = validate_mcp_config(&cfg).expect_err("duplicate name should fail");
20521        assert!(err.to_string().contains("duplicate name"), "got: {err}");
20522    }
20523
20524    #[test]
20525    async fn validate_mcp_config_rejects_zero_timeout() {
20526        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
20527        server.tool_timeout_secs = Some(0);
20528        let cfg = McpConfig {
20529            enabled: true,
20530            servers: vec![server],
20531            ..Default::default()
20532        };
20533        let err = validate_mcp_config(&cfg).expect_err("zero timeout should fail");
20534        assert!(err.to_string().contains("greater than 0"), "got: {err}");
20535    }
20536
20537    #[test]
20538    async fn validate_mcp_config_rejects_timeout_exceeding_max() {
20539        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
20540        server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS + 1);
20541        let cfg = McpConfig {
20542            enabled: true,
20543            servers: vec![server],
20544            ..Default::default()
20545        };
20546        let err = validate_mcp_config(&cfg).expect_err("oversized timeout should fail");
20547        assert!(err.to_string().contains("exceeds max"), "got: {err}");
20548    }
20549
20550    #[test]
20551    async fn validate_mcp_config_allows_max_timeout_exactly() {
20552        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
20553        server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS);
20554        let cfg = McpConfig {
20555            enabled: true,
20556            servers: vec![server],
20557            ..Default::default()
20558        };
20559        assert!(validate_mcp_config(&cfg).is_ok());
20560    }
20561
20562    #[test]
20563    async fn validate_mcp_config_rejects_stdio_with_empty_command() {
20564        let cfg = McpConfig {
20565            enabled: true,
20566            servers: vec![stdio_server("fs", "")],
20567            ..Default::default()
20568        };
20569        let err = validate_mcp_config(&cfg).expect_err("empty command should fail");
20570        assert!(
20571            err.to_string().contains("requires non-empty command"),
20572            "got: {err}"
20573        );
20574    }
20575
20576    #[test]
20577    async fn validate_mcp_config_rejects_http_without_url() {
20578        let cfg = McpConfig {
20579            enabled: true,
20580            servers: vec![McpServerConfig {
20581                name: "svc".to_string(),
20582                transport: McpTransport::Http,
20583                url: None,
20584                ..Default::default()
20585            }],
20586            ..Default::default()
20587        };
20588        let err = validate_mcp_config(&cfg).expect_err("http without url should fail");
20589        assert!(err.to_string().contains("requires url"), "got: {err}");
20590    }
20591
20592    #[test]
20593    async fn validate_mcp_config_rejects_sse_without_url() {
20594        let cfg = McpConfig {
20595            enabled: true,
20596            servers: vec![McpServerConfig {
20597                name: "svc".to_string(),
20598                transport: McpTransport::Sse,
20599                url: None,
20600                ..Default::default()
20601            }],
20602            ..Default::default()
20603        };
20604        let err = validate_mcp_config(&cfg).expect_err("sse without url should fail");
20605        assert!(err.to_string().contains("requires url"), "got: {err}");
20606    }
20607
20608    #[test]
20609    async fn validate_mcp_config_rejects_non_http_scheme() {
20610        let cfg = McpConfig {
20611            enabled: true,
20612            servers: vec![http_server("svc", "ftp://example.com/mcp")],
20613            ..Default::default()
20614        };
20615        let err = validate_mcp_config(&cfg).expect_err("non-http scheme should fail");
20616        assert!(err.to_string().contains("http/https"), "got: {err}");
20617    }
20618
20619    #[test]
20620    async fn validate_mcp_config_rejects_invalid_url() {
20621        let cfg = McpConfig {
20622            enabled: true,
20623            servers: vec![http_server("svc", "not a url at all !!!")],
20624            ..Default::default()
20625        };
20626        let err = validate_mcp_config(&cfg).expect_err("invalid url should fail");
20627        assert!(err.to_string().contains("valid URL"), "got: {err}");
20628    }
20629
20630    #[test]
20631    async fn mcp_config_default_disabled_with_empty_servers() {
20632        let cfg = McpConfig::default();
20633        assert!(!cfg.enabled);
20634        assert!(cfg.servers.is_empty());
20635    }
20636
20637    #[test]
20638    async fn mcp_transport_serde_roundtrip_lowercase() {
20639        let cases = [
20640            (McpTransport::Stdio, "\"stdio\""),
20641            (McpTransport::Http, "\"http\""),
20642            (McpTransport::Sse, "\"sse\""),
20643        ];
20644        for (variant, expected_json) in &cases {
20645            let serialized = serde_json::to_string(variant).expect("serialize");
20646            assert_eq!(&serialized, expected_json, "variant: {variant:?}");
20647            let deserialized: McpTransport =
20648                serde_json::from_str(expected_json).expect("deserialize");
20649            assert_eq!(&deserialized, variant);
20650        }
20651    }
20652
20653    #[tokio::test]
20654    async fn nevis_client_secret_encrypt_decrypt_roundtrip() {
20655        let dir = std::env::temp_dir().join(format!(
20656            "zeroclaw_test_nevis_secret_{}",
20657            uuid::Uuid::new_v4()
20658        ));
20659        fs::create_dir_all(&dir).await.unwrap();
20660
20661        let plaintext_secret = "nevis-test-client-secret-value";
20662
20663        let mut config = Config {
20664            data_dir: dir.join("workspace"),
20665            config_path: dir.join("config.toml"),
20666            ..Default::default()
20667        };
20668        config.security.nevis.client_secret = Some(plaintext_secret.into());
20669
20670        // Save (triggers encryption)
20671        config.save().await.unwrap();
20672
20673        // Read raw TOML and verify plaintext secret is NOT present
20674        let raw_toml = tokio::fs::read_to_string(&config.config_path)
20675            .await
20676            .unwrap();
20677        assert!(
20678            !raw_toml.contains(plaintext_secret),
20679            "Saved TOML must not contain the plaintext client_secret"
20680        );
20681
20682        // Parse stored TOML and verify the value is encrypted
20683        let stored: Config = toml::from_str(&raw_toml).unwrap();
20684        let stored_secret = stored.security.nevis.client_secret.as_ref().unwrap();
20685        assert!(
20686            crate::secrets::SecretStore::is_encrypted(stored_secret),
20687            "Stored client_secret must be marked as encrypted"
20688        );
20689
20690        // Decrypt and verify it matches the original plaintext
20691        let store = crate::secrets::SecretStore::new(&dir, true);
20692        assert_eq!(store.decrypt(stored_secret).unwrap(), plaintext_secret);
20693
20694        // Simulate a full load: deserialize then decrypt (mirrors load_or_init logic)
20695        let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
20696        loaded.config_path = dir.join("config.toml");
20697        let load_store = crate::secrets::SecretStore::new(&dir, loaded.secrets.encrypt);
20698        loaded.decrypt_secrets(&load_store).unwrap();
20699        assert_eq!(
20700            loaded.security.nevis.client_secret.as_deref().unwrap(),
20701            plaintext_secret,
20702            "Loaded client_secret must match the original plaintext after decryption"
20703        );
20704
20705        let _ = fs::remove_dir_all(&dir).await;
20706    }
20707
20708    // ══════════════════════════════════════════════════════════
20709    // Nevis config validation tests
20710    // ══════════════════════════════════════════════════════════
20711
20712    #[test]
20713    async fn nevis_config_validate_disabled_accepts_empty_fields() {
20714        let cfg = NevisConfig::default();
20715        assert!(!cfg.enabled);
20716        assert!(cfg.validate().is_ok());
20717    }
20718
20719    #[test]
20720    async fn nevis_config_validate_rejects_empty_instance_url() {
20721        let cfg = NevisConfig {
20722            enabled: true,
20723            instance_url: String::new(),
20724            client_id: "test-client".into(),
20725            ..NevisConfig::default()
20726        };
20727        let err = cfg.validate().unwrap_err();
20728        assert!(err.contains("instance_url"));
20729    }
20730
20731    #[test]
20732    async fn nevis_config_validate_rejects_empty_client_id() {
20733        let cfg = NevisConfig {
20734            enabled: true,
20735            instance_url: "https://nevis.example.com".into(),
20736            client_id: String::new(),
20737            ..NevisConfig::default()
20738        };
20739        let err = cfg.validate().unwrap_err();
20740        assert!(err.contains("client_id"));
20741    }
20742
20743    #[test]
20744    async fn nevis_config_validate_rejects_empty_realm() {
20745        let cfg = NevisConfig {
20746            enabled: true,
20747            instance_url: "https://nevis.example.com".into(),
20748            client_id: "test-client".into(),
20749            realm: String::new(),
20750            ..NevisConfig::default()
20751        };
20752        let err = cfg.validate().unwrap_err();
20753        assert!(err.contains("realm"));
20754    }
20755
20756    #[test]
20757    async fn nevis_config_validate_rejects_local_without_jwks() {
20758        let cfg = NevisConfig {
20759            enabled: true,
20760            instance_url: "https://nevis.example.com".into(),
20761            client_id: "test-client".into(),
20762            token_validation: "local".into(),
20763            jwks_url: None,
20764            ..NevisConfig::default()
20765        };
20766        let err = cfg.validate().unwrap_err();
20767        assert!(err.contains("jwks_url"));
20768    }
20769
20770    #[test]
20771    async fn nevis_config_validate_rejects_zero_session_timeout() {
20772        let cfg = NevisConfig {
20773            enabled: true,
20774            instance_url: "https://nevis.example.com".into(),
20775            client_id: "test-client".into(),
20776            token_validation: "remote".into(),
20777            session_timeout_secs: 0,
20778            ..NevisConfig::default()
20779        };
20780        let err = cfg.validate().unwrap_err();
20781        assert!(err.contains("session_timeout_secs"));
20782    }
20783
20784    #[test]
20785    async fn nevis_config_validate_accepts_valid_enabled_config() {
20786        let cfg = NevisConfig {
20787            enabled: true,
20788            instance_url: "https://nevis.example.com".into(),
20789            realm: "master".into(),
20790            client_id: "test-client".into(),
20791            token_validation: "remote".into(),
20792            session_timeout_secs: 3600,
20793            ..NevisConfig::default()
20794        };
20795        assert!(cfg.validate().is_ok());
20796    }
20797
20798    #[test]
20799    async fn nevis_config_validate_rejects_invalid_token_validation() {
20800        let cfg = NevisConfig {
20801            enabled: true,
20802            instance_url: "https://nevis.example.com".into(),
20803            realm: "master".into(),
20804            client_id: "test-client".into(),
20805            token_validation: "invalid_mode".into(),
20806            session_timeout_secs: 3600,
20807            ..NevisConfig::default()
20808        };
20809        let err = cfg.validate().unwrap_err();
20810        assert!(
20811            err.contains("invalid value 'invalid_mode'"),
20812            "Expected invalid token_validation error, got: {err}"
20813        );
20814    }
20815
20816    #[test]
20817    async fn nevis_config_debug_redacts_client_secret() {
20818        let cfg = NevisConfig {
20819            client_secret: Some("super-secret".into()),
20820            ..NevisConfig::default()
20821        };
20822        let debug_output = format!("{:?}", cfg);
20823        assert!(
20824            !debug_output.contains("super-secret"),
20825            "Debug output must not contain the raw client_secret"
20826        );
20827        assert!(
20828            debug_output.contains("[REDACTED]"),
20829            "Debug output must show [REDACTED] for client_secret"
20830        );
20831    }
20832
20833    #[test]
20834    async fn telegram_config_ack_reactions_false_deserializes() {
20835        let toml_str = r#"
20836            bot_token = "123:ABC"
20837            allowed_users = ["alice"]
20838            ack_reactions = false
20839        "#;
20840        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
20841        assert_eq!(cfg.ack_reactions, Some(false));
20842    }
20843
20844    #[test]
20845    async fn telegram_config_ack_reactions_true_deserializes() {
20846        let toml_str = r#"
20847            bot_token = "123:ABC"
20848            allowed_users = ["alice"]
20849            ack_reactions = true
20850        "#;
20851        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
20852        assert_eq!(cfg.ack_reactions, Some(true));
20853    }
20854
20855    #[test]
20856    async fn telegram_config_ack_reactions_missing_defaults_to_none() {
20857        let toml_str = r#"
20858            bot_token = "123:ABC"
20859            allowed_users = ["alice"]
20860        "#;
20861        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
20862        assert_eq!(cfg.ack_reactions, None);
20863    }
20864
20865    #[test]
20866    async fn telegram_config_ack_reactions_channel_overrides_top_level() {
20867        let tg_toml = r#"
20868            bot_token = "123:ABC"
20869            allowed_users = ["alice"]
20870            ack_reactions = false
20871        "#;
20872        let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
20873        let top_level_ack = true;
20874        let effective = tg.ack_reactions.unwrap_or(top_level_ack);
20875        assert!(
20876            !effective,
20877            "channel-level false must override top-level true"
20878        );
20879    }
20880
20881    #[test]
20882    async fn telegram_config_ack_reactions_falls_back_to_top_level() {
20883        let tg_toml = r#"
20884            bot_token = "123:ABC"
20885            allowed_users = ["alice"]
20886        "#;
20887        let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
20888        let top_level_ack = false;
20889        let effective = tg.ack_reactions.unwrap_or(top_level_ack);
20890        assert!(
20891            !effective,
20892            "must fall back to top-level false when channel omits field"
20893        );
20894    }
20895
20896    #[test]
20897    async fn google_workspace_allowed_operations_deserialize_from_toml() {
20898        let toml_str = r#"
20899            enabled = true
20900
20901            [[allowed_operations]]
20902            service = "gmail"
20903            resource = "users"
20904            sub_resource = "drafts"
20905            methods = ["create", "update"]
20906        "#;
20907
20908        let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
20909        assert_eq!(cfg.allowed_operations.len(), 1);
20910        assert_eq!(cfg.allowed_operations[0].service, "gmail");
20911        assert_eq!(cfg.allowed_operations[0].resource, "users");
20912        assert_eq!(
20913            cfg.allowed_operations[0].sub_resource.as_deref(),
20914            Some("drafts")
20915        );
20916        assert_eq!(
20917            cfg.allowed_operations[0].methods,
20918            vec!["create".to_string(), "update".to_string()]
20919        );
20920    }
20921
20922    #[test]
20923    async fn google_workspace_allowed_operations_deserialize_without_sub_resource() {
20924        let toml_str = r#"
20925            enabled = true
20926
20927            [[allowed_operations]]
20928            service = "drive"
20929            resource = "files"
20930            methods = ["list", "get"]
20931        "#;
20932
20933        let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
20934        assert_eq!(cfg.allowed_operations[0].sub_resource, None);
20935    }
20936
20937    #[test]
20938    async fn config_validate_accepts_google_workspace_allowed_operations() {
20939        let mut cfg = Config::default();
20940        cfg.google_workspace.enabled = true;
20941        cfg.google_workspace.allowed_services = vec!["gmail".into()];
20942        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
20943            service: "gmail".into(),
20944            resource: "users".into(),
20945            sub_resource: Some("drafts".into()),
20946            methods: vec!["create".into(), "update".into()],
20947        }];
20948
20949        cfg.validate().unwrap();
20950    }
20951
20952    #[test]
20953    async fn config_validate_rejects_duplicate_google_workspace_allowed_operations() {
20954        let mut cfg = Config::default();
20955        cfg.google_workspace.enabled = true;
20956        cfg.google_workspace.allowed_services = vec!["gmail".into()];
20957        cfg.google_workspace.allowed_operations = vec![
20958            GoogleWorkspaceAllowedOperation {
20959                service: "gmail".into(),
20960                resource: "users".into(),
20961                sub_resource: Some("drafts".into()),
20962                methods: vec!["create".into()],
20963            },
20964            GoogleWorkspaceAllowedOperation {
20965                service: "gmail".into(),
20966                resource: "users".into(),
20967                sub_resource: Some("drafts".into()),
20968                methods: vec!["update".into()],
20969            },
20970        ];
20971
20972        let err = cfg.validate().unwrap_err().to_string();
20973        assert!(err.contains("duplicate service/resource/sub_resource entry"));
20974    }
20975
20976    #[test]
20977    async fn config_validate_rejects_operation_service_not_in_allowed_services() {
20978        let mut cfg = Config::default();
20979        cfg.google_workspace.enabled = true;
20980        cfg.google_workspace.allowed_services = vec!["gmail".into()];
20981        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
20982            service: "drive".into(), // drive is not in allowed_services
20983            resource: "files".into(),
20984            sub_resource: None,
20985            methods: vec!["list".into()],
20986        }];
20987
20988        let err = cfg.validate().unwrap_err().to_string();
20989        assert!(
20990            err.contains("not in the effective allowed_services"),
20991            "expected not-in-allowed_services error, got: {err}"
20992        );
20993    }
20994
20995    #[test]
20996    async fn config_validate_accepts_default_service_when_allowed_services_empty() {
20997        // When allowed_services is empty the validator uses DEFAULT_GWS_SERVICES.
20998        // A known default service must pass.
20999        let mut cfg = Config::default();
21000        cfg.google_workspace.enabled = true;
21001        // allowed_services deliberately left empty (falls back to defaults)
21002        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
21003            service: "drive".into(),
21004            resource: "files".into(),
21005            sub_resource: None,
21006            methods: vec!["list".into()],
21007        }];
21008
21009        assert!(cfg.validate().is_ok());
21010    }
21011
21012    #[test]
21013    async fn config_validate_rejects_unknown_service_when_allowed_services_empty() {
21014        // Even with allowed_services empty (using defaults), an operation whose
21015        // service is not in DEFAULT_GWS_SERVICES must fail validation — not silently
21016        // pass through to be rejected at runtime.
21017        let mut cfg = Config::default();
21018        cfg.google_workspace.enabled = true;
21019        // allowed_services deliberately left empty
21020        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
21021            service: "not_a_real_service".into(),
21022            resource: "files".into(),
21023            sub_resource: None,
21024            methods: vec!["list".into()],
21025        }];
21026
21027        let err = cfg.validate().unwrap_err().to_string();
21028        assert!(
21029            err.contains("not in the effective allowed_services"),
21030            "expected effective-allowed_services error, got: {err}"
21031        );
21032    }
21033
21034    // ── Bootstrap files ─────────────────────────────────────
21035
21036    #[tokio::test]
21037    async fn ensure_bootstrap_files_creates_missing_files() {
21038        let tmp = tempfile::TempDir::new().unwrap();
21039        let ws = tmp.path().join("workspace");
21040        let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
21041
21042        ensure_bootstrap_files(&ws).await.unwrap();
21043
21044        let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
21045        let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
21046            .await
21047            .unwrap();
21048        assert!(soul.contains("SOUL.md"));
21049        assert!(identity.contains("IDENTITY.md"));
21050    }
21051
21052    #[tokio::test]
21053    async fn ensure_bootstrap_files_does_not_overwrite_existing() {
21054        let tmp = tempfile::TempDir::new().unwrap();
21055        let ws = tmp.path().join("workspace");
21056        let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
21057
21058        let custom = "# My custom SOUL";
21059        let _: () = tokio::fs::write(ws.join("SOUL.md"), custom).await.unwrap();
21060
21061        ensure_bootstrap_files(&ws).await.unwrap();
21062
21063        let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
21064        assert_eq!(
21065            soul, custom,
21066            "ensure_bootstrap_files must not overwrite existing files"
21067        );
21068
21069        // IDENTITY.md should still be created since it was missing
21070        let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
21071            .await
21072            .unwrap();
21073        assert!(identity.contains("IDENTITY.md"));
21074    }
21075
21076    // ── PacingConfig serde defaults ─────────────────────────────
21077
21078    #[test]
21079    async fn pacing_config_serde_defaults_match_manual_default() {
21080        // Deserialise an empty TOML table and verify the loop-detection
21081        // fields receive the same defaults as `PacingConfig::default()`.
21082        let from_toml: PacingConfig = toml::from_str("").unwrap();
21083        let manual = PacingConfig::default();
21084
21085        assert_eq!(
21086            from_toml.loop_detection_enabled,
21087            manual.loop_detection_enabled
21088        );
21089        assert_eq!(
21090            from_toml.loop_detection_window_size,
21091            manual.loop_detection_window_size
21092        );
21093        assert_eq!(
21094            from_toml.loop_detection_max_repeats,
21095            manual.loop_detection_max_repeats
21096        );
21097
21098        // Verify concrete values so a silent change to the defaults is caught.
21099        assert!(from_toml.loop_detection_enabled, "default should be true");
21100        assert_eq!(from_toml.loop_detection_window_size, 20);
21101        assert_eq!(from_toml.loop_detection_max_repeats, 3);
21102    }
21103
21104    // ── Docker baked config template ────────────────────────────
21105
21106    /// The TOML template baked into Docker images (Dockerfile + Dockerfile.debian).
21107    /// Kept here so changes to the Dockerfiles can be validated by `cargo test`.
21108    const DOCKER_CONFIG_TEMPLATE: &str = r#"
21109schema_version = 3
21110workspace_dir = "/zeroclaw-data/workspace"
21111config_path = "/zeroclaw-data/.zeroclaw/config.toml"
21112api_key = ""
21113default_model_provider = "openrouter"
21114default_model = "anthropic/claude-sonnet-4-20250514"
21115default_temperature = 0.7
21116
21117[gateway]
21118port = 42617
21119host = "[::]"
21120allow_public_bind = true
21121
21122[risk_profiles.default]
21123level = "supervised"
21124auto_approve = ["file_read", "file_write", "file_edit", "memory_recall", "memory_store", "web_search_tool", "web_fetch", "calculator", "glob_search", "content_search", "image_info", "weather", "git_operations"]
21125"#;
21126
21127    #[test]
21128    async fn docker_config_template_is_parseable() {
21129        let cfg: Config = toml::from_str(DOCKER_CONFIG_TEMPLATE)
21130            .expect("Docker baked config.toml must be valid TOML that deserialises into Config");
21131
21132        let auto = &cfg
21133            .risk_profiles
21134            .get("default")
21135            .expect("Docker config must define [risk_profiles.default]")
21136            .auto_approve;
21137        for tool in &[
21138            "file_read",
21139            "file_write",
21140            "file_edit",
21141            "memory_recall",
21142            "memory_store",
21143            "web_search_tool",
21144            "web_fetch",
21145            "calculator",
21146            "glob_search",
21147            "content_search",
21148            "image_info",
21149            "weather",
21150            "git_operations",
21151        ] {
21152            assert!(
21153                auto.iter().any(|t| t == tool),
21154                "Docker config risk_profiles.default.auto_approve missing expected tool: {tool}"
21155            );
21156        }
21157    }
21158
21159    #[test]
21160    async fn cost_enforcement_config_defaults() {
21161        let config = CostEnforcementConfig::default();
21162        assert_eq!(config.mode, "warn");
21163        assert_eq!(config.route_down_model, None);
21164        assert_eq!(config.reserve_percent, 10);
21165    }
21166
21167    #[test]
21168    async fn cost_config_includes_enforcement() {
21169        let config = CostConfig::default();
21170        assert_eq!(config.enforcement.mode, "warn");
21171        assert_eq!(config.enforcement.reserve_percent, 10);
21172    }
21173
21174    // ── Configurable macro tests ──
21175
21176    #[test]
21177    async fn matrix_secret_fields_discovered() {
21178        let mx = MatrixConfig {
21179            enabled: true,
21180            homeserver: "https://m.org".into(),
21181            access_token: Some("tok".into()),
21182            user_id: None,
21183            device_id: None,
21184            allowed_rooms: vec!["!r:m".into()],
21185            interrupt_on_new_message: false,
21186            stream_mode: StreamMode::default(),
21187            draft_update_interval_ms: 1500,
21188            multi_message_delay_ms: 800,
21189            recovery_key: None,
21190            mention_only: false,
21191            password: None,
21192            approval_timeout_secs: 300,
21193            reply_in_thread: true,
21194            ack_reactions: Some(true),
21195            excluded_tools: vec![],
21196            default_target: None,
21197        };
21198        let fields = mx.secret_fields();
21199        assert_eq!(fields.len(), 3);
21200        assert_eq!(fields[0].name, "channels.matrix.access-token");
21201        assert_eq!(fields[0].category, "Channels");
21202        assert!(fields[0].is_set);
21203        assert_eq!(fields[1].name, "channels.matrix.recovery-key");
21204        assert!(!fields[1].is_set);
21205        assert_eq!(fields[2].name, "channels.matrix.password");
21206        assert!(!fields[2].is_set);
21207    }
21208
21209    #[test]
21210    async fn matrix_secret_fields_empty_not_set() {
21211        let mx = MatrixConfig {
21212            enabled: true,
21213            homeserver: "https://m.org".into(),
21214            access_token: None,
21215            user_id: None,
21216            device_id: None,
21217            allowed_rooms: vec!["!r:m".into()],
21218            interrupt_on_new_message: false,
21219            stream_mode: StreamMode::default(),
21220            draft_update_interval_ms: 1500,
21221            multi_message_delay_ms: 800,
21222            recovery_key: None,
21223            mention_only: false,
21224            password: None,
21225            approval_timeout_secs: 300,
21226            reply_in_thread: true,
21227            ack_reactions: Some(true),
21228            excluded_tools: vec![],
21229            default_target: None,
21230        };
21231        let fields = mx.secret_fields();
21232        assert!(!fields[0].is_set);
21233    }
21234
21235    #[test]
21236    async fn set_secret_updates_field() {
21237        let mut mx = MatrixConfig {
21238            enabled: true,
21239            homeserver: "https://m.org".into(),
21240            access_token: Some("old".into()),
21241            user_id: None,
21242            device_id: None,
21243            allowed_rooms: vec!["!r:m".into()],
21244            interrupt_on_new_message: false,
21245            stream_mode: StreamMode::default(),
21246            draft_update_interval_ms: 1500,
21247            multi_message_delay_ms: 800,
21248            recovery_key: None,
21249            mention_only: false,
21250            password: None,
21251            approval_timeout_secs: 300,
21252            reply_in_thread: true,
21253            ack_reactions: Some(true),
21254            excluded_tools: vec![],
21255            default_target: None,
21256        };
21257        mx.set_secret("channels.matrix.access-token", "new-token".into())
21258            .unwrap();
21259        assert_eq!(mx.access_token.as_deref(), Some("new-token"));
21260    }
21261
21262    #[test]
21263    async fn set_secret_unknown_name_fails() {
21264        let mut mx = MatrixConfig {
21265            enabled: true,
21266            homeserver: "https://m.org".into(),
21267            access_token: Some("tok".into()),
21268            user_id: None,
21269            device_id: None,
21270            allowed_rooms: vec!["!r:m".into()],
21271            interrupt_on_new_message: false,
21272            stream_mode: StreamMode::default(),
21273            draft_update_interval_ms: 1500,
21274            multi_message_delay_ms: 800,
21275            recovery_key: None,
21276            mention_only: false,
21277            password: None,
21278            approval_timeout_secs: 300,
21279            reply_in_thread: true,
21280            ack_reactions: Some(true),
21281            excluded_tools: vec![],
21282            default_target: None,
21283        };
21284        assert!(
21285            mx.set_secret("channels.matrix.nonexistent", "val".into())
21286                .is_err()
21287        );
21288    }
21289
21290    #[test]
21291    async fn config_tree_traversal_discovers_nested_secrets() {
21292        let mut config = Config::default();
21293        // Set api_key on first model_provider entry (or create one)
21294        config
21295            .providers
21296            .models
21297            .ensure("anthropic", "default")
21298            .expect("anthropic typed slot")
21299            .api_key = Some("test-key".into());
21300        config.channels.matrix.insert(
21301            "default".to_string(),
21302            MatrixConfig {
21303                enabled: true,
21304                homeserver: "https://m.org".into(),
21305                access_token: Some("mx-tok".into()),
21306                user_id: None,
21307                device_id: None,
21308                allowed_rooms: vec!["!r:m".into()],
21309                interrupt_on_new_message: false,
21310                stream_mode: StreamMode::default(),
21311                draft_update_interval_ms: 1500,
21312                multi_message_delay_ms: 800,
21313                recovery_key: None,
21314                mention_only: false,
21315                password: None,
21316                approval_timeout_secs: 300,
21317                reply_in_thread: true,
21318                ack_reactions: Some(true),
21319                excluded_tools: vec![],
21320                default_target: None,
21321            },
21322        );
21323
21324        let fields = config.secret_fields();
21325        let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
21326        assert!(names.contains(&"channels.matrix.access-token"));
21327        assert!(names.contains(&"channels.matrix.recovery-key"));
21328    }
21329
21330    #[test]
21331    async fn config_set_secret_dispatches_to_child() {
21332        let mut config = Config::default();
21333        config.channels.matrix.insert(
21334            "default".to_string(),
21335            MatrixConfig {
21336                enabled: true,
21337                homeserver: "https://m.org".into(),
21338                access_token: Some("old".into()),
21339                user_id: None,
21340                device_id: None,
21341                allowed_rooms: vec!["!r:m".into()],
21342                interrupt_on_new_message: false,
21343                stream_mode: StreamMode::default(),
21344                draft_update_interval_ms: 1500,
21345                multi_message_delay_ms: 800,
21346                recovery_key: None,
21347                mention_only: false,
21348                password: None,
21349                approval_timeout_secs: 300,
21350                reply_in_thread: true,
21351                ack_reactions: Some(true),
21352                excluded_tools: vec![],
21353                default_target: None,
21354            },
21355        );
21356
21357        config
21358            .set_secret("channels.matrix.access-token", "new".into())
21359            .unwrap();
21360        assert_eq!(
21361            config
21362                .channels
21363                .matrix
21364                .get("default")
21365                .unwrap()
21366                .access_token
21367                .as_deref(),
21368            Some("new")
21369        );
21370    }
21371
21372    #[test]
21373    async fn config_set_secret_dispatches_to_matrix_child() {
21374        let mut config = Config::default();
21375        config.channels.matrix.insert(
21376            "default".to_string(),
21377            MatrixConfig {
21378                enabled: true,
21379                homeserver: "https://m.org".into(),
21380                access_token: Some("old".into()),
21381                user_id: None,
21382                device_id: None,
21383                allowed_rooms: vec!["!r:m".into()],
21384                interrupt_on_new_message: false,
21385                stream_mode: StreamMode::default(),
21386                draft_update_interval_ms: 1500,
21387                multi_message_delay_ms: 800,
21388                mention_only: false,
21389                recovery_key: None,
21390                password: None,
21391                approval_timeout_secs: 300,
21392                reply_in_thread: true,
21393                ack_reactions: Some(true),
21394                excluded_tools: vec![],
21395                default_target: None,
21396            },
21397        );
21398        config
21399            .set_secret("channels.matrix.access-token", "sk-test".into())
21400            .unwrap();
21401        assert_eq!(
21402            config
21403                .channels
21404                .matrix
21405                .get("default")
21406                .unwrap()
21407                .access_token
21408                .as_deref(),
21409            Some("sk-test")
21410        );
21411    }
21412
21413    #[test]
21414    async fn config_set_secret_unknown_fails() {
21415        let mut config = Config::default();
21416        assert!(
21417            config
21418                .set_secret("nonexistent.field", "val".into())
21419                .is_err()
21420        );
21421    }
21422
21423    #[test]
21424    async fn encrypt_decrypt_roundtrip_via_macro() {
21425        let dir = TempDir::new().unwrap();
21426        let store = crate::secrets::SecretStore::new(dir.path(), true);
21427
21428        let mut mx = MatrixConfig {
21429            enabled: true,
21430            homeserver: "https://m.org".into(),
21431            access_token: Some("plaintext-token".into()),
21432            user_id: None,
21433            device_id: None,
21434            allowed_rooms: vec!["!r:m".into()],
21435            interrupt_on_new_message: false,
21436            stream_mode: StreamMode::default(),
21437            draft_update_interval_ms: 1500,
21438            multi_message_delay_ms: 800,
21439            recovery_key: None,
21440            mention_only: false,
21441            password: None,
21442            approval_timeout_secs: 300,
21443            reply_in_thread: true,
21444            ack_reactions: Some(true),
21445            excluded_tools: vec![],
21446            default_target: None,
21447        };
21448
21449        // Encrypt
21450        mx.encrypt_secrets(&store).unwrap();
21451        assert!(crate::secrets::SecretStore::is_encrypted(
21452            mx.access_token.as_deref().unwrap_or_default()
21453        ));
21454        assert_ne!(mx.access_token.as_deref(), Some("plaintext-token"));
21455
21456        // Decrypt
21457        mx.decrypt_secrets(&store).unwrap();
21458        assert_eq!(mx.access_token.as_deref(), Some("plaintext-token"));
21459    }
21460
21461    #[test]
21462    async fn encrypt_skips_already_encrypted() {
21463        let dir = TempDir::new().unwrap();
21464        let store = crate::secrets::SecretStore::new(dir.path(), true);
21465
21466        let mut mx = MatrixConfig {
21467            enabled: true,
21468            homeserver: "https://m.org".into(),
21469            access_token: Some("plaintext-token".into()),
21470            user_id: None,
21471            device_id: None,
21472            allowed_rooms: vec!["!r:m".into()],
21473            interrupt_on_new_message: false,
21474            stream_mode: StreamMode::default(),
21475            draft_update_interval_ms: 1500,
21476            multi_message_delay_ms: 800,
21477            recovery_key: None,
21478            mention_only: false,
21479            password: None,
21480            approval_timeout_secs: 300,
21481            reply_in_thread: true,
21482            ack_reactions: Some(true),
21483            excluded_tools: vec![],
21484            default_target: None,
21485        };
21486
21487        mx.encrypt_secrets(&store).unwrap();
21488        let first_encrypted = mx.access_token.clone();
21489
21490        // Encrypt again — should be idempotent
21491        mx.encrypt_secrets(&store).unwrap();
21492        assert_eq!(mx.access_token, first_encrypted);
21493    }
21494
21495    #[test]
21496    async fn encrypt_no_op_on_disabled_store() {
21497        let dir = TempDir::new().unwrap();
21498        let store = crate::secrets::SecretStore::new(dir.path(), false);
21499
21500        let mut mx = MatrixConfig {
21501            enabled: true,
21502            homeserver: "https://m.org".into(),
21503            access_token: Some("plaintext-token".into()),
21504            user_id: None,
21505            device_id: None,
21506            allowed_rooms: vec!["!r:m".into()],
21507            interrupt_on_new_message: false,
21508            stream_mode: StreamMode::default(),
21509            draft_update_interval_ms: 1500,
21510            multi_message_delay_ms: 800,
21511            recovery_key: None,
21512            mention_only: false,
21513            password: None,
21514            approval_timeout_secs: 300,
21515            reply_in_thread: true,
21516            ack_reactions: Some(true),
21517            excluded_tools: vec![],
21518            default_target: None,
21519        };
21520
21521        mx.encrypt_secrets(&store).unwrap();
21522        // With encryption disabled, value should stay plaintext
21523        assert_eq!(mx.access_token.as_deref(), Some("plaintext-token"));
21524    }
21525
21526    // ── Property method tests ──
21527
21528    fn test_matrix_config() -> MatrixConfig {
21529        MatrixConfig {
21530            enabled: true,
21531            homeserver: "https://m.org".into(),
21532            access_token: Some("tok".into()),
21533            user_id: Some("@bot:m.org".into()),
21534            device_id: None,
21535            allowed_rooms: vec!["!r:m".into()],
21536            interrupt_on_new_message: false,
21537            stream_mode: StreamMode::default(),
21538            draft_update_interval_ms: 1500,
21539            multi_message_delay_ms: 800,
21540            recovery_key: None,
21541            mention_only: false,
21542            password: None,
21543            approval_timeout_secs: 300,
21544            reply_in_thread: true,
21545            ack_reactions: Some(true),
21546            excluded_tools: vec![],
21547            default_target: None,
21548        }
21549    }
21550
21551    #[test]
21552    async fn prop_fields_returns_typed_entries() {
21553        let mx = test_matrix_config();
21554        let fields = mx.prop_fields();
21555        let by_name: std::collections::HashMap<&str, &crate::traits::PropFieldInfo> =
21556            fields.iter().map(|f| (f.name.as_str(), f)).collect();
21557
21558        // String field
21559        let homeserver = by_name["channels.matrix.homeserver"];
21560        assert_eq!(homeserver.type_hint, "String");
21561        assert_eq!(homeserver.display_value, "https://m.org");
21562
21563        // Option<String> — set
21564        let user_id = by_name["channels.matrix.user-id"];
21565        assert_eq!(user_id.type_hint, "Option<String>");
21566        assert_eq!(user_id.display_value, "@bot:m.org");
21567
21568        // Option<String> — unset
21569        let device_id = by_name["channels.matrix.device-id"];
21570        assert_eq!(device_id.display_value, "<unset>");
21571
21572        // u64 field
21573        let interval = by_name["channels.matrix.draft-update-interval-ms"];
21574        assert_eq!(interval.type_hint, "u64");
21575        assert_eq!(interval.display_value, "1500");
21576
21577        // Enum field
21578        let stream = by_name["channels.matrix.stream-mode"];
21579        assert!(stream.is_enum());
21580        assert!(stream.enum_variants.is_some());
21581
21582        // Secret field — masked
21583        let token = by_name["channels.matrix.access-token"];
21584        assert!(token.is_secret);
21585        assert_eq!(token.display_value, "****");
21586
21587        // All fields have correct category
21588        for field in &fields {
21589            assert_eq!(field.category, "Channels");
21590        }
21591    }
21592
21593    #[test]
21594    async fn get_prop_returns_values_by_path() {
21595        let mx = test_matrix_config();
21596
21597        assert_eq!(
21598            mx.get_prop("channels.matrix.homeserver").unwrap(),
21599            "https://m.org"
21600        );
21601        assert_eq!(
21602            mx.get_prop("channels.matrix.draft-update-interval-ms")
21603                .unwrap(),
21604            "1500"
21605        );
21606        assert_eq!(
21607            mx.get_prop("channels.matrix.user-id").unwrap(),
21608            "@bot:m.org"
21609        );
21610        assert_eq!(mx.get_prop("channels.matrix.device-id").unwrap(), "<unset>");
21611        // Secrets return masked value
21612        assert_eq!(
21613            mx.get_prop("channels.matrix.access-token").unwrap(),
21614            "**** (encrypted)"
21615        );
21616    }
21617
21618    #[test]
21619    async fn get_prop_unknown_path_fails() {
21620        let mx = test_matrix_config();
21621        assert!(mx.get_prop("channels.matrix.nonexistent").is_err());
21622    }
21623
21624    #[test]
21625    async fn set_prop_string() {
21626        let mut mx = test_matrix_config();
21627        mx.set_prop("channels.matrix.homeserver", "https://new.org")
21628            .unwrap();
21629        assert_eq!(mx.homeserver, "https://new.org");
21630    }
21631
21632    #[test]
21633    async fn set_prop_bool() {
21634        let mut mx = test_matrix_config();
21635        mx.set_prop("channels.matrix.interrupt-on-new-message", "true")
21636            .unwrap();
21637        assert!(mx.interrupt_on_new_message);
21638    }
21639
21640    #[test]
21641    async fn set_prop_bool_rejects_invalid() {
21642        let mut mx = test_matrix_config();
21643        let err = mx
21644            .set_prop("channels.matrix.interrupt-on-new-message", "yes")
21645            .unwrap_err();
21646        assert!(err.to_string().contains("bool"));
21647    }
21648
21649    #[test]
21650    async fn set_prop_u64() {
21651        let mut mx = test_matrix_config();
21652        mx.set_prop("channels.matrix.draft-update-interval-ms", "3000")
21653            .unwrap();
21654        assert_eq!(mx.draft_update_interval_ms, 3000);
21655    }
21656
21657    #[test]
21658    async fn set_prop_u64_rejects_invalid() {
21659        let mut mx = test_matrix_config();
21660        assert!(
21661            mx.set_prop("channels.matrix.draft-update-interval-ms", "abc")
21662                .is_err()
21663        );
21664    }
21665
21666    #[test]
21667    async fn set_prop_option_string_set_and_clear() {
21668        let mut mx = test_matrix_config();
21669        mx.set_prop("channels.matrix.user-id", "@new:m.org")
21670            .unwrap();
21671        assert_eq!(mx.user_id.as_deref(), Some("@new:m.org"));
21672
21673        // Empty string clears Option
21674        mx.set_prop("channels.matrix.user-id", "").unwrap();
21675        assert!(mx.user_id.is_none());
21676    }
21677
21678    #[test]
21679    async fn set_prop_enum() {
21680        let mut mx = test_matrix_config();
21681        mx.set_prop("channels.matrix.stream-mode", "partial")
21682            .unwrap();
21683        assert_eq!(mx.stream_mode, StreamMode::Partial);
21684
21685        mx.set_prop("channels.matrix.stream-mode", "multi_message")
21686            .unwrap();
21687        assert_eq!(mx.stream_mode, StreamMode::MultiMessage);
21688    }
21689
21690    #[test]
21691    async fn set_prop_enum_rejects_invalid() {
21692        let mut mx = test_matrix_config();
21693        let err = mx
21694            .set_prop("channels.matrix.stream-mode", "invalid")
21695            .unwrap_err();
21696        assert!(err.to_string().contains("expected one of"));
21697    }
21698
21699    #[test]
21700    async fn set_prop_unknown_path_fails() {
21701        let mut mx = test_matrix_config();
21702        assert!(mx.set_prop("channels.matrix.nonexistent", "val").is_err());
21703    }
21704
21705    #[test]
21706    async fn prop_is_secret_static_check() {
21707        assert!(MatrixConfig::prop_is_secret("channels.matrix.access-token"));
21708        assert!(MatrixConfig::prop_is_secret("channels.matrix.recovery-key"));
21709        assert!(!MatrixConfig::prop_is_secret("channels.matrix.homeserver"));
21710        assert!(!MatrixConfig::prop_is_secret(
21711            "channels.matrix.interrupt-on-new-message"
21712        ));
21713    }
21714
21715    #[test]
21716    async fn apply_env_overrides_rejects_schema_version() {
21717        let _env_guard = env_override_lock().await;
21718        // SAFETY: test-only, single-threaded test runner.
21719        unsafe { std::env::set_var("ZEROCLAW_schema_version", "99") };
21720        let mut config = Config::default();
21721        let result = crate::env_overrides::apply_env_overrides(&mut config);
21722        // SAFETY: test-only, single-threaded test runner.
21723        unsafe { std::env::remove_var("ZEROCLAW_schema_version") };
21724
21725        let err = result.expect_err("schema_version override must be rejected");
21726        let msg = format!("{err:#}");
21727        assert!(
21728            msg.contains("schema_version") && msg.contains("not overridable"),
21729            "error must name the path and the reason: {msg}",
21730        );
21731        // Untouched on rejection.
21732        assert_eq!(
21733            config.schema_version,
21734            crate::migration::CURRENT_SCHEMA_VERSION
21735        );
21736    }
21737
21738    #[test]
21739    async fn prop_is_env_overridden_reflects_env_overridden_paths() {
21740        // Empty by default — no env applied.
21741        let mut cfg = Config::default();
21742        assert!(!cfg.prop_is_env_overridden("channels.matrix.homeserver"));
21743        assert!(!cfg.prop_is_env_overridden("gateway.request-timeout-secs"));
21744
21745        // Populate the field directly (the same set that
21746        // `apply_env_overrides` returns from `load_or_init`).
21747        cfg.env_overridden_paths = std::collections::HashSet::from([
21748            "channels.matrix.homeserver".to_string(),
21749            "gateway.request-timeout-secs".to_string(),
21750        ]);
21751
21752        // True for paths in the list, false for anything else.
21753        assert!(cfg.prop_is_env_overridden("channels.matrix.homeserver"));
21754        assert!(cfg.prop_is_env_overridden("gateway.request-timeout-secs"));
21755        assert!(!cfg.prop_is_env_overridden("channels.matrix.access-token"));
21756        assert!(!cfg.prop_is_env_overridden("gateway.host"));
21757        // Empty path / non-schema path → false.
21758        assert!(!cfg.prop_is_env_overridden(""));
21759        assert!(!cfg.prop_is_env_overridden("does.not.exist"));
21760    }
21761
21762    #[test]
21763    async fn prop_is_secret_routes_through_hashmap_keyed_paths() {
21764        // Regression: the macro's HashMap<String, T> arm previously passed the
21765        // full materialised path (e.g. `model_providers.openrouter.api-key`)
21766        // straight to the inner type's `prop_is_secret`, which then matched on
21767        // its own configurable_prefix and returned false. Result: the CLI's
21768        // `config set --json` and the gateway's PropResponse both took the
21769        // non-secret branch and emitted `{value}` instead of `{populated}` for
21770        // any secret on a map-keyed nested type.
21771        assert!(Config::prop_is_secret(
21772            "providers.models.openrouter.default.api-key"
21773        ));
21774        assert!(Config::prop_is_secret(
21775            "providers.models.anthropic.default.api-key"
21776        ));
21777        assert!(!Config::prop_is_secret(
21778            "providers.models.openrouter.default.endpoint"
21779        ));
21780        assert!(!Config::prop_is_secret(
21781            "providers.models.openrouter.default.context-window"
21782        ));
21783    }
21784
21785    #[test]
21786    async fn typed_custom_slot_round_trips_uri_through_save_and_load() {
21787        // Legacy colon-URL keys (`custom:https://...`) are gone — `custom`
21788        // is a typed slot whose `uri` field carries the operator URL.
21789        // This pins: secret routing, save/encrypt, and round-trip reload
21790        // for the typed `custom` slot.
21791        let dir = TempDir::new().unwrap();
21792        let mut config = Config {
21793            config_path: dir.path().join("config.toml"),
21794            data_dir: dir.path().join("workspace"),
21795            ..Default::default()
21796        };
21797        let alias = "default";
21798        config
21799            .providers
21800            .models
21801            .ensure("custom", alias)
21802            .expect("custom typed slot");
21803
21804        let prefix = format!("providers.models.custom.{alias}");
21805        let api_key_path = format!("{prefix}.api-key");
21806        let uri_path = format!("{prefix}.uri");
21807        let model_path = format!("{prefix}.model");
21808        let temperature_path = format!("{prefix}.temperature");
21809
21810        assert!(
21811            Config::prop_is_secret(&api_key_path),
21812            "typed custom-slot api-key must route through the secret marker",
21813        );
21814
21815        config.set_prop(&api_key_path, "sk-test-custom").unwrap();
21816        config
21817            .set_prop(&uri_path, "https://api.example.invalid/v1")
21818            .unwrap();
21819        config.set_prop(&model_path, "local-large").unwrap();
21820        config.set_prop(&temperature_path, "0.2").unwrap();
21821
21822        let provider = config
21823            .providers
21824            .models
21825            .find("custom", alias)
21826            .expect("custom typed slot entry must be present");
21827        assert_eq!(provider.api_key.as_deref(), Some("sk-test-custom"));
21828        assert_eq!(
21829            provider.uri.as_deref(),
21830            Some("https://api.example.invalid/v1")
21831        );
21832        assert_eq!(provider.model.as_deref(), Some("local-large"));
21833        assert_eq!(provider.temperature, Some(0.2));
21834
21835        assert_eq!(config.get_prop(&api_key_path).unwrap(), "**** (encrypted)");
21836        assert_eq!(
21837            config.get_prop(&uri_path).unwrap(),
21838            "https://api.example.invalid/v1"
21839        );
21840
21841        config.save().await.unwrap();
21842        let raw_toml = tokio::fs::read_to_string(&config.config_path)
21843            .await
21844            .unwrap();
21845        assert!(
21846            raw_toml.contains("[providers.models.custom.default]"),
21847            "saved TOML should write under the typed custom slot",
21848        );
21849        assert!(
21850            !raw_toml.contains("sk-test-custom"),
21851            "saved TOML must not contain the plaintext custom provider API key",
21852        );
21853
21854        let mut loaded: Config = crate::migration::migrate_to_current(&raw_toml).unwrap();
21855        loaded.config_path = config.config_path.clone();
21856        loaded.data_dir = config.data_dir.clone();
21857        let store = crate::secrets::SecretStore::new(dir.path(), loaded.secrets.encrypt);
21858        loaded.decrypt_secrets(&store).unwrap();
21859        let loaded_provider = loaded
21860            .providers
21861            .models
21862            .find("custom", alias)
21863            .expect("typed custom slot entry must round-trip through save/load");
21864        assert_eq!(loaded_provider.api_key.as_deref(), Some("sk-test-custom"));
21865        assert_eq!(
21866            loaded_provider.uri.as_deref(),
21867            Some("https://api.example.invalid/v1")
21868        );
21869        assert_eq!(loaded_provider.model.as_deref(), Some("local-large"));
21870        assert_eq!(loaded_provider.temperature, Some(0.2));
21871    }
21872
21873    #[test]
21874    async fn env_override_save_cycle_preserves_on_disk_secret() {
21875        // Regression bar for the data-loss bug identified in PR
21876        // review: an operator with a real on-disk credential who sets a
21877        // `ZEROCLAW_*` env override for the same path and triggers any
21878        // save (dashboard auto-save, CLI `config set` for an unrelated
21879        // field, onboarding finalizer) must NOT corrupt the disk file.
21880        //
21881        // Pre-fix behavior: `mask_env_overrides_for_save` read disk via
21882        // `get_prop`, which returns `"**** (encrypted)"` for secret-typed
21883        // fields regardless of underlying state. That mask string then got
21884        // re-encrypted as plaintext and written to disk, destroying the
21885        // operator's real credential on the next reload.
21886        //
21887        // Post-fix: `apply_env_overrides` snapshots the post-decrypt
21888        // plaintext at apply time; `mask_env_overrides_for_save` restores
21889        // from that snapshot before `encrypt_secrets()` runs. The disk
21890        // secret survives the cycle.
21891        let dir = TempDir::new().unwrap();
21892        let mut config = Config {
21893            config_path: dir.path().join("config.toml"),
21894            data_dir: dir.path().join("workspace"),
21895            ..Default::default()
21896        };
21897        let original_secret = "sk-ant-real-on-disk-credential";
21898        let api_key_path = "providers.models.anthropic.default.api-key";
21899        config
21900            .providers
21901            .models
21902            .ensure("anthropic", "default")
21903            .expect("typed slot");
21904        config.set_prop(api_key_path, original_secret).unwrap();
21905
21906        // First save: encrypts the original plaintext, writes to disk.
21907        config.save().await.unwrap();
21908
21909        // Reload from disk to confirm the original landed correctly.
21910        let raw = tokio::fs::read_to_string(&config.config_path)
21911            .await
21912            .unwrap();
21913        let mut reloaded: Config = crate::migration::migrate_to_current(&raw).unwrap();
21914        reloaded.config_path = config.config_path.clone();
21915        reloaded.data_dir = config.data_dir.clone();
21916        let store = crate::secrets::SecretStore::new(dir.path(), reloaded.secrets.encrypt);
21917        reloaded.decrypt_secrets(&store).unwrap();
21918        assert_eq!(
21919            reloaded
21920                .providers
21921                .models
21922                .anthropic
21923                .get("default")
21924                .and_then(|c| c.base.api_key.as_deref()),
21925            Some(original_secret),
21926            "baseline: original secret round-trips through one save/reload cycle",
21927        );
21928
21929        // Simulate `apply_env_overrides` having injected a different value
21930        // for the same path — this is the state `Config::load_or_init`
21931        // leaves the in-memory config in when an operator boots with
21932        // `ZEROCLAW_providers__models__anthropic__default__api_key=...`
21933        // set in the environment.
21934        let env_value = "sk-ant-from-env-DIFFERENT";
21935        reloaded.env_overridden_paths = std::collections::HashSet::from([api_key_path.to_string()]);
21936        reloaded.pre_override_snapshots = std::collections::HashMap::from([(
21937            api_key_path.to_string(),
21938            original_secret.to_string(),
21939        )]);
21940        reloaded.set_prop(api_key_path, env_value).unwrap();
21941
21942        // Save again. With the pre-fix code path, this is the moment the
21943        // disk file got corrupted with the encrypted display mask.
21944        reloaded.save().await.unwrap();
21945
21946        // Reload, decrypt, and confirm the original secret survived
21947        // (and the env value did NOT leak to disk, and the literal mask
21948        // string was NOT persisted).
21949        let raw_after = tokio::fs::read_to_string(&reloaded.config_path)
21950            .await
21951            .unwrap();
21952        assert!(
21953            !raw_after.contains(env_value),
21954            "env-injected value must never reach disk: {raw_after}",
21955        );
21956        assert!(
21957            !raw_after.contains("**** (encrypted)"),
21958            "display mask must never be persisted as a secret value: {raw_after}",
21959        );
21960
21961        let mut after: Config = crate::migration::migrate_to_current(&raw_after).unwrap();
21962        after.config_path = reloaded.config_path.clone();
21963        after.data_dir = reloaded.data_dir.clone();
21964        let store2 = crate::secrets::SecretStore::new(dir.path(), after.secrets.encrypt);
21965        after.decrypt_secrets(&store2).unwrap();
21966        assert_eq!(
21967            after
21968                .providers
21969                .models
21970                .anthropic
21971                .get("default")
21972                .and_then(|c| c.base.api_key.as_deref()),
21973            Some(original_secret),
21974            "original on-disk secret must survive an env-override + save cycle",
21975        );
21976    }
21977
21978    #[test]
21979    async fn enum_variants_callback_returns_values() {
21980        let mx = test_matrix_config();
21981        let fields = mx.prop_fields();
21982        let stream_field = fields
21983            .iter()
21984            .find(|f| f.name == "channels.matrix.stream-mode")
21985            .unwrap();
21986        let variants = (stream_field.enum_variants.unwrap())();
21987        assert!(variants.contains(&"off".to_string()));
21988        assert!(variants.contains(&"partial".to_string()));
21989        assert!(variants.contains(&"multi_message".to_string()));
21990    }
21991
21992    #[test]
21993    async fn map_key_sections_discovers_per_family_provider_slots() {
21994        // Typed-family split: `providers.models` is a struct of typed
21995        // family maps, not a single open HashMap. Each family slot
21996        // (`providers.models.<family>`) is its own Map-kind section; the
21997        // dashboard's "+ Add alias" affordance hangs off the family path.
21998        let sections = Config::map_key_sections();
21999        let anthropic = sections
22000            .iter()
22001            .find(|s| s.path == "providers.models.anthropic")
22002            .expect("providers.models.anthropic must be discoverable as a map-keyed section");
22003        assert_eq!(anthropic.kind, crate::traits::MapKeyKind::Map);
22004        assert_eq!(anthropic.value_type, "AnthropicModelProviderConfig");
22005
22006        // agents is also #[nested] HashMap on root Config.
22007        assert!(
22008            sections.iter().any(|s| s.path == "agents"),
22009            "agents map should be discoverable"
22010        );
22011
22012        // mcp.servers is a Vec<McpServerConfig> with #[nested] — should
22013        // surface as a List-kind section so the dashboard's "+ Add MCP
22014        // server" affordance picks it up. Without this, dashboard users
22015        // hit a silent dead-end and have to hand-edit config.toml. Pinned
22016        // here so a regression that drops the #[nested] annotation or the
22017        // Configurable derive on McpServerConfig fails CI.
22018        let mcp_servers = sections
22019            .iter()
22020            .find(|s| s.path == "mcp.servers")
22021            .expect("mcp.servers must be discoverable as a list-shaped section");
22022        assert_eq!(mcp_servers.kind, crate::traits::MapKeyKind::List);
22023        assert_eq!(mcp_servers.value_type, "McpServerConfig");
22024    }
22025
22026    #[test]
22027    async fn create_map_key_inserts_default_mcp_server() {
22028        // Round-trip: `POST /api/config/map-key?path=mcp.servers&key=github`.
22029        // The new entry's `name` field is initialized to the supplied key
22030        // by the macro's List-kind insertion logic.
22031        let mut config = Config::default();
22032        assert!(config.mcp.servers.is_empty());
22033
22034        let created = config
22035            .create_map_key("mcp.servers", "github")
22036            .expect("mcp.servers should accept new list entries");
22037        assert!(created, "first add should report created=true");
22038        assert_eq!(config.mcp.servers.len(), 1);
22039        assert_eq!(
22040            config.mcp.servers[0].name, "github",
22041            "new entry must carry the supplied key as its name field"
22042        );
22043    }
22044
22045    #[test]
22046    async fn create_map_key_inserts_default_alias_under_typed_family() {
22047        // Dashboard "+ Add alias" target is the typed family slot,
22048        // not a free-form provider key under `providers.models`.
22049        let mut config = Config::default();
22050        assert!(
22051            !config
22052                .providers
22053                .models
22054                .contains_model_provider_type("anthropic")
22055        );
22056
22057        let created = config
22058            .create_map_key("providers.models.anthropic", "default")
22059            .expect("typed family slot should accept a new alias");
22060        assert!(created, "first add should report created=true");
22061        assert!(
22062            config
22063                .providers
22064                .models
22065                .find("anthropic", "default")
22066                .is_some(),
22067            "the new alias must show up under the typed family slot",
22068        );
22069
22070        // Idempotent: second add returns false, doesn't error.
22071        let again = config
22072            .create_map_key("providers.models.anthropic", "default")
22073            .expect("second add still resolves the section");
22074        assert!(!again, "duplicate add should report created=false");
22075    }
22076
22077    #[test]
22078    async fn create_map_key_rejects_unknown_section() {
22079        let mut config = Config::default();
22080        let err = config
22081            .create_map_key("not.a.real.section", "anything")
22082            .expect_err("unknown section path should error");
22083        assert!(err.contains("not.a.real.section"));
22084    }
22085
22086    #[test]
22087    async fn init_defaults_instantiates_none_sections() {
22088        let mut config = Config::default();
22089        assert!(config.channels.matrix.is_empty());
22090
22091        // Channels are HashMaps — init_defaults cannot insert a default key
22092        // (there is no meaningful default alias). Callers use create_map_key.
22093        config
22094            .create_map_key("channels.matrix", "default")
22095            .expect("create_map_key should insert a default matrix entry");
22096        assert!(
22097            config.channels.matrix.contains_key("default"),
22098            "create_map_key must add the 'default' alias"
22099        );
22100
22101        // init_defaults on an already-populated map section is a no-op.
22102        let initialized = config.init_defaults(Some("channels.matrix"));
22103        assert!(
22104            !initialized.contains(&"channels.matrix"),
22105            "init_defaults should not report channels.matrix when entry already exists"
22106        );
22107    }
22108
22109    #[test]
22110    async fn deserialized_matrix_set_prop_round_trips_vec_string() {
22111        // Mirror the real-world daemon flow: config loaded from disk where
22112        // [channels.matrix] is present (possibly with all default fields),
22113        // then a PATCH from the dashboard hits set_prop.
22114        let toml_src = r#"
22115schema_version = 3
22116
22117[channels.matrix.default]
22118enabled = false
22119homeserver = ""
22120access_token = ""
22121allowed_rooms = []
22122allowed_users = []
22123"#;
22124        let mut config: Config = toml::from_str(toml_src).expect("parse toml");
22125        assert!(
22126            config.channels.matrix.contains_key("default"),
22127            "matrix must have a 'default' alias after deserialize"
22128        );
22129
22130        config
22131            .set_prop(
22132                "channels.matrix.default.allowed-rooms",
22133                r#"["alice","bob"]"#,
22134            )
22135            .expect("set_prop should succeed against deserialized matrix");
22136        assert_eq!(
22137            config.channels.matrix.get("default").unwrap().allowed_rooms,
22138            vec!["alice".to_string(), "bob".to_string()],
22139        );
22140    }
22141
22142    #[test]
22143    async fn init_defaults_then_set_prop_round_trips_vec_string() {
22144        // Regression for #6175 Channels picker → form → save:
22145        // 1. create_map_key inserts channels.matrix["default"] = MatrixConfig::default()
22146        // 2. set_prop on channels.matrix.default.allowed-rooms must accept a JSON-array
22147        //    string (the shape coerce_for_set_prop emits for Vec<String>).
22148        // 3. get_prop reads it back.
22149        let mut config = Config::default();
22150        config
22151            .create_map_key("channels.matrix", "default")
22152            .expect("create_map_key should insert a default matrix entry");
22153        assert!(config.channels.matrix.contains_key("default"));
22154
22155        // prop_fields must surface the kebab path so the form can render it.
22156        let has_field = config
22157            .prop_fields()
22158            .iter()
22159            .any(|f| f.name == "channels.matrix.default.allowed-rooms");
22160        assert!(
22161            has_field,
22162            "channels.matrix.default.allowed-rooms must appear in prop_fields after init"
22163        );
22164
22165        // set_prop with the JSON-array string the gateway PATCH path produces.
22166        config
22167            .set_prop(
22168                "channels.matrix.default.allowed-rooms",
22169                r#"["alice","bob"]"#,
22170            )
22171            .expect("set_prop should accept JSON-array string for Vec<String>");
22172        assert_eq!(
22173            config.channels.matrix.get("default").unwrap().allowed_rooms,
22174            vec!["alice".to_string(), "bob".to_string()],
22175        );
22176    }
22177
22178    #[test]
22179    async fn mcp_servers_addable_via_create_map_key_and_per_entry_props() {
22180        // `mcp.servers` is a `Vec<McpServerConfig>` with `#[nested]`, so the
22181        // `Configurable` derive surfaces it as a List section (not an
22182        // ObjectArray prop) — operators add servers via
22183        // `POST /api/config/map-key?path=mcp.servers&key=<name>` and edit
22184        // each server's fields via per-prop GET/PUT.
22185        //
22186        // This replaces the prior model where the entire Vec round-tripped
22187        // through set_prop("mcp.servers", "<json-array>"). The List model
22188        // matches the rest of the schema (`providers.models`, `agents`,
22189        // etc.) and gives the dashboard a per-field editor instead of a
22190        // monolithic JSON blob.
22191        let mut config = Config::default();
22192
22193        // The List section is discoverable.
22194        let sections = Config::map_key_sections();
22195        assert!(
22196            sections
22197                .iter()
22198                .any(|s| s.path == "mcp.servers" && s.kind == crate::traits::MapKeyKind::List),
22199            "mcp.servers should surface as a List section in map_key_sections()"
22200        );
22201
22202        // create_map_key inserts a default-valued entry and seeds its
22203        // `name` field from the supplied key.
22204        config
22205            .create_map_key("mcp.servers", "fs")
22206            .expect("mcp.servers should accept new list entries via create_map_key");
22207        assert_eq!(config.mcp.servers.len(), 1);
22208        assert_eq!(config.mcp.servers[0].name, "fs");
22209
22210        // Per-entry fields are mutated via standard set_prop on the inner
22211        // path (the same call site the per-prop PUT handler uses); the
22212        // McpServerConfig schema's `#[prefix = "mcp.servers"]` makes the
22213        // path resolution work without hand-table dispatch.
22214        // (Wider per-entry path routing through Vec<T> requires a
22215        // future generalization of route_hashmap_path-equivalent for
22216        // List sections; tracked as future work.)
22217    }
22218
22219    #[test]
22220    async fn init_defaults_skips_already_set() {
22221        let mut config = Config::default();
22222        config
22223            .channels
22224            .matrix
22225            .insert("default".to_string(), test_matrix_config());
22226
22227        let initialized = config.init_defaults(Some("channels.matrix"));
22228        // Already set — should not re-initialize
22229        assert!(!initialized.contains(&"channels.matrix"));
22230        // Original value preserved
22231        assert_eq!(
22232            config.channels.matrix.get("default").unwrap().homeserver,
22233            "https://m.org"
22234        );
22235    }
22236
22237    #[test]
22238    async fn nested_get_set_prop_traverses_config_tree() {
22239        let mut config = Config::default();
22240        config
22241            .channels
22242            .matrix
22243            .insert("default".to_string(), test_matrix_config());
22244
22245        // get_prop traverses Config → ChannelsConfig → channels.matrix["default"] → MatrixConfig
22246        assert_eq!(
22247            config
22248                .get_prop("channels.matrix.default.homeserver")
22249                .unwrap(),
22250            "https://m.org"
22251        );
22252
22253        // set_prop traverses the same path
22254        config
22255            .set_prop("channels.matrix.default.homeserver", "https://new.org")
22256            .unwrap();
22257        assert_eq!(
22258            config.channels.matrix.get("default").unwrap().homeserver,
22259            "https://new.org"
22260        );
22261    }
22262
22263    #[test]
22264    async fn hashmap_nested_encrypt_decrypt_traverses_values() {
22265        let dir = TempDir::new().unwrap();
22266        let store = crate::secrets::SecretStore::new(dir.path(), true);
22267
22268        let mut config = Config::default();
22269        config.providers.models.openrouter.insert(
22270            "test".into(),
22271            crate::schema::OpenRouterModelProviderConfig {
22272                base: ModelProviderConfig {
22273                    api_key: Some("secret-key".into()),
22274                    ..Default::default()
22275                },
22276            },
22277        );
22278
22279        config.encrypt_secrets(&store).unwrap();
22280        let encrypted_key = config
22281            .providers
22282            .models
22283            .find("openrouter", "test")
22284            .expect("entry exists")
22285            .api_key
22286            .as_ref()
22287            .unwrap();
22288        assert!(crate::secrets::SecretStore::is_encrypted(encrypted_key));
22289
22290        config.decrypt_secrets(&store).unwrap();
22291        assert_eq!(
22292            config
22293                .providers
22294                .models
22295                .find("openrouter", "test")
22296                .expect("entry exists")
22297                .api_key
22298                .as_deref(),
22299            Some("secret-key")
22300        );
22301    }
22302
22303    #[test]
22304    async fn vec_secret_encrypt_decrypt_traverses_elements() {
22305        let dir = TempDir::new().unwrap();
22306        let store = crate::secrets::SecretStore::new(dir.path(), true);
22307
22308        let mut config = Config::default();
22309        config.gateway.paired_tokens = vec!["token-a".into(), "token-b".into()];
22310
22311        config.encrypt_secrets(&store).unwrap();
22312        for token in &config.gateway.paired_tokens {
22313            assert!(crate::secrets::SecretStore::is_encrypted(token));
22314        }
22315
22316        config.decrypt_secrets(&store).unwrap();
22317        assert_eq!(config.gateway.paired_tokens, vec!["token-a", "token-b"]);
22318    }
22319
22320    /// Walk every property on a default Config: get_prop must succeed,
22321    /// and set_prop must round-trip for non-secret, non-enum scalar fields.
22322    #[test]
22323    async fn every_prop_is_gettable_and_settable() {
22324        let mut config = Config::default();
22325        // Initialize all Option<T> sections so their fields are reachable
22326        config.init_defaults(None);
22327
22328        let fields = config.prop_fields();
22329        assert!(
22330            fields.len() > 50,
22331            "Expected 50+ props, got {} — macro may be skipping fields",
22332            fields.len()
22333        );
22334
22335        for field in &fields {
22336            // get_prop must not panic or error
22337            let get_result = config.get_prop(&field.name);
22338            assert!(
22339                get_result.is_ok(),
22340                "get_prop failed for '{}': {}",
22341                field.name,
22342                get_result.unwrap_err()
22343            );
22344
22345            // set_prop: round-trip the display value back through set_prop.
22346            // Skip secrets (masked), enums (need valid variant), and <unset> Options.
22347            if field.is_secret || field.is_enum() || field.display_value == "<unset>" {
22348                continue;
22349            }
22350
22351            let set_result = config.set_prop(&field.name, &field.display_value);
22352            assert!(
22353                set_result.is_ok(),
22354                "set_prop failed for '{}' with value '{}': {}",
22355                field.name,
22356                field.display_value,
22357                set_result.unwrap_err()
22358            );
22359
22360            // Value should survive the round-trip
22361            let after = config.get_prop(&field.name).unwrap();
22362            assert_eq!(
22363                after, field.display_value,
22364                "round-trip mismatch for '{}': set '{}', got '{}'",
22365                field.name, field.display_value, after
22366            );
22367        }
22368    }
22369
22370    /// Audit gate: every path emitted by `prop_fields()` must round-trip
22371    /// through `get_prop`. The CLI (`zeroclaw config get/set`), the TUI
22372    /// onboarding prompts (`prompt_field`), the gateway list endpoint
22373    /// (`/api/config/list`), and the dashboard form all derive from
22374    /// `prop_fields()`; if a path appears here but `get_prop` rejects
22375    /// it, that field is unreachable on every surface.
22376    ///
22377    /// `init_defaults(None)` populates Option-shaped subsections (memory
22378    /// backend specifics, tunnel provider details, etc.) so the walk
22379    /// also exercises fields that only materialize once a backend is
22380    /// chosen.
22381    #[test]
22382    async fn every_prop_field_path_is_reachable_via_get_prop() {
22383        let mut config = Config::default();
22384        config.init_defaults(None);
22385        for field in config.prop_fields() {
22386            let result = config.get_prop(&field.name);
22387            assert!(
22388                result.is_ok(),
22389                "get_prop('{}') failed: {} \u{2014} prop_fields() advertises a path \
22390                 that the CLI / gateway / TUI all expect to be readable. \
22391                 Either the macro emits the path but routing is missing, \
22392                 or the field shouldn't be in prop_fields().",
22393                field.name,
22394                result.unwrap_err()
22395            );
22396        }
22397    }
22398
22399    #[test]
22400    async fn onboard_state_prop_path_uses_top_level_kebab_field_name() {
22401        let mut config = Config::default();
22402
22403        config
22404            .set_prop("onboard-state.completed-sections", "agents")
22405            .expect("onboard state marker path should be writable");
22406        assert_eq!(
22407            config
22408                .get_prop("onboard-state.completed-sections")
22409                .expect("onboard state marker path should be readable"),
22410            "[\"agents\"]"
22411        );
22412    }
22413
22414    #[test]
22415    async fn per_agent_nested_prop_fields_use_agent_alias_paths() {
22416        let mut config = Config::default();
22417        config
22418            .agents
22419            .insert("bob".to_string(), AliasedAgentConfig::default());
22420
22421        let fields = config.prop_fields();
22422        assert!(
22423            fields
22424                .iter()
22425                .any(|field| field.name == "agents.bob.history-pruning.enabled"),
22426            "agent nested history-pruning fields should be emitted under the agent alias"
22427        );
22428        assert!(
22429            fields
22430                .iter()
22431                .any(|field| field.name == "agents.bob.precheck.enabled"),
22432            "agent nested precheck fields should be emitted under the agent alias"
22433        );
22434        assert!(
22435            !fields
22436                .iter()
22437                .any(|field| field.name.starts_with("agents.bob.agent.history-pruning")),
22438            "agent nested fields must not leak the legacy global agent prefix"
22439        );
22440        assert!(
22441            !fields
22442                .iter()
22443                .any(|field| field.name.starts_with("agents.bob.agent.precheck")),
22444            "agent nested precheck fields must not leak the legacy global agent prefix"
22445        );
22446
22447        config
22448            .set_prop("agents.bob.history-pruning.enabled", "true")
22449            .expect("set_prop should accept the emitted per-agent nested path");
22450        assert_eq!(
22451            config
22452                .get_prop("agents.bob.history-pruning.enabled")
22453                .expect("get_prop should accept the emitted per-agent nested path"),
22454            "true"
22455        );
22456
22457        config
22458            .set_prop("agents.bob.precheck.enabled", "false")
22459            .expect("set_prop should accept the emitted per-agent precheck path");
22460        assert_eq!(
22461            config
22462                .get_prop("agents.bob.precheck.enabled")
22463                .expect("get_prop should accept the emitted per-agent precheck path"),
22464            "false"
22465        );
22466    }
22467
22468    /// Audit gate: every non-secret scalar prop round-trips through
22469    /// `set_prop(get_prop(p))`. The CLI's `zeroclaw config set` and the
22470    /// dashboard's PATCH op both rely on this being true so an operator
22471    /// can read a value, edit it locally, and write it back. Vec /
22472    /// object-array fields are skipped — they pass through serde-JSON
22473    /// rather than scalar string parsing.
22474    #[test]
22475    async fn every_scalar_prop_round_trips_through_set_prop() {
22476        let mut config = Config::default();
22477        config.init_defaults(None);
22478        let fields = config.prop_fields();
22479        for field in &fields {
22480            if field.is_secret
22481                || matches!(
22482                    field.kind,
22483                    crate::config::PropKind::StringArray | crate::config::PropKind::ObjectArray
22484                )
22485            {
22486                continue;
22487            }
22488            let value = match config.get_prop(&field.name) {
22489                Ok(v) => v,
22490                Err(_) => continue,
22491            };
22492            // Sentinel for unset Option fields — no round-trip applies.
22493            if value == "<unset>" {
22494                continue;
22495            }
22496            let result = config.set_prop(&field.name, &value);
22497            assert!(
22498                result.is_ok(),
22499                "round-trip set_prop('{}', '{}') failed: {}",
22500                field.name,
22501                value,
22502                result.unwrap_err()
22503            );
22504        }
22505    }
22506
22507    /// Every enum field must have a working enum_variants callback, and
22508    /// set_prop must accept each variant it advertises.
22509    #[test]
22510    async fn every_enum_variant_is_settable() {
22511        let mut config = Config::default();
22512        config.init_defaults(None);
22513
22514        for field in config.prop_fields() {
22515            if !field.is_enum() {
22516                continue;
22517            }
22518            let get_variants = field.enum_variants.unwrap_or_else(|| {
22519                panic!("enum field '{}' has no enum_variants callback", field.name)
22520            });
22521            let variants = get_variants();
22522            assert!(
22523                !variants.is_empty(),
22524                "enum field '{}' returned no variants",
22525                field.name
22526            );
22527
22528            for variant in &variants {
22529                let result = config.set_prop(&field.name, variant);
22530                assert!(
22531                    result.is_ok(),
22532                    "set_prop('{}', '{}') failed: {}",
22533                    field.name,
22534                    variant,
22535                    result.unwrap_err()
22536                );
22537            }
22538        }
22539    }
22540
22541    #[test]
22542    async fn channel_approval_timeout_secs_defaults_to_300() {
22543        let discord: DiscordConfig = serde_json::from_str(r#"{"bot_token":"tok"}"#).unwrap();
22544        assert_eq!(discord.approval_timeout_secs, 300);
22545
22546        let slack: SlackConfig = serde_json::from_str(r#"{"bot_token":"tok"}"#).unwrap();
22547        assert_eq!(slack.approval_timeout_secs, 300);
22548
22549        let signal: SignalConfig =
22550            serde_json::from_str(r#"{"http_url":"http://localhost","account":"+1"}"#).unwrap();
22551        assert_eq!(signal.approval_timeout_secs, 300);
22552
22553        let matrix: MatrixConfig = serde_json::from_str(
22554            r#"{"homeserver":"https://matrix.org","access_token":"tok","allowed_users":[]}"#,
22555        )
22556        .unwrap();
22557        assert_eq!(matrix.approval_timeout_secs, 300);
22558
22559        let whatsapp: WhatsAppConfig = serde_json::from_str(r#"{}"#).unwrap();
22560        assert_eq!(whatsapp.approval_timeout_secs, 300);
22561    }
22562
22563    #[test]
22564    async fn channel_approval_timeout_secs_explicit_override() {
22565        let discord: DiscordConfig =
22566            serde_json::from_str(r#"{"bot_token":"tok","approval_timeout_secs":60}"#).unwrap();
22567        assert_eq!(discord.approval_timeout_secs, 60);
22568
22569        let slack: SlackConfig =
22570            serde_json::from_str(r#"{"bot_token":"tok","approval_timeout_secs":120}"#).unwrap();
22571        assert_eq!(slack.approval_timeout_secs, 120);
22572
22573        let signal: SignalConfig = serde_json::from_str(
22574            r#"{"http_url":"http://localhost","account":"+1","approval_timeout_secs":90}"#,
22575        )
22576        .unwrap();
22577        assert_eq!(signal.approval_timeout_secs, 90);
22578
22579        let matrix: MatrixConfig = serde_json::from_str(
22580            r#"{"homeserver":"https://matrix.org","access_token":"tok","allowed_users":[],"approval_timeout_secs":45}"#,
22581        )
22582        .unwrap();
22583        assert_eq!(matrix.approval_timeout_secs, 45);
22584
22585        let whatsapp: WhatsAppConfig =
22586            serde_json::from_str(r#"{"approval_timeout_secs":180}"#).unwrap();
22587        assert_eq!(whatsapp.approval_timeout_secs, 180);
22588    }
22589
22590    // ── Multi-agent cross-reference validators ─────────────────────
22591
22592    /// Build a minimal valid Config with one agent on a configured
22593    /// channel + risk profile + model provider. Each test mutates a
22594    /// single field to provoke a validator.
22595    fn multi_agent_test_config() -> Config {
22596        use crate::providers::ChannelRef;
22597
22598        let mut config = Config::default();
22599
22600        // Risk profile (mandatory for enabled agents).
22601        config
22602            .risk_profiles
22603            .insert("default".to_string(), RiskProfileConfig::default());
22604
22605        // Anthropic model provider (mandatory for the agent).
22606        config.providers.models.anthropic.insert(
22607            "default".to_string(),
22608            AnthropicModelProviderConfig::default(),
22609        );
22610
22611        // A configured Telegram channel the agent can reference. Just
22612        // having the entry in the map is enough for the dotted-alias
22613        // validator; we are not exercising channel-level behavior here.
22614        config
22615            .channels
22616            .telegram
22617            .insert("draft".to_string(), TelegramConfig::default());
22618
22619        // Agent that targets the model provider, risk profile, and
22620        // channel. Default workspace is jailed.
22621        let agent = AliasedAgentConfig {
22622            channels: vec![ChannelRef::new("telegram.draft")],
22623            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
22624            risk_profile: "default".to_string(),
22625            ..AliasedAgentConfig::default()
22626        };
22627        config.agents.insert("alpha".to_string(), agent);
22628
22629        config
22630    }
22631
22632    #[test]
22633    async fn validate_accepts_per_agent_precheck_controls() {
22634        let mut config = multi_agent_test_config();
22635        let alpha = config.agents.get_mut("alpha").unwrap();
22636        alpha.precheck.enabled = false;
22637        alpha.precheck.timeout_secs = 5;
22638
22639        config
22640            .validate()
22641            .expect("precheck enabled/timeout controls should validate");
22642    }
22643
22644    #[test]
22645    async fn validate_rejects_per_agent_precheck_zero_timeout() {
22646        let mut config = multi_agent_test_config();
22647        let alpha = config.agents.get_mut("alpha").unwrap();
22648        alpha.precheck.timeout_secs = 0;
22649
22650        let err = config
22651            .validate()
22652            .expect_err("zero precheck timeout must fail validation")
22653            .to_string();
22654        assert!(
22655            err.contains("agents.alpha.precheck.timeout_secs"),
22656            "expected precheck timeout field path, got: {err}"
22657        );
22658    }
22659
22660    #[test]
22661    async fn validate_rejects_workspace_access_self_reference() {
22662        let mut config = multi_agent_test_config();
22663        let alpha = config.agents.get_mut("alpha").unwrap();
22664        alpha.workspace.access.insert(
22665            crate::multi_agent::AgentAlias::new("alpha"),
22666            crate::multi_agent::AccessMode::Read,
22667        );
22668        let err = config
22669            .validate()
22670            .expect_err("self-reference must fail validation");
22671        let msg = err.to_string();
22672        assert!(
22673            msg.contains("agents.alpha.workspace.access.alpha"),
22674            "expected field path in error, got: {msg}"
22675        );
22676        assert!(
22677            msg.contains("self-references"),
22678            "expected self-reference explanation, got: {msg}"
22679        );
22680    }
22681
22682    #[test]
22683    async fn validate_rejects_workspace_access_dangling_target() {
22684        let mut config = multi_agent_test_config();
22685        let alpha = config.agents.get_mut("alpha").unwrap();
22686        alpha.workspace.access.insert(
22687            crate::multi_agent::AgentAlias::new("ghost"),
22688            crate::multi_agent::AccessMode::ReadWrite,
22689        );
22690        let err = config
22691            .validate()
22692            .expect_err("dangling target must fail validation");
22693        let msg = err.to_string();
22694        assert!(
22695            msg.contains("agents.ghost is not configured"),
22696            "expected dangling-ref explanation, got: {msg}"
22697        );
22698    }
22699
22700    #[test]
22701    async fn validate_rejects_read_memory_from_self_reference() {
22702        let mut config = multi_agent_test_config();
22703        let alpha = config.agents.get_mut("alpha").unwrap();
22704        alpha
22705            .workspace
22706            .read_memory_from
22707            .push(crate::multi_agent::AgentAlias::new("alpha"));
22708        let err = config
22709            .validate()
22710            .expect_err("self-reference must fail validation");
22711        assert!(
22712            err.to_string().contains("read_memory_from[0]"),
22713            "expected indexed field path, got: {err}"
22714        );
22715    }
22716
22717    #[test]
22718    async fn validate_rejects_read_memory_from_cross_backend() {
22719        let mut config = multi_agent_test_config();
22720
22721        // Add a second agent on Postgres.
22722        let beta = AliasedAgentConfig {
22723            channels: vec![crate::providers::ChannelRef::new("telegram.draft")],
22724            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
22725            risk_profile: "default".to_string(),
22726            memory: crate::multi_agent::AgentMemoryConfig {
22727                backend: crate::multi_agent::MemoryBackendKind::Postgres,
22728            },
22729            ..AliasedAgentConfig::default()
22730        };
22731        config.agents.insert("beta".to_string(), beta);
22732
22733        // Alpha (Sqlite default) tries to read from beta (Postgres).
22734        let alpha = config.agents.get_mut("alpha").unwrap();
22735        alpha
22736            .workspace
22737            .read_memory_from
22738            .push(crate::multi_agent::AgentAlias::new("beta"));
22739
22740        let err = config
22741            .validate()
22742            .expect_err("cross-backend allowlist must fail validation");
22743        let msg = err.to_string();
22744        assert!(
22745            msg.contains("same-backend siblings only"),
22746            "expected cross-backend explanation, got: {msg}"
22747        );
22748    }
22749
22750    #[test]
22751    async fn validate_rejects_peer_group_dangling_member() {
22752        let mut config = multi_agent_test_config();
22753        let group = crate::multi_agent::PeerGroupConfig {
22754            channel: "telegram".to_string(),
22755            agents: vec![
22756                crate::multi_agent::AgentAlias::new("alpha"),
22757                crate::multi_agent::AgentAlias::new("ghost"),
22758            ],
22759            ..crate::multi_agent::PeerGroupConfig::default()
22760        };
22761        config.peer_groups.insert("team_chat".to_string(), group);
22762        let err = config
22763            .validate()
22764            .expect_err("dangling group member must fail validation");
22765        assert!(
22766            err.to_string().contains("peer_groups.team_chat.agents[1]"),
22767            "expected indexed field path, got: {err}"
22768        );
22769    }
22770
22771    #[test]
22772    async fn validate_rejects_peer_group_member_without_channel() {
22773        let mut config = multi_agent_test_config();
22774
22775        // Add a discord channel and a beta agent that ONLY uses discord.
22776        config
22777            .channels
22778            .discord
22779            .insert("ops".to_string(), DiscordConfig::default());
22780        let beta = AliasedAgentConfig {
22781            channels: vec![crate::providers::ChannelRef::new("discord.ops")],
22782            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
22783            risk_profile: "default".to_string(),
22784            ..AliasedAgentConfig::default()
22785        };
22786        config.agents.insert("beta".to_string(), beta);
22787
22788        // Group on telegram.draft includes beta (who only has discord).
22789        let group = crate::multi_agent::PeerGroupConfig {
22790            channel: "telegram".to_string(),
22791            agents: vec![
22792                crate::multi_agent::AgentAlias::new("alpha"),
22793                crate::multi_agent::AgentAlias::new("beta"),
22794            ],
22795            ..crate::multi_agent::PeerGroupConfig::default()
22796        };
22797        config.peer_groups.insert("team_chat".to_string(), group);
22798
22799        let err = config
22800            .validate()
22801            .expect_err("channel-mismatch group member must fail validation");
22802        let msg = err.to_string();
22803        assert!(
22804            msg.contains("agents.beta.channels has no entry of type"),
22805            "expected channel-mismatch explanation, got: {msg}"
22806        );
22807    }
22808
22809    #[test]
22810    async fn validate_accepts_valid_peer_group_with_two_compatible_members() {
22811        let mut config = multi_agent_test_config();
22812
22813        // Beta on the same telegram channel.
22814        let beta = AliasedAgentConfig {
22815            channels: vec![crate::providers::ChannelRef::new("telegram.draft")],
22816            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
22817            risk_profile: "default".to_string(),
22818            ..AliasedAgentConfig::default()
22819        };
22820        config.agents.insert("beta".to_string(), beta);
22821
22822        // Group on telegram.draft includes both members.
22823        let group = crate::multi_agent::PeerGroupConfig {
22824            channel: "telegram".to_string(),
22825            agents: vec![
22826                crate::multi_agent::AgentAlias::new("alpha"),
22827                crate::multi_agent::AgentAlias::new("beta"),
22828            ],
22829            ..crate::multi_agent::PeerGroupConfig::default()
22830        };
22831        config.peer_groups.insert("team_chat".to_string(), group);
22832
22833        config
22834            .validate()
22835            .expect("two-member same-channel peer group must validate cleanly");
22836    }
22837
22838    #[test]
22839    async fn config_validate_rejects_classifier_provider_pointing_at_missing_alias() {
22840        // Use the SHARED `typed_provider_refs` validation loop — same error
22841        // surface as tts_provider / transcription_provider.
22842        let toml = r#"
22843            [providers.models.custom.default]
22844            api_key = "k"
22845            model = "qwen3.6-plus"
22846            uri = "https://example.com/v1"
22847            wire_api = "chat_completions"
22848
22849            [risk_profiles.default]
22850            level = "supervised"
22851
22852            [agents.default]
22853            enabled = true
22854            model_provider = "custom.default"
22855            risk_profile = "default"
22856            classifier_provider = "custom.does-not-exist"
22857        "#;
22858        let cfg: Config = toml::from_str(toml).unwrap();
22859        let err = cfg
22860            .validate()
22861            .expect_err("missing alias must fail validate");
22862        let msg = format!("{err:#}");
22863        assert!(
22864            msg.contains("classifier_provider")
22865                && msg.contains("does-not-exist")
22866                && msg.contains("providers.models.custom.does-not-exist is not configured"),
22867            "expected DanglingReference error mentioning field + alias + section, got: {msg}"
22868        );
22869    }
22870
22871    #[test]
22872    async fn config_validate_accepts_classifier_provider_pointing_at_existing_alias() {
22873        let toml = r#"
22874            [providers.models.custom.default]
22875            api_key = "k1"
22876            model = "qwen3.6-plus"
22877            uri = "https://example.com/v1"
22878            wire_api = "chat_completions"
22879
22880            [providers.models.custom.kimi-k2-5]
22881            api_key = "k2"
22882            model = "kimi-k2.5"
22883            uri = "https://example.com/v1"
22884            wire_api = "chat_completions"
22885
22886            [risk_profiles.default]
22887            level = "supervised"
22888
22889            [agents.default]
22890            enabled = true
22891            model_provider = "custom.default"
22892            risk_profile = "default"
22893            classifier_provider = "custom.kimi-k2-5"
22894        "#;
22895        let cfg: Config = toml::from_str(toml).unwrap();
22896        cfg.validate()
22897            .expect("validate must succeed for resolvable ref");
22898        assert_eq!(
22899            cfg.agents
22900                .get("default")
22901                .unwrap()
22902                .classifier_provider
22903                .as_str(),
22904            "custom.kimi-k2-5"
22905        );
22906    }
22907
22908    #[test]
22909    async fn config_validate_accepts_empty_classifier_provider_as_inheritance_signal() {
22910        // No classifier_provider field at all → must validate, must remain
22911        // the empty default. This pins backward compatibility.
22912        let toml = r#"
22913            [providers.models.custom.default]
22914            api_key = "k"
22915            model = "qwen3.6-plus"
22916            uri = "https://example.com/v1"
22917            wire_api = "chat_completions"
22918
22919            [risk_profiles.default]
22920            level = "supervised"
22921
22922            [agents.default]
22923            enabled = true
22924            model_provider = "custom.default"
22925            risk_profile = "default"
22926        "#;
22927        let cfg: Config = toml::from_str(toml).unwrap();
22928        cfg.validate()
22929            .expect("missing classifier_provider must validate");
22930        assert!(
22931            cfg.agents
22932                .get("default")
22933                .unwrap()
22934                .classifier_provider
22935                .is_empty()
22936        );
22937    }
22938}