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::autonomy::DelegationPolicy;
9use crate::domain_matcher::DomainMatcher;
10use crate::traits::{ChannelConfig, HasPropKind, PropKind};
11use crate::validation_bail;
12use anyhow::{Context, Result};
13use directories::UserDirs;
14use serde::{Deserialize, Serialize};
15use std::collections::HashMap;
16use std::path::{Path, PathBuf};
17use std::sync::{OnceLock, RwLock};
18#[cfg(unix)]
19use tokio::fs::File;
20use tokio::fs::{self, OpenOptions};
21use tokio::io::AsyncWriteExt;
22use zeroclaw_macros::Configurable;
23
24const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
25    "model_provider.anthropic",
26    "model_provider.compatible",
27    "model_provider.copilot",
28    "model_provider.gemini",
29    "model_provider.glm",
30    "model_provider.ollama",
31    "model_provider.openai",
32    "model_provider.openrouter",
33    "channel.dingtalk",
34    "channel.discord",
35    "channel.lark",
36    "channel.matrix",
37    "channel.mattermost",
38    "channel.nextcloud_talk",
39    "channel.qq",
40    "channel.signal",
41    "channel.slack",
42    "channel.telegram",
43    "channel.wati",
44    "channel.wechat",
45    "channel.whatsapp",
46    "tool.browser",
47    "tool.composio",
48    "tool.http_request",
49    "tool.pushover",
50    "tool.web_search",
51    "memory.embeddings",
52    "tunnel.custom",
53    "transcription.groq",
54];
55
56const SUPPORTED_PROXY_SERVICE_SELECTORS: &[&str] = &[
57    "model_provider.*",
58    "channel.*",
59    "tool.*",
60    "memory.*",
61    "tunnel.*",
62    "transcription.*",
63];
64
65static RUNTIME_PROXY_CONFIG: OnceLock<RwLock<ProxyConfig>> = OnceLock::new();
66static RUNTIME_PROXY_CLIENT_CACHE: OnceLock<RwLock<HashMap<String, reqwest::Client>>> =
67    OnceLock::new();
68
69// ── Top-level config ──────────────────────────────────────────────
70
71/// Top-level ZeroClaw configuration, loaded from `config.toml`.
72///
73/// Resolution order: `ZEROCLAW_CONFIG_DIR` env → `ZEROCLAW_WORKSPACE` env → `~/.zeroclaw/config.toml`.
74#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
75#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
76pub struct Config {
77    /// Shared instance data directory (databases, hygiene state, cost
78    /// records, daemon state files). Computed from `ZEROCLAW_CONFIG_DIR`
79    /// / `ZEROCLAW_DATA_DIR` / `ZEROCLAW_WORKSPACE` (deprecated) at
80    /// load time, not serialized. Per-agent identity + markdown lives
81    /// at `agent_workspace_dir(&alias)`, not here.
82    #[serde(skip)]
83    pub data_dir: PathBuf,
84    /// Path to config.toml - computed from home, not serialized
85    #[serde(skip)]
86    pub config_path: PathBuf,
87    /// Dotted prop-paths overridden by `ZEROCLAW_*` env vars at load time.
88    /// Populated by `apply_env_overrides`; consulted by `save()` to mask the
89    /// env-injected values back to disk-or-default before encryption, and by
90    /// `prop_is_env_overridden` for O(1) display-layer lookup (config list,
91    /// dashboard, quickstart).
92    #[serde(skip)]
93    pub env_overridden_paths: std::collections::HashSet<String>,
94    /// Per-path snapshot of pre-override raw values, captured at apply time
95    /// from the post-`decrypt_secrets` in-memory state (so secret entries
96    /// hold plaintext, not the display mask). `save()` restores from this
97    /// map so env-injected values never reach disk and the operator's
98    /// original on-disk credentials survive any save cycle.
99    #[serde(skip)]
100    pub pre_override_snapshots: std::collections::HashMap<String, String>,
101    /// Per-path snapshot of `op://` external secret references captured before
102    /// `decrypt_secrets()` resolves them for runtime use. `save()` restores
103    /// these references unless the same path was intentionally edited.
104    #[serde(skip)]
105    pub onepassword_reference_snapshots: std::collections::HashMap<String, String>,
106    /// Dotted prop-paths mutated since the last persist; drives the
107    /// per-path PATCH applied by `save_dirty()`.
108    #[serde(skip)]
109    pub dirty_paths: std::collections::HashSet<String>,
110    /// Security-critical sections the resilient loader reset to `Default`
111    /// because the on-disk block was malformed. Non-empty = posture may be
112    /// weaker than intended; exposure gating should refuse to trust the
113    /// instance until repaired. Never serialized — a load-time signal.
114    #[serde(skip)]
115    pub degraded_security: Vec<String>,
116    /// Config file schema version.
117    #[serde(default = "default_schema_version")]
118    pub schema_version: u32,
119
120    /// All configured provider profiles, grouped by category under a
121    /// single `[providers]` root. Categories today: `models`, `tts`,
122    /// `transcription`. Shape: `[providers.<category>.<type>.<alias>]`,
123    /// e.g. `[providers.models.anthropic.default]`,
124    /// `[providers.tts.openai.default]`,
125    /// `[providers.transcription.groq.default]`.
126    #[serde(default)]
127    #[nested]
128    pub providers: crate::providers::Providers,
129
130    /// Model-routing rules — route `hint:<name>` to specific
131    /// model_provider + model combos.
132    #[serde(default, skip_serializing_if = "Vec::is_empty")]
133    #[credential_class = "requires_follow_up"]
134    pub model_routes: Vec<ModelRouteConfig>,
135
136    /// Embedding-routing rules — route `hint:<name>` to specific
137    /// model_provider + model combos for embedding requests.
138    #[serde(default, skip_serializing_if = "Vec::is_empty")]
139    #[credential_class = "requires_follow_up"]
140    pub embedding_routes: Vec<EmbeddingRouteConfig>,
141
142    /// Observability backend configuration (`[observability]`).
143    #[serde(default)]
144    #[nested]
145    pub observability: ObservabilityConfig,
146
147    /// Trust scoring and regression detection configuration (`[trust]`).
148    #[serde(default)]
149    #[nested]
150    pub trust: crate::scattered_types::TrustConfig,
151
152    /// Security subsystem configuration (`[security]`).
153    #[serde(default)]
154    #[nested]
155    pub security: SecurityConfig,
156
157    /// Backup tool configuration (`[backup]`).
158    #[serde(default)]
159    #[nested]
160    pub backup: BackupConfig,
161
162    /// Data retention and purge configuration (`[data_retention]`).
163    #[serde(default)]
164    #[nested]
165    pub data_retention: DataRetentionConfig,
166
167    /// Cloud transformation accelerator configuration (`[cloud_ops]`).
168    #[serde(default)]
169    #[nested]
170    pub cloud_ops: CloudOpsConfig,
171
172    /// Conversational AI agent builder configuration (`[conversational_ai]`).
173    ///
174    /// Experimental / future feature — not yet wired into the agent runtime.
175    /// Omitted from generated config files when disabled (the default).
176    /// Existing configs that already contain this section will continue to
177    /// deserialize correctly thanks to `#[serde(default)]`.
178    #[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")]
179    #[nested]
180    pub conversational_ai: ConversationalAiConfig,
181
182    /// Managed cybersecurity service configuration (`[security_ops]`).
183    #[serde(default)]
184    #[nested]
185    pub security_ops: SecurityOpsConfig,
186
187    /// Runtime adapter configuration (`[runtime]`). Controls native vs Docker execution.
188    #[serde(default)]
189    #[nested]
190    pub runtime: RuntimeConfig,
191
192    /// Reliability settings: retries, backoff, key rotation (`[reliability]`).
193    #[serde(default)]
194    #[nested]
195    pub reliability: ReliabilityConfig,
196
197    /// Scheduler configuration for periodic task execution (`[scheduler]`).
198    #[serde(default)]
199    #[nested]
200    pub scheduler: SchedulerConfig,
201
202    /// Pacing controls for slow/local LLM workloads (`[pacing]`).
203    #[serde(default)]
204    #[nested]
205    pub pacing: PacingConfig,
206
207    /// Skills loading and community repository behavior (`[skills]`).
208    #[serde(default)]
209    #[nested]
210    pub skills: SkillsConfig,
211
212    /// Pipeline tool configuration (`[pipeline]`).
213    #[serde(default)]
214    #[nested]
215    pub pipeline: PipelineConfig,
216
217    /// Automatic query classification — maps user messages to model hints.
218    #[serde(default)]
219    #[nested]
220    pub query_classification: QueryClassificationConfig,
221
222    /// Heartbeat configuration for periodic health pings (`[heartbeat]`).
223    #[serde(default)]
224    #[nested]
225    pub heartbeat: HeartbeatConfig,
226
227    /// Declarative cron jobs (`[cron.<alias>]`), alias-keyed.
228    ///
229    /// Each entry is a named scheduled job synced into the database at
230    /// scheduler startup. Subsystem runtime knobs (enable/disable, catch-up,
231    /// run-history retention) live on `[scheduler]`.
232    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
233    #[nested]
234    pub cron: HashMap<String, CronJobDecl>,
235
236    /// ACP (Agent Client Protocol) server configuration (`[acp]`).
237    #[serde(default)]
238    #[nested]
239    pub acp: AcpConfig,
240
241    /// Channel configurations: Telegram, Discord, Slack, etc. (`[channels]`).
242    #[serde(default, alias = "channels_config")]
243    #[nested]
244    pub channels: ChannelsConfig,
245
246    /// Memory backend configuration: sqlite, markdown, embeddings (`[memory]`).
247    #[serde(default)]
248    #[nested]
249    pub memory: MemoryConfig,
250
251    /// Persistent storage model_provider configuration (`[storage]`).
252    #[serde(default)]
253    #[nested]
254    pub storage: StorageConfig,
255
256    /// Tunnel configuration for exposing the gateway publicly (`[tunnel]`).
257    #[serde(default)]
258    #[nested]
259    pub tunnel: TunnelConfig,
260
261    /// Gateway server configuration: host, port, pairing, rate limits (`[gateway]`).
262    #[serde(default)]
263    #[nested]
264    pub gateway: GatewayConfig,
265
266    /// WebSocket Secure (WSS) transport for remote TUI connections (`[wss]`).
267    #[serde(default)]
268    #[nested]
269    pub wss: WssConfig,
270
271    /// Composio managed OAuth tools integration (`[composio]`).
272    #[serde(default)]
273    #[nested]
274    pub composio: ComposioConfig,
275
276    /// Microsoft 365 Graph API integration (`[microsoft365]`).
277    #[serde(default)]
278    #[nested]
279    pub microsoft365: Microsoft365Config,
280
281    /// Secrets encryption configuration (`[secrets]`).
282    #[serde(default)]
283    #[nested]
284    pub secrets: SecretsConfig,
285
286    /// Browser automation configuration (`[browser]`).
287    #[serde(default)]
288    #[nested]
289    pub browser: BrowserConfig,
290
291    /// Browser delegation configuration (`[browser_delegate]`).
292    ///
293    /// Delegates browser-based tasks to a browser-capable CLI subprocess (e.g.
294    /// Claude Code with `claude-in-chrome` MCP tools). Useful for interacting
295    /// with corporate web apps (Teams, Outlook, Jira, Confluence) that lack
296    /// direct API access. A persistent Chrome profile can be configured so SSO
297    /// sessions survive across invocations.
298    ///
299    /// Fields:
300    /// - `enabled` (`bool`, default `false`) — enable the browser delegation tool.
301    /// - `cli_binary` (`String`, default `"claude"`) — CLI binary to spawn for browser tasks.
302    /// - `chrome_profile_dir` (`String`, default `""`) — Chrome user-data directory for
303    ///   persistent SSO sessions. When empty, a fresh profile is used each invocation.
304    /// - `allowed_domains` (`Vec<String>`, default `[]`) — allowlist of domains the browser
305    ///   may navigate to. Empty means all non-blocked domains are permitted.
306    /// - `blocked_domains` (`Vec<String>`, default `[]`) — denylist of domains. Blocked
307    ///   domains take precedence over allowed domains.
308    /// - `task_timeout_secs` (`u64`, default `120`) — per-task timeout in seconds.
309    ///
310    /// Compatibility: additive and disabled by default; existing configs remain valid when omitted.
311    /// Rollback/migration: remove `[browser_delegate]` or keep `enabled = false` to disable.
312    #[serde(default)]
313    #[nested]
314    pub browser_delegate: crate::scattered_types::BrowserDelegateConfig,
315
316    /// HTTP request tool configuration (`[http_request]`).
317    #[serde(default)]
318    #[nested]
319    pub http_request: HttpRequestConfig,
320
321    /// Multimodal (image) handling configuration (`[multimodal]`).
322    #[serde(default)]
323    #[nested]
324    pub multimodal: MultimodalConfig,
325
326    /// Automatic media understanding pipeline (`[media_pipeline]`).
327    #[serde(default)]
328    #[nested]
329    pub media_pipeline: MediaPipelineConfig,
330
331    /// Web fetch tool configuration (`[web_fetch]`).
332    #[serde(default)]
333    #[nested]
334    pub web_fetch: WebFetchConfig,
335
336    /// Link enricher configuration (`[link_enricher]`).
337    #[serde(default)]
338    #[nested]
339    pub link_enricher: LinkEnricherConfig,
340
341    /// Text browser tool configuration (`[text_browser]`).
342    #[serde(default)]
343    #[nested]
344    pub text_browser: TextBrowserConfig,
345
346    /// Web search tool configuration (`[web_search]`).
347    #[serde(default)]
348    #[nested]
349    pub web_search: WebSearchConfig,
350
351    /// Project delivery intelligence configuration (`[project_intel]`).
352    #[serde(default)]
353    #[nested]
354    pub project_intel: ProjectIntelConfig,
355
356    /// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]`).
357    #[serde(default)]
358    #[nested]
359    pub google_workspace: GoogleWorkspaceConfig,
360
361    /// Proxy configuration for outbound HTTP/HTTPS/SOCKS5 traffic (`[proxy]`).
362    #[serde(default)]
363    #[nested]
364    pub proxy: ProxyConfig,
365
366    /// Cost tracking and budget enforcement configuration (`[cost]`).
367    /// Also hosts the operator-managed rate sheet at
368    /// `[cost.rates.<type>.<model>]`.
369    #[serde(default)]
370    #[nested]
371    pub cost: CostConfig,
372
373    /// Peripheral board configuration for hardware integration (`[peripherals]`).
374    #[serde(default)]
375    #[nested]
376    pub peripherals: PeripheralsConfig,
377
378    /// Delegate tool global default configuration (`[delegate]`).
379    #[serde(default)]
380    #[nested]
381    pub delegate: DelegateToolConfig,
382
383    /// Aliased agents in this install. Each entry under `[agents.<alias>]`
384    /// is one user-facing agent with its own identity, channels, model
385    /// provider, risk profile, workspace, and memory scope.
386    /// `DelegateTool` consults this map when one agent delegates a
387    /// subtask to another.
388    #[serde(default)]
389    #[nested]
390    pub agents: HashMap<String, AliasedAgentConfig>,
391
392    /// Named risk/autonomy profiles (`[risk_profiles.<alias>]`).
393    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
394    #[nested]
395    pub risk_profiles: HashMap<String, RiskProfileConfig>,
396
397    /// Named runtime/LLM execution profiles (`[runtime_profiles.<alias>]`).
398    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
399    #[nested]
400    pub runtime_profiles: HashMap<String, RuntimeProfileConfig>,
401
402    /// Named skill bundles (`[skill_bundles.<alias>]`).
403    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
404    #[nested]
405    pub skill_bundles: HashMap<String, SkillBundleConfig>,
406
407    /// Named knowledge bundles (`[knowledge_bundles.<alias>]`).
408    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
409    #[nested]
410    pub knowledge_bundles: HashMap<String, KnowledgeBundleConfig>,
411
412    /// Named MCP server bundles (`[mcp_bundles.<alias>]`).
413    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
414    #[nested]
415    pub mcp_bundles: HashMap<String, McpBundleConfig>,
416
417    /// Named peer groups (`[peer_groups.<name>]`). Each entry binds a
418    /// channel, a list of member agents, and optional non-agent
419    /// (external) members and a per-group blocklist. Mutual opt-in:
420    /// two agents become peers only when both appear in the same
421    /// group's `agents`. Empty by default for single-agent installs.
422    /// See `crate::multi_agent::PeerGroupConfig`.
423    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
424    #[nested]
425    pub peer_groups: HashMap<String, crate::multi_agent::PeerGroupConfig>,
426
427    /// Hooks configuration (lifecycle hooks and built-in hook toggles).
428    #[serde(default)]
429    #[nested]
430    pub hooks: HooksConfig,
431
432    /// Hardware configuration (wizard-driven physical world setup).
433    #[serde(default)]
434    #[nested]
435    pub hardware: HardwareConfig,
436
437    /// Voice transcription configuration (Whisper API via Groq).
438    #[serde(default)]
439    #[nested]
440    pub transcription: TranscriptionConfig,
441
442    /// Text-to-Speech configuration (`[tts]`).
443    #[serde(default)]
444    #[nested]
445    pub tts: TtsConfig,
446
447    /// External MCP server connections (`[mcp]`).
448    #[serde(default, alias = "mcpServers")]
449    #[nested]
450    pub mcp: McpConfig,
451
452    /// Dynamic node discovery configuration (`[nodes]`).
453    #[serde(default)]
454    #[nested]
455    pub nodes: NodesConfig,
456
457    /// Meta-state for the Quickstart flow (which sections the user has
458    /// already walked through). Not user-facing config (`[onboard_state]`).
459    #[serde(default)]
460    #[nested]
461    pub onboard_state: OnboardStateConfig,
462
463    /// Notion integration configuration (`[notion]`).
464    #[serde(default)]
465    #[nested]
466    pub notion: NotionConfig,
467
468    /// Jira integration configuration (`[jira]`).
469    #[serde(default)]
470    #[nested]
471    pub jira: JiraConfig,
472
473    /// Secure inter-node transport configuration (`[node_transport]`).
474    #[serde(default)]
475    #[nested]
476    pub node_transport: NodeTransportConfig,
477
478    /// Knowledge graph configuration (`[knowledge]`).
479    #[serde(default)]
480    #[nested]
481    pub knowledge: KnowledgeConfig,
482
483    /// LinkedIn integration configuration (`[linkedin]`).
484    #[serde(default)]
485    #[nested]
486    pub linkedin: LinkedInConfig,
487
488    /// Standalone image generation tool configuration (`[image_gen]`).
489    #[serde(default)]
490    #[nested]
491    pub image_gen: ImageGenConfig,
492
493    /// Standalone file upload tool configuration (`[file_upload]`).
494    #[serde(default)]
495    #[nested]
496    pub file_upload: FileUploadConfig,
497
498    /// Standalone multi-file bundle upload tool configuration
499    /// (`[file_upload_bundle]`).
500    #[serde(default)]
501    #[nested]
502    pub file_upload_bundle: FileUploadBundleConfig,
503
504    /// Standalone file download tool configuration (`[file_download]`).
505    #[serde(default)]
506    #[nested]
507    pub file_download: FileDownloadConfig,
508
509    /// Plugin system configuration (`[plugins]`).
510    #[serde(default)]
511    #[nested]
512    pub plugins: PluginsConfig,
513
514    /// Locale for tool descriptions (e.g. `"en"`, `"zh-CN"`).
515    ///
516    /// When set, tool descriptions shown in system prompts are loaded from
517    /// Fluent `.ftl` locale files. Falls back to embedded English, then to
518    /// hardcoded descriptions.
519    ///
520    /// If omitted or empty, the locale is auto-detected from `ZEROCLAW_LOCALE`,
521    /// `LANG`, or `LC_ALL` environment variables (defaulting to `"en"`).
522    #[serde(default)]
523    pub locale: Option<String>,
524
525    /// Verifiable Intent (VI) credential verification and issuance (`[verifiable_intent]`).
526    #[serde(default)]
527    #[nested]
528    pub verifiable_intent: VerifiableIntentConfig,
529
530    /// Claude Code tool configuration (`[claude_code]`).
531    #[serde(default)]
532    #[nested]
533    pub claude_code: ClaudeCodeConfig,
534
535    /// Claude Code task runner with Slack progress and SSH session handoff (`[claude_code_runner]`).
536    #[serde(default)]
537    #[nested]
538    pub claude_code_runner: ClaudeCodeRunnerConfig,
539
540    /// Codex CLI tool configuration (`[codex_cli]`).
541    #[serde(default)]
542    #[nested]
543    pub codex_cli: CodexCliConfig,
544
545    /// Gemini CLI tool configuration (`[gemini_cli]`).
546    #[serde(default)]
547    #[nested]
548    pub gemini_cli: GeminiCliConfig,
549
550    /// OpenCode CLI tool configuration (`[opencode_cli]`).
551    #[serde(default)]
552    #[nested]
553    pub opencode_cli: OpenCodeCliConfig,
554
555    /// Standard Operating Procedures engine configuration (`[sop]`).
556    #[serde(default)]
557    #[nested]
558    pub sop: SopConfig,
559
560    /// Shell tool configuration (`[shell_tool]`).
561    #[serde(default)]
562    #[nested]
563    pub shell_tool: ShellToolConfig,
564
565    /// Escalation routing configuration (`[escalation]`).
566    #[serde(default)]
567    #[nested]
568    pub escalation: EscalationConfig,
569}
570
571/// Multi-client workspace isolation configuration.
572///
573/// When enabled, each client engagement gets an isolated workspace with
574/// separate memory, audit, secrets, and tool restrictions.
575#[allow(clippy::struct_excessive_bools)]
576/// Opaque state the Quickstart flow writes so it can tell, on a
577/// re-run, which sections the user has already walked through at least
578/// once — which lets it offer "Reconfigure? [y/N]" skip gates instead of
579/// forcing users through every field again.
580///
581/// This is meta-state about the Quickstart flow, not user-facing config.
582#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
583#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
584#[prefix = "onboard_state"]
585pub struct OnboardStateConfig {
586    /// Section keys the user has completed at least once.
587    /// Values are the lowercased Section variant names
588    /// (`"workspace"`, `"model_providers"`, …).
589    #[serde(default)]
590    pub completed_sections: Vec<String>,
591    /// `true` once the Quickstart has applied a `BuilderSubmission`
592    /// successfully on this install. Web gateway and TUI auto-launch
593    /// the Quickstart on startup iff this is `false` **and** no
594    /// `agents.*` entries exist (the implicit-completion rule covers
595    /// upgrades). The flag is flipped in the same atomic write that
596    /// lands the Quickstart submission; re-entering the Quickstart
597    /// later to add another agent does not flip it back to `false`.
598    #[serde(default)]
599    pub quickstart_completed: bool,
600}
601
602/// Used by `#[serde(skip_serializing_if)]` on plain `bool` fields to omit
603/// them from TOML output when they carry their struct-level default (`false`).
604/// Keeps fresh model_provider entries clean — a default-constructed
605/// `ModelProviderConfig` for one model_provider family shouldn't write flag fields
606/// that only apply to a different family.
607fn is_false(value: &bool) -> bool {
608    !*value
609}
610
611/// One trait per family-endpoint enum. Returns the URI template for the chosen
612/// variant — a literal URL for fixed endpoints (`https://api.openai.com/v1`),
613/// or a substitution template for computed endpoints (Azure's
614/// `https://{resource}.openai.azure.com/...`). Substitution happens family-side
615/// in the runtime constructor; for non-templated families the return value is
616/// the final URL.
617///
618/// Resolution order at runtime is uniform across every model model_provider family:
619/// operator's `cfg.uri` first; family endpoint enum's `uri()` second; loud
620/// failure when neither is set.
621pub trait ModelEndpoint {
622    fn uri(&self) -> &'static str;
623}
624
625/// Implemented by every `*ModelProviderConfig`. Multi-region families
626/// override to return `Some(self.endpoint.uri())`; single-endpoint families
627/// inherit the `None` default. Drives `ModelProviders::resolved_endpoint_uri`,
628/// which is itself driven by the `for_each_model_provider_slot!` macro — so
629/// adding a new family without an impl is a compile error.
630pub trait FamilyEndpoint {
631    fn endpoint_uri(&self) -> Option<&'static str> {
632        None
633    }
634}
635
636/// Wire protocol flavor for the model_provider client. `responses` routes
637/// through OpenAI's Codex/Responses API (`POST /v1/responses`);
638/// `chat_completions` routes through the legacy `/v1/chat/completions` (or
639/// the family's chat-completions-compatible endpoint). Auto-selected per
640/// family when unset.
641#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
642#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
643#[serde(rename_all = "snake_case")]
644pub enum WireApi {
645    Responses,
646    ChatCompletions,
647}
648
649impl WireApi {
650    #[must_use]
651    pub fn as_str(self) -> &'static str {
652        match self {
653            Self::Responses => "responses",
654            Self::ChatCompletions => "chat_completions",
655        }
656    }
657}
658
659/// Authentication mode for model model_provider families that support more than one
660/// (e.g. Qwen, Minimax can use API key OR OAuth). Families that only support a
661/// single auth flow simply omit this field from their config struct.
662#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
663#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
664#[serde(rename_all = "snake_case")]
665pub enum AuthMode {
666    /// Standard API key authentication via the `api_key` field.
667    #[default]
668    ApiKey,
669    /// OAuth flow — credential resolution defers to the family runtime impl
670    /// (typically reading a vendor-specific token cache or env var).
671    OAuth,
672}
673
674/// Named model_provider profile definition.
675#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
676#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
677#[prefix = "providers.models"]
678pub struct ModelProviderConfig {
679    /// Secret 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.
680    #[secret]
681    #[credential_class = "encrypted_secret"]
682    #[tab(Connection)]
683    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub api_key: Option<String>,
686    /// Provider implementation to instantiate for this profile. Use this
687    /// when a canonical typed slot should run through a compatible
688    /// implementation, e.g. `[providers.models.openai.proxy] kind =
689    /// "openai-compatible"`.
690    #[serde(default, skip_serializing_if = "Option::is_none")]
691    pub kind: Option<String>,
692    /// 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.
693    #[tab(Connection)]
694    #[serde(default, skip_serializing_if = "Option::is_none")]
695    pub uri: Option<String>,
696    /// 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.
697    #[tab(Model)]
698    #[serde(default, skip_serializing_if = "Option::is_none")]
699    pub model: Option<String>,
700    /// Ordered list of other provider aliases to try when every model on this
701    /// alias has failed. Each entry is a dotted `<type>.<alias>` reference into
702    /// `providers.models` and resolves with its own credentials, endpoint, and
703    /// model. A fallback never inherits this alias's key. The walk is
704    /// depth-first: this alias's models are exhausted first, then each fallback
705    /// alias is descended in turn (applying its own `fallback_models` and
706    /// `fallback`). Empty means no provider-level fallback.
707    #[tab(Model)]
708    #[serde(default, skip_serializing_if = "Vec::is_empty")]
709    pub fallback: Vec<crate::providers::ModelProviderRef>,
710    /// Ordered alternate models to try on THIS provider before falling over to
711    /// the `fallback` aliases. Same endpoint, key, and headers as the primary
712    /// `model`. Only the model identifier changes. Use this when a provider
713    /// serves a backup model (e.g. a smaller or older variant) that should be
714    /// tried before leaving the provider entirely. Empty means only `model` is
715    /// tried.
716    #[tab(Model)]
717    #[serde(default, skip_serializing_if = "Vec::is_empty")]
718    pub fallback_models: Vec<String>,
719    /// Sampling temperature passed to the model. Lower values (0.0–0.3) give
720    /// deterministic, near-verbatim output, which fits code, routing, summarization.
721    /// Higher values (0.7–1.2) give more varied output, which fits open-ended chat.
722    #[tab(Model)]
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    pub temperature: Option<f64>,
725    /// 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.
726    #[tab(Model)]
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub timeout_secs: Option<u64>,
729    /// 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.
730    #[tab(Connection)]
731    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
732    #[secret]
733    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
734    pub extra_headers: HashMap<String, String>,
735    /// 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.
736    #[tab(Advanced)]
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub wire_api: Option<WireApi>,
739    /// 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.
740    #[tab(Connection)]
741    #[serde(default, skip_serializing_if = "is_false")]
742    #[credential_class = "external_auth_store"]
743    pub requires_openai_auth: bool,
744    /// 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.
745    #[tab(Model)]
746    #[serde(default, skip_serializing_if = "Option::is_none")]
747    pub max_tokens: Option<u32>,
748    /// 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.
749    #[tab(Advanced)]
750    #[serde(default, skip_serializing_if = "is_false")]
751    pub merge_system_into_user: bool,
752    /// Extra JSON parameters to include in API requests.
753    /// Merged at the top level of the request body, allowing provider-specific
754    /// features (routing, transforms, etc.) without code changes.
755    /// Example: `provider_extra = { model_provider = { only = ["Anthropic"] } }`
756    #[tab(Advanced)]
757    #[serde(default, skip_serializing_if = "Option::is_none")]
758    pub provider_extra: Option<serde_json::Value>,
759    /// Per-model pricing for cost tracking, USD per 1M tokens.
760    ///
761    /// Free-form key/value map. Keys are user-defined model identifiers; an
762    /// optional `.input` / `.output` suffix encodes pricing dimension when
763    /// the operator wants to split rates. A bare key without a suffix is
764    /// used as a flat per-token rate when neither dimension is specified.
765    /// Default is empty: cost tracking falls back to "unknown" rates and
766    /// only token usage is recorded.
767    ///
768    /// Example: `pricing = { opus = 15.0, sonnet = 3.0 }`
769    /// Or split: `pricing = { "opus.input" = 15.0, "opus.output" = 75.0 }`
770    #[tab(Advanced)]
771    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
772    pub pricing: HashMap<String, f64>,
773    /// Override the provider's default for native tool calling.
774    /// `None` (default) honors the provider's built-in choice. `Some(true)`
775    /// forces native tool calls on, `Some(false)` forces text-fallback.
776    /// Currently consulted only by the Groq factory, which defaults to
777    /// text-fallback because llama-family Groq models reject native tool
778    /// calls with HTTP 400. Setting `native_tools = true` re-enables native
779    /// tool calling for Groq models that support it.
780    #[tab(Advanced)]
781    #[serde(default, skip_serializing_if = "Option::is_none")]
782    pub native_tools: Option<bool>,
783    /// Enable or disable chain-of-thought thinking for models that support it
784    /// (e.g. Qwen3, GLM-4). `true` turns thinking on, `false` turns it off.
785    /// `None` (default) lets the model decide. Forwarded as `enable_thinking`
786    /// in the request body; mirrors the Ollama provider's `think` field.
787    #[tab(Advanced)]
788    #[serde(default, skip_serializing_if = "Option::is_none")]
789    pub think: Option<bool>,
790    /// Arbitrary key/value pairs forwarded verbatim as `chat_template_kwargs`
791    /// in the request body (llama.cpp-specific). Use this to pass model-family
792    /// template variables that control behaviour not exposed by other fields.
793    /// Example (Qwen3 thinking suppression):
794    ///   `chat_template_kwargs = { enable_thinking = false }`
795    #[tab(Advanced)]
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    pub chat_template_kwargs: Option<serde_json::Value>,
798}
799
800// ── Per-family model model_provider configs ────────────────────────────
801//
802// Each family carries its own typed config (composing `ModelProviderConfig`
803// via `#[serde(flatten)]`) plus a per-family `*Endpoint` enum that names the
804// known endpoints and resolves them via the `ModelEndpoint` trait. Families
805// that support multiple auth flows additionally carry an `auth_mode` field.
806//
807// Pattern reference for adding a new family:
808// - Single-endpoint family with no extras: see `AnthropicModelProviderConfig`
809// - Family with extras: see `OpenAIModelProviderConfig`
810// - Family with computed-endpoint template: see `AzureModelProviderConfig`
811// - Multi-region family with a required `endpoint` field: see `MoonshotModelProviderConfig`
812//
813// The `ModelProviders` container in `crates/zeroclaw-config/src/model_providers.rs`
814// holds a typed slot per family; the runtime impls in zeroclaw-providers
815// consume the typed configs directly.
816
817// ── OpenAI ──
818
819/// OpenAI canonical endpoint. Single variant — OpenAI publishes one base URL.
820#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
821#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
822#[serde(rename_all = "snake_case")]
823pub enum OpenAIEndpoint {
824    #[default]
825    Default,
826}
827
828impl ModelEndpoint for OpenAIEndpoint {
829    fn uri(&self) -> &'static str {
830        match self {
831            Self::Default => "https://api.openai.com/v1",
832        }
833    }
834}
835
836/// OpenAI model model_provider config. The OpenAI-family extras (`wire_api`,
837/// `requires_openai_auth`) live on the shared `ModelProviderConfig` base
838/// because they're consumed by validation and runtime helpers that operate
839/// on the base struct without family awareness; this wrapper is a thin
840/// typed slot, no extra fields.
841#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
842#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
843#[prefix = "providers.models.openai"]
844pub struct OpenAIModelProviderConfig {
845    #[nested]
846    #[serde(flatten)]
847    pub base: ModelProviderConfig,
848}
849
850// ── Azure OpenAI ──
851
852/// Azure OpenAI endpoint template. Single variant; the URL is computed at
853/// runtime by substituting `{resource}` and `{deployment}` from the typed
854/// config fields.
855#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
856#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
857#[serde(rename_all = "snake_case")]
858pub enum AzureEndpoint {
859    #[default]
860    Default,
861}
862
863impl ModelEndpoint for AzureEndpoint {
864    fn uri(&self) -> &'static str {
865        match self {
866            // Azure's URI is a template — substitution happens in the
867            // AzureModelProvider runtime constructor against the typed
868            // config's resource / deployment fields.
869            Self::Default => "https://{resource}.openai.azure.com/openai/deployments/{deployment}",
870        }
871    }
872}
873
874/// Azure OpenAI model model_provider config. Carries the Azure-specific connection
875/// fields (`resource`, `deployment`, `api_version`) — the URI template
876/// substitutes `{resource}` and `{deployment}` at runtime. Operators can
877/// still override the entire endpoint via `base.uri`.
878#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
879#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
880#[prefix = "providers.models.azure"]
881pub struct AzureModelProviderConfig {
882    #[nested]
883    #[serde(flatten)]
884    pub base: ModelProviderConfig,
885    /// Azure resource name (the `<resource>` part of `<resource>.openai.azure.com`).
886    #[serde(
887        default,
888        skip_serializing_if = "Option::is_none",
889        alias = "azure_openai_resource"
890    )]
891    pub resource: Option<String>,
892    /// Azure deployment name: the deployment created in Azure AI Studio.
893    #[serde(
894        default,
895        skip_serializing_if = "Option::is_none",
896        alias = "azure_openai_deployment"
897    )]
898    pub deployment: Option<String>,
899    /// Azure API version string (e.g. `2024-10-21`).
900    #[serde(
901        default,
902        skip_serializing_if = "Option::is_none",
903        alias = "azure_openai_api_version"
904    )]
905    pub api_version: Option<String>,
906}
907
908// ── Anthropic ──
909
910/// Anthropic canonical endpoint.
911#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
912#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
913#[serde(rename_all = "snake_case")]
914pub enum AnthropicEndpoint {
915    #[default]
916    Default,
917}
918
919impl ModelEndpoint for AnthropicEndpoint {
920    fn uri(&self) -> &'static str {
921        match self {
922            Self::Default => "https://api.anthropic.com",
923        }
924    }
925}
926
927/// Anthropic model model_provider config. No family-specific extras yet — typed
928/// slot reserved for future Anthropic-only knobs (cache_control, beta
929/// headers) so they land cleanly without another schema rework.
930#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
931#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
932#[prefix = "providers.models.anthropic"]
933pub struct AnthropicModelProviderConfig {
934    #[nested]
935    #[serde(flatten)]
936    pub base: ModelProviderConfig,
937}
938
939// ── Moonshot (multi-region exemplar) ──
940
941/// Moonshot endpoint variants. Operators pick the region that matches their
942/// account; the runtime resolves the URI from the chosen variant unless
943/// overridden by `base.uri`. Code variant is intl-only.
944#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
945#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
946#[serde(rename_all = "snake_case")]
947pub enum MoonshotEndpoint {
948    /// Mainland China endpoint.
949    Cn,
950    /// International endpoint.
951    #[default]
952    Intl,
953    /// Code-specialist endpoint (intl).
954    Code,
955}
956
957impl ModelEndpoint for MoonshotEndpoint {
958    fn uri(&self) -> &'static str {
959        match self {
960            Self::Cn => "https://api.moonshot.cn/v1",
961            Self::Intl => "https://api.moonshot.ai/v1",
962            Self::Code => "https://api.moonshot.cn/coder/v1",
963        }
964    }
965}
966
967/// Moonshot model model_provider config. The `endpoint` field is required (no
968/// implicit default) — operators must pick a region explicitly. Migration
969/// fills it in from collapsed `moonshot-cn` / `moonshot-intl` outer keys.
970#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
971#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
972#[prefix = "providers.models.moonshot"]
973pub struct MoonshotModelProviderConfig {
974    #[nested]
975    #[serde(flatten)]
976    pub base: ModelProviderConfig,
977    /// Required: pick `cn`, `intl`, or `code`. Defaults to `intl` when omitted
978    /// to ease transition; operators on the China endpoint should set
979    /// `endpoint = "cn"` explicitly.
980    #[serde(default)]
981    pub endpoint: MoonshotEndpoint,
982}
983
984impl FamilyEndpoint for MoonshotModelProviderConfig {
985    fn endpoint_uri(&self) -> Option<&'static str> {
986        Some(self.endpoint.uri())
987    }
988}
989
990// ── Qwen (multi-region + auth_mode exemplar) ──
991
992/// Qwen endpoint variants. Operators pick the region matching their account.
993#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
994#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
995#[serde(rename_all = "snake_case")]
996pub enum QwenEndpoint {
997    /// Mainland China (DashScope).
998    Cn,
999    /// International (alicloud international).
1000    #[default]
1001    Intl,
1002    /// United States (DashScope US).
1003    Us,
1004    /// Code-specialist endpoint.
1005    Code,
1006}
1007
1008impl ModelEndpoint for QwenEndpoint {
1009    fn uri(&self) -> &'static str {
1010        match self {
1011            Self::Cn => "https://dashscope.aliyuncs.com/compatible-mode/v1",
1012            Self::Intl => "https://dashscope-intl.aliyuncs.com/compatible-mode/v1",
1013            Self::Us => "https://dashscope-us.aliyuncs.com/compatible-mode/v1",
1014            Self::Code => {
1015                "https://dashscope.aliyuncs.com/api/v1/services/aigc/text-generation/generation"
1016            }
1017        }
1018    }
1019}
1020
1021/// Qwen model model_provider config. Multi-region (`endpoint` required) and
1022/// supports both API key and OAuth flows (`auth_mode` chooses which).
1023#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1024#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1025#[prefix = "providers.models.qwen"]
1026pub struct QwenModelProviderConfig {
1027    #[nested]
1028    #[serde(flatten)]
1029    pub base: ModelProviderConfig,
1030    #[serde(default)]
1031    pub endpoint: QwenEndpoint,
1032    /// Auth flow. Defaults to `api_key`; set to `oauth` to use the vendor's
1033    /// OAuth-cache integration instead of the `api_key` field.
1034    #[serde(default, skip_serializing_if = "Option::is_none")]
1035    pub auth_mode: Option<AuthMode>,
1036    /// Long-lived Qwen OAuth refresh token. When set, the runtime
1037    /// exchanges it for a short-lived access token at provider
1038    /// construction time. Operators relying on the upstream `qwen login`
1039    /// tool (which writes `~/.qwen/oauth_creds.json`) leave this unset;
1040    /// the file-cache integration takes over.
1041    #[serde(default, skip_serializing_if = "Option::is_none")]
1042    #[secret(category = "model_provider")]
1043    pub oauth_refresh_token: Option<String>,
1044    /// Override of Qwen's published OAuth client_id. Most operators
1045    /// should leave this unset.
1046    #[serde(default, skip_serializing_if = "Option::is_none")]
1047    pub oauth_client_id: Option<String>,
1048    /// Operator override of the resource URL the refreshed access token
1049    /// is paired with. When unset, the runtime falls back to the
1050    /// `endpoint`-derived URL (or the cached `resource_url` when reading
1051    /// from `~/.qwen/oauth_creds.json`).
1052    #[serde(default, skip_serializing_if = "Option::is_none")]
1053    pub oauth_resource_url: Option<String>,
1054}
1055
1056impl FamilyEndpoint for QwenModelProviderConfig {
1057    fn endpoint_uri(&self) -> Option<&'static str> {
1058        Some(self.endpoint.uri())
1059    }
1060}
1061
1062// ── OpenRouter ──
1063
1064#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1065#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1066#[serde(rename_all = "snake_case")]
1067pub enum OpenRouterEndpoint {
1068    #[default]
1069    Default,
1070}
1071
1072impl ModelEndpoint for OpenRouterEndpoint {
1073    fn uri(&self) -> &'static str {
1074        match self {
1075            Self::Default => "https://openrouter.ai/api/v1",
1076        }
1077    }
1078}
1079
1080#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1081#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1082#[prefix = "providers.models.openrouter"]
1083pub struct OpenRouterModelProviderConfig {
1084    #[nested]
1085    #[serde(flatten)]
1086    pub base: ModelProviderConfig,
1087}
1088
1089// ── Ollama (local-default endpoint) ──
1090
1091#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1092#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1093#[serde(rename_all = "snake_case")]
1094pub enum OllamaEndpoint {
1095    #[default]
1096    LocalDefault,
1097}
1098
1099impl ModelEndpoint for OllamaEndpoint {
1100    fn uri(&self) -> &'static str {
1101        match self {
1102            Self::LocalDefault => "http://localhost:11434",
1103        }
1104    }
1105}
1106
1107#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1108#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1109#[prefix = "providers.models.ollama"]
1110pub struct OllamaModelProviderConfig {
1111    #[nested]
1112    #[serde(flatten)]
1113    pub base: ModelProviderConfig,
1114    /// Override the Ollama `num_ctx` (context window, in tokens) sent on
1115    /// every `/api/chat` request. Defaults to the framework constant
1116    /// (`OLLAMA_DEFAULT_NUM_CTX`) when unset.
1117    #[serde(default, skip_serializing_if = "Option::is_none")]
1118    pub num_ctx: Option<u32>,
1119    /// Override the Ollama `num_predict` (max output tokens) sent on every
1120    /// `/api/chat` request. Defaults to the framework constant
1121    /// (`OLLAMA_DEFAULT_NUM_PREDICT`) when unset.
1122    #[serde(default, skip_serializing_if = "Option::is_none")]
1123    pub num_predict: Option<i32>,
1124    /// Force every Ollama `/api/chat` request to use this temperature,
1125    /// overriding the per-call value passed through
1126    /// `ModelProvider::chat_with_system(.., temperature)`. When unset
1127    /// (`None`, the default), the per-call temperature wins: full
1128    /// backward compatibility.
1129    #[serde(default, skip_serializing_if = "Option::is_none")]
1130    pub temperature_override: Option<f64>,
1131}
1132
1133// ── Together ──
1134
1135#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1136#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1137#[serde(rename_all = "snake_case")]
1138pub enum TogetherEndpoint {
1139    #[default]
1140    Default,
1141}
1142
1143impl ModelEndpoint for TogetherEndpoint {
1144    fn uri(&self) -> &'static str {
1145        match self {
1146            Self::Default => "https://api.together.xyz/v1",
1147        }
1148    }
1149}
1150
1151#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1152#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1153#[prefix = "providers.models.together"]
1154pub struct TogetherModelProviderConfig {
1155    #[nested]
1156    #[serde(flatten)]
1157    pub base: ModelProviderConfig,
1158}
1159
1160// ── Fireworks ──
1161
1162#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1163#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1164#[serde(rename_all = "snake_case")]
1165pub enum FireworksEndpoint {
1166    #[default]
1167    Default,
1168}
1169
1170impl ModelEndpoint for FireworksEndpoint {
1171    fn uri(&self) -> &'static str {
1172        match self {
1173            Self::Default => "https://api.fireworks.ai/inference/v1",
1174        }
1175    }
1176}
1177
1178#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1179#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1180#[prefix = "providers.models.fireworks"]
1181pub struct FireworksModelProviderConfig {
1182    #[nested]
1183    #[serde(flatten)]
1184    pub base: ModelProviderConfig,
1185}
1186
1187// ── Groq ──
1188
1189#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1190#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1191#[serde(rename_all = "snake_case")]
1192pub enum GroqEndpoint {
1193    #[default]
1194    Default,
1195}
1196
1197impl ModelEndpoint for GroqEndpoint {
1198    fn uri(&self) -> &'static str {
1199        match self {
1200            Self::Default => "https://api.groq.com/openai/v1",
1201        }
1202    }
1203}
1204
1205#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1206#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1207#[prefix = "providers.models.groq"]
1208pub struct GroqModelProviderConfig {
1209    #[nested]
1210    #[serde(flatten)]
1211    pub base: ModelProviderConfig,
1212}
1213
1214// ── Mistral ──
1215
1216#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1217#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1218#[serde(rename_all = "snake_case")]
1219pub enum MistralEndpoint {
1220    #[default]
1221    Default,
1222}
1223
1224impl ModelEndpoint for MistralEndpoint {
1225    fn uri(&self) -> &'static str {
1226        match self {
1227            Self::Default => "https://api.mistral.ai/v1",
1228        }
1229    }
1230}
1231
1232#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1233#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1234#[prefix = "providers.models.mistral"]
1235pub struct MistralModelProviderConfig {
1236    #[nested]
1237    #[serde(flatten)]
1238    pub base: ModelProviderConfig,
1239}
1240
1241// ── Atomic Chat (local OpenAI-compatible runtime, e.g. Jan) ──
1242
1243#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1244#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1245#[serde(rename_all = "snake_case")]
1246pub enum AtomicChatEndpoint {
1247    #[default]
1248    Default,
1249}
1250
1251impl ModelEndpoint for AtomicChatEndpoint {
1252    fn uri(&self) -> &'static str {
1253        match self {
1254            Self::Default => "http://127.0.0.1:1337/v1",
1255        }
1256    }
1257}
1258
1259#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1260#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1261#[prefix = "providers.models.atomic_chat"]
1262pub struct AtomicChatModelProviderConfig {
1263    #[nested]
1264    #[serde(flatten)]
1265    pub base: ModelProviderConfig,
1266}
1267
1268// ── DeepSeek ──
1269
1270#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1271#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1272#[serde(rename_all = "snake_case")]
1273pub enum DeepseekEndpoint {
1274    #[default]
1275    Default,
1276}
1277
1278impl ModelEndpoint for DeepseekEndpoint {
1279    fn uri(&self) -> &'static str {
1280        match self {
1281            Self::Default => "https://api.deepseek.com/v1",
1282        }
1283    }
1284}
1285
1286#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1287#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1288#[prefix = "providers.models.deepseek"]
1289pub struct DeepseekModelProviderConfig {
1290    #[nested]
1291    #[serde(flatten)]
1292    pub base: ModelProviderConfig,
1293}
1294
1295// ── Cohere ──
1296
1297#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1298#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1299#[serde(rename_all = "snake_case")]
1300pub enum CohereEndpoint {
1301    #[default]
1302    Default,
1303}
1304
1305impl ModelEndpoint for CohereEndpoint {
1306    fn uri(&self) -> &'static str {
1307        match self {
1308            Self::Default => "https://api.cohere.ai/compatibility/v1",
1309        }
1310    }
1311}
1312
1313#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1314#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1315#[prefix = "providers.models.cohere"]
1316pub struct CohereModelProviderConfig {
1317    #[nested]
1318    #[serde(flatten)]
1319    pub base: ModelProviderConfig,
1320}
1321
1322// ── Perplexity ──
1323
1324#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1325#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1326#[serde(rename_all = "snake_case")]
1327pub enum PerplexityEndpoint {
1328    #[default]
1329    Default,
1330}
1331
1332impl ModelEndpoint for PerplexityEndpoint {
1333    fn uri(&self) -> &'static str {
1334        match self {
1335            Self::Default => "https://api.perplexity.ai",
1336        }
1337    }
1338}
1339
1340#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1341#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1342#[prefix = "providers.models.perplexity"]
1343pub struct PerplexityModelProviderConfig {
1344    #[nested]
1345    #[serde(flatten)]
1346    pub base: ModelProviderConfig,
1347}
1348
1349// ── xAI (Grok) ──
1350
1351#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1352#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1353#[serde(rename_all = "snake_case")]
1354pub enum XaiEndpoint {
1355    #[default]
1356    Default,
1357}
1358
1359impl ModelEndpoint for XaiEndpoint {
1360    fn uri(&self) -> &'static str {
1361        match self {
1362            Self::Default => "https://api.x.ai/v1",
1363        }
1364    }
1365}
1366
1367#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1368#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1369#[prefix = "providers.models.xai"]
1370pub struct XaiModelProviderConfig {
1371    #[nested]
1372    #[serde(flatten)]
1373    pub base: ModelProviderConfig,
1374}
1375
1376// ── Cerebras ──
1377
1378#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1379#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1380#[serde(rename_all = "snake_case")]
1381pub enum CerebrasEndpoint {
1382    #[default]
1383    Default,
1384}
1385
1386impl ModelEndpoint for CerebrasEndpoint {
1387    fn uri(&self) -> &'static str {
1388        match self {
1389            Self::Default => "https://api.cerebras.ai/v1",
1390        }
1391    }
1392}
1393
1394#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1395#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1396#[prefix = "providers.models.cerebras"]
1397pub struct CerebrasModelProviderConfig {
1398    #[nested]
1399    #[serde(flatten)]
1400    pub base: ModelProviderConfig,
1401}
1402
1403// ── SambaNova ──
1404
1405#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1406#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1407#[serde(rename_all = "snake_case")]
1408pub enum SambanovaEndpoint {
1409    #[default]
1410    Default,
1411}
1412
1413impl ModelEndpoint for SambanovaEndpoint {
1414    fn uri(&self) -> &'static str {
1415        match self {
1416            Self::Default => "https://api.sambanova.ai/v1",
1417        }
1418    }
1419}
1420
1421#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1422#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1423#[prefix = "providers.models.sambanova"]
1424pub struct SambanovaModelProviderConfig {
1425    #[nested]
1426    #[serde(flatten)]
1427    pub base: ModelProviderConfig,
1428}
1429
1430// ── Hyperbolic ──
1431
1432#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1433#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1434#[serde(rename_all = "snake_case")]
1435pub enum HyperbolicEndpoint {
1436    #[default]
1437    Default,
1438}
1439
1440impl ModelEndpoint for HyperbolicEndpoint {
1441    fn uri(&self) -> &'static str {
1442        match self {
1443            Self::Default => "https://api.hyperbolic.xyz/v1",
1444        }
1445    }
1446}
1447
1448#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1449#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1450#[prefix = "providers.models.hyperbolic"]
1451pub struct HyperbolicModelProviderConfig {
1452    #[nested]
1453    #[serde(flatten)]
1454    pub base: ModelProviderConfig,
1455}
1456
1457// ── DeepInfra ──
1458
1459#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1460#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1461#[serde(rename_all = "snake_case")]
1462pub enum DeepinfraEndpoint {
1463    #[default]
1464    Default,
1465}
1466
1467impl ModelEndpoint for DeepinfraEndpoint {
1468    fn uri(&self) -> &'static str {
1469        match self {
1470            Self::Default => "https://api.deepinfra.com/v1/openai",
1471        }
1472    }
1473}
1474
1475#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1476#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1477#[prefix = "providers.models.deepinfra"]
1478pub struct DeepinfraModelProviderConfig {
1479    #[nested]
1480    #[serde(flatten)]
1481    pub base: ModelProviderConfig,
1482}
1483
1484// ── Hugging Face ──
1485
1486#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1487#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1488#[serde(rename_all = "snake_case")]
1489pub enum HuggingfaceEndpoint {
1490    #[default]
1491    Default,
1492}
1493
1494impl ModelEndpoint for HuggingfaceEndpoint {
1495    fn uri(&self) -> &'static str {
1496        match self {
1497            Self::Default => "https://router.huggingface.co/v1",
1498        }
1499    }
1500}
1501
1502#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1503#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1504#[prefix = "providers.models.huggingface"]
1505pub struct HuggingfaceModelProviderConfig {
1506    #[nested]
1507    #[serde(flatten)]
1508    pub base: ModelProviderConfig,
1509}
1510
1511// ── AI21 ──
1512
1513#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1514#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1515#[serde(rename_all = "snake_case")]
1516pub enum Ai21Endpoint {
1517    #[default]
1518    Default,
1519}
1520impl ModelEndpoint for Ai21Endpoint {
1521    fn uri(&self) -> &'static str {
1522        match self {
1523            Self::Default => "https://api.ai21.com/studio/v1",
1524        }
1525    }
1526}
1527#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1528#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1529#[prefix = "providers.models.ai21"]
1530pub struct Ai21ModelProviderConfig {
1531    #[nested]
1532    #[serde(flatten)]
1533    pub base: ModelProviderConfig,
1534}
1535
1536// ── Reka ──
1537
1538#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1539#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1540#[serde(rename_all = "snake_case")]
1541pub enum RekaEndpoint {
1542    #[default]
1543    Default,
1544}
1545impl ModelEndpoint for RekaEndpoint {
1546    fn uri(&self) -> &'static str {
1547        match self {
1548            Self::Default => "https://api.reka.ai/v1",
1549        }
1550    }
1551}
1552#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1553#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1554#[prefix = "providers.models.reka"]
1555pub struct RekaModelProviderConfig {
1556    #[nested]
1557    #[serde(flatten)]
1558    pub base: ModelProviderConfig,
1559}
1560
1561// ── BaseTen ──
1562
1563#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1564#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1565#[serde(rename_all = "snake_case")]
1566pub enum BasetenEndpoint {
1567    #[default]
1568    Default,
1569}
1570impl ModelEndpoint for BasetenEndpoint {
1571    fn uri(&self) -> &'static str {
1572        match self {
1573            Self::Default => "https://inference.baseten.co/v1",
1574        }
1575    }
1576}
1577#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1578#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1579#[prefix = "providers.models.baseten"]
1580pub struct BasetenModelProviderConfig {
1581    #[nested]
1582    #[serde(flatten)]
1583    pub base: ModelProviderConfig,
1584}
1585
1586// ── NScale ──
1587
1588#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1589#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1590#[serde(rename_all = "snake_case")]
1591pub enum NscaleEndpoint {
1592    #[default]
1593    Default,
1594}
1595impl ModelEndpoint for NscaleEndpoint {
1596    fn uri(&self) -> &'static str {
1597        match self {
1598            Self::Default => "https://inference.api.nscale.com/v1",
1599        }
1600    }
1601}
1602#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1603#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1604#[prefix = "providers.models.nscale"]
1605pub struct NscaleModelProviderConfig {
1606    #[nested]
1607    #[serde(flatten)]
1608    pub base: ModelProviderConfig,
1609}
1610
1611// ── AnyScale ──
1612
1613#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1614#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1615#[serde(rename_all = "snake_case")]
1616pub enum AnyscaleEndpoint {
1617    #[default]
1618    Default,
1619}
1620impl ModelEndpoint for AnyscaleEndpoint {
1621    fn uri(&self) -> &'static str {
1622        match self {
1623            Self::Default => "https://api.endpoints.anyscale.com/v1",
1624        }
1625    }
1626}
1627#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1628#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1629#[prefix = "providers.models.anyscale"]
1630pub struct AnyscaleModelProviderConfig {
1631    #[nested]
1632    #[serde(flatten)]
1633    pub base: ModelProviderConfig,
1634}
1635
1636// ── Nebius ──
1637
1638#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1639#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1640#[serde(rename_all = "snake_case")]
1641pub enum NebiusEndpoint {
1642    #[default]
1643    Default,
1644}
1645impl ModelEndpoint for NebiusEndpoint {
1646    fn uri(&self) -> &'static str {
1647        match self {
1648            Self::Default => "https://api.studio.nebius.ai/v1",
1649        }
1650    }
1651}
1652#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1653#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1654#[prefix = "providers.models.nebius"]
1655pub struct NebiusModelProviderConfig {
1656    #[nested]
1657    #[serde(flatten)]
1658    pub base: ModelProviderConfig,
1659}
1660
1661// ── Friendli ──
1662
1663#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1664#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1665#[serde(rename_all = "snake_case")]
1666pub enum FriendliEndpoint {
1667    #[default]
1668    Default,
1669}
1670impl ModelEndpoint for FriendliEndpoint {
1671    fn uri(&self) -> &'static str {
1672        match self {
1673            Self::Default => "https://api.friendli.ai/serverless/v1",
1674        }
1675    }
1676}
1677#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1678#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1679#[prefix = "providers.models.friendli"]
1680pub struct FriendliModelProviderConfig {
1681    #[nested]
1682    #[serde(flatten)]
1683    pub base: ModelProviderConfig,
1684}
1685
1686// ── Stepfun ──
1687
1688#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1689#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1690#[serde(rename_all = "snake_case")]
1691pub enum StepfunEndpoint {
1692    /// Mainland China endpoint.
1693    Cn,
1694    /// International endpoint.
1695    #[default]
1696    Intl,
1697}
1698impl ModelEndpoint for StepfunEndpoint {
1699    fn uri(&self) -> &'static str {
1700        match self {
1701            Self::Cn => "https://api.stepfun.com/v1",
1702            Self::Intl => "https://api.stepfun.ai/v1",
1703        }
1704    }
1705}
1706#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1707#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1708#[prefix = "providers.models.stepfun"]
1709pub struct StepfunModelProviderConfig {
1710    #[nested]
1711    #[serde(flatten)]
1712    pub base: ModelProviderConfig,
1713    #[serde(default)]
1714    pub endpoint: StepfunEndpoint,
1715}
1716
1717impl FamilyEndpoint for StepfunModelProviderConfig {
1718    fn endpoint_uri(&self) -> Option<&'static str> {
1719        Some(self.endpoint.uri())
1720    }
1721}
1722
1723// ── AIHubMix ──
1724
1725#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1726#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1727#[serde(rename_all = "snake_case")]
1728pub enum AihubmixEndpoint {
1729    #[default]
1730    Default,
1731}
1732impl ModelEndpoint for AihubmixEndpoint {
1733    fn uri(&self) -> &'static str {
1734        match self {
1735            Self::Default => "https://aihubmix.com/v1",
1736        }
1737    }
1738}
1739#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1740#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1741#[prefix = "providers.models.aihubmix"]
1742pub struct AihubmixModelProviderConfig {
1743    #[nested]
1744    #[serde(flatten)]
1745    pub base: ModelProviderConfig,
1746}
1747
1748// ── SiliconFlow ──
1749
1750#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1751#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1752#[serde(rename_all = "snake_case")]
1753pub enum SiliconflowEndpoint {
1754    #[default]
1755    Default,
1756}
1757impl ModelEndpoint for SiliconflowEndpoint {
1758    fn uri(&self) -> &'static str {
1759        match self {
1760            Self::Default => "https://api.siliconflow.com/v1",
1761        }
1762    }
1763}
1764#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1765#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1766#[prefix = "providers.models.siliconflow"]
1767pub struct SiliconflowModelProviderConfig {
1768    #[nested]
1769    #[serde(flatten)]
1770    pub base: ModelProviderConfig,
1771}
1772
1773// ── Astrai ──
1774
1775#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1776#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1777#[serde(rename_all = "snake_case")]
1778pub enum AstraiEndpoint {
1779    #[default]
1780    Default,
1781}
1782impl ModelEndpoint for AstraiEndpoint {
1783    fn uri(&self) -> &'static str {
1784        match self {
1785            Self::Default => "https://as-trai.com/v1",
1786        }
1787    }
1788}
1789#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1790#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1791#[prefix = "providers.models.astrai"]
1792pub struct AstraiModelProviderConfig {
1793    #[nested]
1794    #[serde(flatten)]
1795    pub base: ModelProviderConfig,
1796}
1797
1798// ── Avian ──
1799
1800#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1801#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1802#[serde(rename_all = "snake_case")]
1803pub enum AvianEndpoint {
1804    #[default]
1805    Default,
1806}
1807impl ModelEndpoint for AvianEndpoint {
1808    fn uri(&self) -> &'static str {
1809        match self {
1810            Self::Default => "https://api.avian.io/v1",
1811        }
1812    }
1813}
1814#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1815#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1816#[prefix = "providers.models.avian"]
1817pub struct AvianModelProviderConfig {
1818    #[nested]
1819    #[serde(flatten)]
1820    pub base: ModelProviderConfig,
1821}
1822
1823// ── DeepMyst ──
1824
1825#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1826#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1827#[serde(rename_all = "snake_case")]
1828pub enum DeepmystEndpoint {
1829    #[default]
1830    Default,
1831}
1832impl ModelEndpoint for DeepmystEndpoint {
1833    fn uri(&self) -> &'static str {
1834        match self {
1835            Self::Default => "https://api.deepmyst.com/v1",
1836        }
1837    }
1838}
1839#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1840#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1841#[prefix = "providers.models.deepmyst"]
1842pub struct DeepmystModelProviderConfig {
1843    #[nested]
1844    #[serde(flatten)]
1845    pub base: ModelProviderConfig,
1846}
1847
1848// ── Venice ──
1849
1850#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1851#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1852#[serde(rename_all = "snake_case")]
1853pub enum VeniceEndpoint {
1854    #[default]
1855    Default,
1856}
1857impl ModelEndpoint for VeniceEndpoint {
1858    fn uri(&self) -> &'static str {
1859        match self {
1860            Self::Default => "https://api.venice.ai",
1861        }
1862    }
1863}
1864#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1865#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1866#[prefix = "providers.models.venice"]
1867pub struct VeniceModelProviderConfig {
1868    #[nested]
1869    #[serde(flatten)]
1870    pub base: ModelProviderConfig,
1871}
1872
1873// ── Novita ──
1874
1875#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1876#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1877#[serde(rename_all = "snake_case")]
1878pub enum NovitaEndpoint {
1879    #[default]
1880    Default,
1881}
1882impl ModelEndpoint for NovitaEndpoint {
1883    fn uri(&self) -> &'static str {
1884        match self {
1885            Self::Default => "https://api.novita.ai/openai",
1886        }
1887    }
1888}
1889#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1890#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1891#[prefix = "providers.models.novita"]
1892pub struct NovitaModelProviderConfig {
1893    #[nested]
1894    #[serde(flatten)]
1895    pub base: ModelProviderConfig,
1896}
1897
1898// ── NVIDIA ──
1899
1900#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1901#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1902#[serde(rename_all = "snake_case")]
1903pub enum NvidiaEndpoint {
1904    #[default]
1905    Default,
1906}
1907impl ModelEndpoint for NvidiaEndpoint {
1908    fn uri(&self) -> &'static str {
1909        match self {
1910            Self::Default => "https://integrate.api.nvidia.com/v1",
1911        }
1912    }
1913}
1914#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1915#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1916#[prefix = "providers.models.nvidia"]
1917pub struct NvidiaModelProviderConfig {
1918    #[nested]
1919    #[serde(flatten)]
1920    pub base: ModelProviderConfig,
1921}
1922
1923// ── Telnyx ──
1924
1925#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1926#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1927#[serde(rename_all = "snake_case")]
1928pub enum TelnyxEndpoint {
1929    #[default]
1930    Default,
1931}
1932impl ModelEndpoint for TelnyxEndpoint {
1933    fn uri(&self) -> &'static str {
1934        match self {
1935            Self::Default => "https://api.telnyx.com/v2",
1936        }
1937    }
1938}
1939#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1940#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1941#[prefix = "providers.models.telnyx"]
1942pub struct TelnyxModelProviderConfig {
1943    #[nested]
1944    #[serde(flatten)]
1945    pub base: ModelProviderConfig,
1946}
1947
1948// ── Vercel AI Gateway ──
1949
1950#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1951#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1952#[serde(rename_all = "snake_case")]
1953pub enum VercelEndpoint {
1954    #[default]
1955    Default,
1956}
1957impl ModelEndpoint for VercelEndpoint {
1958    fn uri(&self) -> &'static str {
1959        match self {
1960            Self::Default => "https://ai-gateway.vercel.sh/v1",
1961        }
1962    }
1963}
1964#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1965#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1966#[prefix = "providers.models.vercel"]
1967pub struct VercelModelProviderConfig {
1968    #[nested]
1969    #[serde(flatten)]
1970    pub base: ModelProviderConfig,
1971}
1972
1973// ── Cloudflare AI Gateway ──
1974
1975#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1976#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1977#[serde(rename_all = "snake_case")]
1978pub enum CloudflareEndpoint {
1979    #[default]
1980    Default,
1981}
1982impl ModelEndpoint for CloudflareEndpoint {
1983    fn uri(&self) -> &'static str {
1984        match self {
1985            Self::Default => "https://gateway.ai.cloudflare.com/v1",
1986        }
1987    }
1988}
1989#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
1990#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
1991#[prefix = "providers.models.cloudflare"]
1992pub struct CloudflareModelProviderConfig {
1993    #[nested]
1994    #[serde(flatten)]
1995    pub base: ModelProviderConfig,
1996}
1997
1998// ── OVH ──
1999
2000#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2001#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2002#[serde(rename_all = "snake_case")]
2003pub enum OvhEndpoint {
2004    #[default]
2005    Default,
2006}
2007impl ModelEndpoint for OvhEndpoint {
2008    fn uri(&self) -> &'static str {
2009        match self {
2010            Self::Default => "https://oai.endpoints.kepler.ai.cloud.ovh.net/v1",
2011        }
2012    }
2013}
2014#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2015#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2016#[prefix = "providers.models.ovh"]
2017pub struct OvhModelProviderConfig {
2018    #[nested]
2019    #[serde(flatten)]
2020    pub base: ModelProviderConfig,
2021}
2022
2023// ── GitHub Copilot ──
2024
2025#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2026#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2027#[serde(rename_all = "snake_case")]
2028pub enum CopilotEndpoint {
2029    #[default]
2030    Default,
2031}
2032impl ModelEndpoint for CopilotEndpoint {
2033    fn uri(&self) -> &'static str {
2034        match self {
2035            Self::Default => "https://api.githubcopilot.com",
2036        }
2037    }
2038}
2039#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2040#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2041#[prefix = "providers.models.copilot"]
2042pub struct CopilotModelProviderConfig {
2043    #[nested]
2044    #[serde(flatten)]
2045    pub base: ModelProviderConfig,
2046}
2047
2048// ── GLM (multi-region) ──
2049
2050#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2051#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2052#[serde(rename_all = "snake_case")]
2053pub enum GlmEndpoint {
2054    Cn,
2055    #[default]
2056    Global,
2057}
2058impl ModelEndpoint for GlmEndpoint {
2059    fn uri(&self) -> &'static str {
2060        match self {
2061            Self::Cn => "https://open.bigmodel.cn/api/paas/v4",
2062            Self::Global => "https://api.z.ai/api/paas/v4",
2063        }
2064    }
2065}
2066#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2067#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2068#[prefix = "providers.models.glm"]
2069pub struct GlmModelProviderConfig {
2070    #[nested]
2071    #[serde(flatten)]
2072    pub base: ModelProviderConfig,
2073    #[serde(default)]
2074    pub endpoint: GlmEndpoint,
2075}
2076
2077impl FamilyEndpoint for GlmModelProviderConfig {
2078    fn endpoint_uri(&self) -> Option<&'static str> {
2079        Some(self.endpoint.uri())
2080    }
2081}
2082
2083// ── Minimax (multi-region + auth_mode) ──
2084
2085#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2086#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2087#[serde(rename_all = "snake_case")]
2088pub enum MinimaxEndpoint {
2089    Cn,
2090    #[default]
2091    Intl,
2092}
2093impl ModelEndpoint for MinimaxEndpoint {
2094    fn uri(&self) -> &'static str {
2095        match self {
2096            Self::Cn => "https://api.minimaxi.com/v1",
2097            Self::Intl => "https://api.minimax.io/v1",
2098        }
2099    }
2100}
2101
2102impl MinimaxEndpoint {
2103    /// OAuth `/oauth/token` endpoint for this region. Used by
2104    /// `refresh_minimax_oauth_access_token` to mint short-lived access
2105    /// tokens from the operator-supplied `oauth_refresh_token`.
2106    pub fn oauth_token_endpoint(self) -> &'static str {
2107        match self {
2108            Self::Cn => "https://api.minimaxi.com/oauth/token",
2109            Self::Intl => "https://api.minimax.io/oauth/token",
2110        }
2111    }
2112}
2113
2114#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2115#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2116#[prefix = "providers.models.minimax"]
2117pub struct MinimaxModelProviderConfig {
2118    #[nested]
2119    #[serde(flatten)]
2120    pub base: ModelProviderConfig,
2121    #[serde(default)]
2122    pub endpoint: MinimaxEndpoint,
2123    #[serde(default, skip_serializing_if = "Option::is_none")]
2124    pub auth_mode: Option<AuthMode>,
2125    /// Long-lived OAuth refresh token issued by MiniMax. When set, the
2126    /// runtime exchanges it for a short-lived access token at provider
2127    /// construction time and uses that as the API credential. Operators
2128    /// who prefer dashboard-generated long-lived API keys can leave this
2129    /// unset and populate `api_key` directly.
2130    #[serde(default, skip_serializing_if = "Option::is_none")]
2131    #[secret(category = "model_provider")]
2132    pub oauth_refresh_token: Option<String>,
2133    /// Override of MiniMax's published OAuth client_id. Most operators
2134    /// should leave this unset; the runtime defaults to the
2135    /// vendor-published client_id (same one MiniMax's own portal uses).
2136    #[serde(default, skip_serializing_if = "Option::is_none")]
2137    pub oauth_client_id: Option<String>,
2138}
2139
2140impl FamilyEndpoint for MinimaxModelProviderConfig {
2141    fn endpoint_uri(&self) -> Option<&'static str> {
2142        Some(self.endpoint.uri())
2143    }
2144}
2145
2146// ── Z.AI (multi-region) ──
2147
2148#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2149#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2150#[serde(rename_all = "snake_case")]
2151pub enum ZaiEndpoint {
2152    Cn,
2153    #[default]
2154    Global,
2155}
2156impl ModelEndpoint for ZaiEndpoint {
2157    fn uri(&self) -> &'static str {
2158        match self {
2159            Self::Cn => "https://open.bigmodel.cn/api/coding/paas/v4",
2160            Self::Global => "https://api.z.ai/api/coding/paas/v4",
2161        }
2162    }
2163}
2164#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2165#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2166#[prefix = "providers.models.zai"]
2167pub struct ZaiModelProviderConfig {
2168    #[nested]
2169    #[serde(flatten)]
2170    pub base: ModelProviderConfig,
2171    #[serde(default)]
2172    pub endpoint: ZaiEndpoint,
2173}
2174
2175impl FamilyEndpoint for ZaiModelProviderConfig {
2176    fn endpoint_uri(&self) -> Option<&'static str> {
2177        Some(self.endpoint.uri())
2178    }
2179}
2180
2181// ── Doubao (Volcengine; single canonical endpoint) ──
2182
2183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2184#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2185#[serde(rename_all = "snake_case")]
2186pub enum DoubaoEndpoint {
2187    #[default]
2188    Default,
2189}
2190impl ModelEndpoint for DoubaoEndpoint {
2191    fn uri(&self) -> &'static str {
2192        match self {
2193            Self::Default => "https://ark.cn-beijing.volces.com/api/v3",
2194        }
2195    }
2196}
2197#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2198#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2199#[prefix = "providers.models.doubao"]
2200pub struct DoubaoModelProviderConfig {
2201    #[nested]
2202    #[serde(flatten)]
2203    pub base: ModelProviderConfig,
2204}
2205
2206// ── Yi (Lingyiwanwu; single endpoint) ──
2207
2208#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2209#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2210#[serde(rename_all = "snake_case")]
2211pub enum YiEndpoint {
2212    #[default]
2213    Default,
2214}
2215impl ModelEndpoint for YiEndpoint {
2216    fn uri(&self) -> &'static str {
2217        match self {
2218            Self::Default => "https://api.lingyiwanwu.com/v1",
2219        }
2220    }
2221}
2222#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2223#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2224#[prefix = "providers.models.yi"]
2225pub struct YiModelProviderConfig {
2226    #[nested]
2227    #[serde(flatten)]
2228    pub base: ModelProviderConfig,
2229}
2230
2231// ── Hunyuan (Tencent; single endpoint) ──
2232
2233#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2234#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2235#[serde(rename_all = "snake_case")]
2236pub enum HunyuanEndpoint {
2237    #[default]
2238    Default,
2239}
2240impl ModelEndpoint for HunyuanEndpoint {
2241    fn uri(&self) -> &'static str {
2242        match self {
2243            Self::Default => "https://api.hunyuan.cloud.tencent.com/v1",
2244        }
2245    }
2246}
2247#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2248#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2249#[prefix = "providers.models.hunyuan"]
2250pub struct HunyuanModelProviderConfig {
2251    #[nested]
2252    #[serde(flatten)]
2253    pub base: ModelProviderConfig,
2254}
2255
2256// ── Qianfan (Baidu) ──
2257
2258#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2259#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2260#[serde(rename_all = "snake_case")]
2261pub enum QianfanEndpoint {
2262    #[default]
2263    Default,
2264}
2265impl ModelEndpoint for QianfanEndpoint {
2266    fn uri(&self) -> &'static str {
2267        match self {
2268            Self::Default => "https://qianfan.baidubce.com/v2",
2269        }
2270    }
2271}
2272#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2273#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2274#[prefix = "providers.models.qianfan"]
2275pub struct QianfanModelProviderConfig {
2276    #[nested]
2277    #[serde(flatten)]
2278    pub base: ModelProviderConfig,
2279}
2280
2281// ── Baichuan ──
2282
2283#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2284#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2285#[serde(rename_all = "snake_case")]
2286pub enum BaichuanEndpoint {
2287    #[default]
2288    Default,
2289}
2290impl ModelEndpoint for BaichuanEndpoint {
2291    fn uri(&self) -> &'static str {
2292        match self {
2293            Self::Default => "https://api.baichuan-ai.com/v1",
2294        }
2295    }
2296}
2297#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2298#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2299#[prefix = "providers.models.baichuan"]
2300pub struct BaichuanModelProviderConfig {
2301    #[nested]
2302    #[serde(flatten)]
2303    pub base: ModelProviderConfig,
2304}
2305
2306// ── Gemini (OAuth-capable) ──
2307
2308#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2309#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2310#[serde(rename_all = "snake_case")]
2311pub enum GeminiEndpoint {
2312    #[default]
2313    Default,
2314}
2315impl ModelEndpoint for GeminiEndpoint {
2316    fn uri(&self) -> &'static str {
2317        match self {
2318            Self::Default => "https://generativelanguage.googleapis.com/v1beta",
2319        }
2320    }
2321}
2322#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2323#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2324#[prefix = "providers.models.gemini"]
2325pub struct GeminiModelProviderConfig {
2326    #[nested]
2327    #[serde(flatten)]
2328    pub base: ModelProviderConfig,
2329    /// Auth flow. Defaults to `api_key`; `oauth` uses GeminiModelProvider's
2330    /// OAuth-cache integration instead of the `api_key` field.
2331    #[serde(default, skip_serializing_if = "Option::is_none")]
2332    pub auth_mode: Option<AuthMode>,
2333    /// Google OAuth app `client_id`, used when this alias drives ZeroClaw's
2334    /// own browser/device-code login flow (`zeroclaw auth login
2335    /// --model-provider gemini --profile <alias>`). Operators relying on
2336    /// the upstream `gemini login` tool don't need this; that tool writes
2337    /// its own client_id / client_secret into `~/.gemini/oauth_creds.json`.
2338    #[serde(default, skip_serializing_if = "Option::is_none")]
2339    #[secret(category = "model_provider")]
2340    pub oauth_client_id: Option<String>,
2341    /// Google OAuth app `client_secret`. Set alongside `oauth_client_id`.
2342    #[serde(default, skip_serializing_if = "Option::is_none")]
2343    #[secret(category = "model_provider")]
2344    pub oauth_client_secret: Option<String>,
2345    /// Pin a specific GCP project ID for the OAuth `loadCodeAssist`
2346    /// discovery call. When unset, the discovery probes for an
2347    /// already-onboarded project on the credential's account. Replaces
2348    /// `GOOGLE_CLOUD_PROJECT` / `GOOGLE_CLOUD_PROJECT_ID` env vars.
2349    #[serde(default, skip_serializing_if = "Option::is_none")]
2350    pub oauth_project: Option<String>,
2351}
2352
2353// ── Gemini CLI (subprocess wrapper) ──
2354
2355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2356#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2357#[serde(rename_all = "snake_case")]
2358pub enum GeminiCliEndpoint {
2359    #[default]
2360    LocalSubprocess,
2361}
2362impl ModelEndpoint for GeminiCliEndpoint {
2363    fn uri(&self) -> &'static str {
2364        // Subprocess — no remote endpoint. Sentinel for trait conformity.
2365        "subprocess://gemini-cli"
2366    }
2367}
2368#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2369#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2370#[prefix = "providers.models.gemini_cli"]
2371pub struct GeminiCliModelProviderConfig {
2372    #[nested]
2373    #[serde(flatten)]
2374    pub base: ModelProviderConfig,
2375    /// Path to the `gemini` CLI binary. Falls back to `gemini` (PATH lookup).
2376    #[serde(default, skip_serializing_if = "Option::is_none")]
2377    pub binary_path: Option<String>,
2378}
2379
2380// ── LMStudio (local default) ──
2381
2382#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2383#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2384#[serde(rename_all = "snake_case")]
2385pub enum LmstudioEndpoint {
2386    #[default]
2387    LocalDefault,
2388}
2389impl ModelEndpoint for LmstudioEndpoint {
2390    fn uri(&self) -> &'static str {
2391        match self {
2392            Self::LocalDefault => "http://localhost:1234/v1",
2393        }
2394    }
2395}
2396#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2397#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2398#[prefix = "providers.models.lmstudio"]
2399pub struct LmstudioModelProviderConfig {
2400    #[nested]
2401    #[serde(flatten)]
2402    pub base: ModelProviderConfig,
2403}
2404
2405// ── llama.cpp (local default) ──
2406
2407#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2408#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2409#[serde(rename_all = "snake_case")]
2410pub enum LlamacppEndpoint {
2411    #[default]
2412    LocalDefault,
2413}
2414impl ModelEndpoint for LlamacppEndpoint {
2415    fn uri(&self) -> &'static str {
2416        match self {
2417            Self::LocalDefault => "http://localhost:8080/v1",
2418        }
2419    }
2420}
2421#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2422#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2423#[prefix = "providers.models.llamacpp"]
2424pub struct LlamacppModelProviderConfig {
2425    #[nested]
2426    #[serde(flatten)]
2427    pub base: ModelProviderConfig,
2428}
2429
2430// ── SGLang (local default) ──
2431
2432#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2433#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2434#[serde(rename_all = "snake_case")]
2435pub enum SglangEndpoint {
2436    #[default]
2437    LocalDefault,
2438}
2439impl ModelEndpoint for SglangEndpoint {
2440    fn uri(&self) -> &'static str {
2441        match self {
2442            Self::LocalDefault => "http://localhost:30000/v1",
2443        }
2444    }
2445}
2446#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2447#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2448#[prefix = "providers.models.sglang"]
2449pub struct SglangModelProviderConfig {
2450    #[nested]
2451    #[serde(flatten)]
2452    pub base: ModelProviderConfig,
2453}
2454
2455// ── vLLM (local default) ──
2456
2457#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2458#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2459#[serde(rename_all = "snake_case")]
2460pub enum VllmEndpoint {
2461    #[default]
2462    LocalDefault,
2463}
2464impl ModelEndpoint for VllmEndpoint {
2465    fn uri(&self) -> &'static str {
2466        match self {
2467            Self::LocalDefault => "http://localhost:8000/v1",
2468        }
2469    }
2470}
2471#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2472#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2473#[prefix = "providers.models.vllm"]
2474pub struct VllmModelProviderConfig {
2475    #[nested]
2476    #[serde(flatten)]
2477    pub base: ModelProviderConfig,
2478}
2479
2480// ── Osaurus (local default) ──
2481
2482#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2483#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2484#[serde(rename_all = "snake_case")]
2485pub enum OsaurusEndpoint {
2486    #[default]
2487    LocalDefault,
2488}
2489impl ModelEndpoint for OsaurusEndpoint {
2490    fn uri(&self) -> &'static str {
2491        match self {
2492            Self::LocalDefault => "http://localhost:1337/v1",
2493        }
2494    }
2495}
2496#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2497#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2498#[prefix = "providers.models.osaurus"]
2499pub struct OsaurusModelProviderConfig {
2500    #[nested]
2501    #[serde(flatten)]
2502    pub base: ModelProviderConfig,
2503}
2504
2505// ── LiteLLM (operator-self-hosted gateway) ──
2506
2507#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2508#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2509#[serde(rename_all = "snake_case")]
2510pub enum LitellmEndpoint {
2511    #[default]
2512    LocalDefault,
2513}
2514impl ModelEndpoint for LitellmEndpoint {
2515    fn uri(&self) -> &'static str {
2516        match self {
2517            Self::LocalDefault => "http://localhost:4000/v1",
2518        }
2519    }
2520}
2521#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2522#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2523#[prefix = "providers.models.litellm"]
2524pub struct LitellmModelProviderConfig {
2525    #[nested]
2526    #[serde(flatten)]
2527    pub base: ModelProviderConfig,
2528}
2529
2530// ── Lepton ──
2531
2532#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2533#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2534#[serde(rename_all = "snake_case")]
2535pub enum LeptonEndpoint {
2536    #[default]
2537    Default,
2538}
2539impl ModelEndpoint for LeptonEndpoint {
2540    fn uri(&self) -> &'static str {
2541        match self {
2542            Self::Default => "https://llama3-1-405b.lepton.run/api/v1",
2543        }
2544    }
2545}
2546#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2547#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2548#[prefix = "providers.models.lepton"]
2549pub struct LeptonModelProviderConfig {
2550    #[nested]
2551    #[serde(flatten)]
2552    pub base: ModelProviderConfig,
2553}
2554
2555// ── Morph ──
2556
2557#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2558#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2559#[serde(rename_all = "snake_case")]
2560pub enum MorphEndpoint {
2561    #[default]
2562    Default,
2563}
2564impl ModelEndpoint for MorphEndpoint {
2565    fn uri(&self) -> &'static str {
2566        match self {
2567            Self::Default => "https://api.morphllm.com/v1",
2568        }
2569    }
2570}
2571#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2572#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2573#[prefix = "providers.models.morph"]
2574pub struct MorphModelProviderConfig {
2575    #[nested]
2576    #[serde(flatten)]
2577    pub base: ModelProviderConfig,
2578}
2579
2580// ── GitHub Models ──
2581
2582#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2583#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2584#[serde(rename_all = "snake_case")]
2585pub enum GithubModelsEndpoint {
2586    #[default]
2587    Default,
2588}
2589impl ModelEndpoint for GithubModelsEndpoint {
2590    fn uri(&self) -> &'static str {
2591        match self {
2592            Self::Default => "https://models.github.ai/inference",
2593        }
2594    }
2595}
2596#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2597#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2598#[prefix = "providers.models.github_models"]
2599pub struct GithubModelsModelProviderConfig {
2600    #[nested]
2601    #[serde(flatten)]
2602    pub base: ModelProviderConfig,
2603}
2604
2605// ── Upstage ──
2606
2607#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2608#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2609#[serde(rename_all = "snake_case")]
2610pub enum UpstageEndpoint {
2611    #[default]
2612    Default,
2613}
2614impl ModelEndpoint for UpstageEndpoint {
2615    fn uri(&self) -> &'static str {
2616        match self {
2617            Self::Default => "https://api.upstage.ai/v1",
2618        }
2619    }
2620}
2621#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2622#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2623#[prefix = "providers.models.upstage"]
2624pub struct UpstageModelProviderConfig {
2625    #[nested]
2626    #[serde(flatten)]
2627    pub base: ModelProviderConfig,
2628}
2629
2630// ── Featherless ──
2631
2632#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2633#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2634#[serde(rename_all = "snake_case")]
2635pub enum FeatherlessEndpoint {
2636    #[default]
2637    Default,
2638}
2639impl ModelEndpoint for FeatherlessEndpoint {
2640    fn uri(&self) -> &'static str {
2641        match self {
2642            Self::Default => "https://api.featherless.ai/v1",
2643        }
2644    }
2645}
2646#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2647#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2648#[prefix = "providers.models.featherless"]
2649pub struct FeatherlessModelProviderConfig {
2650    #[nested]
2651    #[serde(flatten)]
2652    pub base: ModelProviderConfig,
2653}
2654
2655// ── Arcee ──
2656
2657#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2658#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2659#[serde(rename_all = "snake_case")]
2660pub enum ArceeEndpoint {
2661    #[default]
2662    Default,
2663}
2664impl ModelEndpoint for ArceeEndpoint {
2665    fn uri(&self) -> &'static str {
2666        match self {
2667            // Arcee publishes its OpenAI-compatible API at the `/api/v1` path
2668            // (not the conventional `/v1` root). Confirmed against Arcee docs.
2669            Self::Default => "https://api.arcee.ai/api/v1",
2670        }
2671    }
2672}
2673#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2674#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2675#[prefix = "providers.models.arcee"]
2676pub struct ArceeModelProviderConfig {
2677    #[nested]
2678    #[serde(flatten)]
2679    pub base: ModelProviderConfig,
2680}
2681
2682// ── Lambda AI ──
2683
2684#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2685#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2686#[serde(rename_all = "snake_case")]
2687pub enum LambdaAiEndpoint {
2688    #[default]
2689    Default,
2690}
2691impl ModelEndpoint for LambdaAiEndpoint {
2692    fn uri(&self) -> &'static str {
2693        match self {
2694            Self::Default => "https://api.lambda.ai/v1",
2695        }
2696    }
2697}
2698#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2699#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2700#[prefix = "providers.models.lambda_ai"]
2701pub struct LambdaAiModelProviderConfig {
2702    #[nested]
2703    #[serde(flatten)]
2704    pub base: ModelProviderConfig,
2705}
2706
2707// ── Inception ──
2708
2709#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2710#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2711#[serde(rename_all = "snake_case")]
2712pub enum InceptionEndpoint {
2713    #[default]
2714    Default,
2715}
2716impl ModelEndpoint for InceptionEndpoint {
2717    fn uri(&self) -> &'static str {
2718        match self {
2719            Self::Default => "https://api.inceptionlabs.ai/v1",
2720        }
2721    }
2722}
2723#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2724#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2725#[prefix = "providers.models.inception"]
2726pub struct InceptionModelProviderConfig {
2727    #[nested]
2728    #[serde(flatten)]
2729    pub base: ModelProviderConfig,
2730}
2731
2732// ── Synthetic ──
2733
2734#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2735#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2736#[serde(rename_all = "snake_case")]
2737pub enum SyntheticEndpoint {
2738    #[default]
2739    Default,
2740}
2741impl ModelEndpoint for SyntheticEndpoint {
2742    fn uri(&self) -> &'static str {
2743        match self {
2744            Self::Default => "https://api.synthetic.new/openai/v1",
2745        }
2746    }
2747}
2748#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2749#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2750#[prefix = "providers.models.synthetic"]
2751pub struct SyntheticModelProviderConfig {
2752    #[nested]
2753    #[serde(flatten)]
2754    pub base: ModelProviderConfig,
2755}
2756
2757// ── OpenCode (Zen) ──
2758
2759#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2760#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2761#[serde(rename_all = "snake_case")]
2762pub enum OpencodeEndpoint {
2763    #[default]
2764    Default,
2765}
2766impl ModelEndpoint for OpencodeEndpoint {
2767    fn uri(&self) -> &'static str {
2768        match self {
2769            Self::Default => "https://opencode.ai/zen/v1",
2770        }
2771    }
2772}
2773#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2774#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2775#[prefix = "providers.models.opencode"]
2776pub struct OpencodeModelProviderConfig {
2777    #[nested]
2778    #[serde(flatten)]
2779    pub base: ModelProviderConfig,
2780}
2781
2782// ── KiloCli (subprocess wrapper) ──
2783
2784#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2785#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2786#[serde(rename_all = "snake_case")]
2787pub enum KiloCliEndpoint {
2788    #[default]
2789    LocalSubprocess,
2790}
2791impl ModelEndpoint for KiloCliEndpoint {
2792    fn uri(&self) -> &'static str {
2793        match self {
2794            Self::LocalSubprocess => "subprocess://kilocli",
2795        }
2796    }
2797}
2798#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2799#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2800#[prefix = "providers.models.kilocli"]
2801pub struct KiloCliModelProviderConfig {
2802    #[nested]
2803    #[serde(flatten)]
2804    pub base: ModelProviderConfig,
2805    /// Path to the `kilo` CLI binary. Falls back to `kilo` (PATH lookup).
2806    #[serde(default, skip_serializing_if = "Option::is_none")]
2807    pub binary_path: Option<String>,
2808}
2809
2810// ── Kilo (AI Gateway — OpenAI-compatible) ──
2811
2812/// Kilo AI Gateway endpoint. Single canonical endpoint at kilo.ai.
2813#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2814#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2815#[serde(rename_all = "snake_case")]
2816pub enum KiloEndpoint {
2817    #[default]
2818    Gateway,
2819}
2820impl ModelEndpoint for KiloEndpoint {
2821    fn uri(&self) -> &'static str {
2822        match self {
2823            Self::Gateway => "https://api.kilo.ai/api/gateway",
2824        }
2825    }
2826}
2827
2828#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2829#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2830#[prefix = "providers.models.kilo"]
2831pub struct KiloModelProviderConfig {
2832    #[nested]
2833    #[serde(flatten)]
2834    pub base: ModelProviderConfig,
2835    /// Kilo endpoint variant. Defaults to the canonical Kilo AI Gateway.
2836    #[serde(default, skip_serializing_if = "KiloEndpoint::is_default")]
2837    pub endpoint: KiloEndpoint,
2838}
2839
2840impl KiloEndpoint {
2841    fn is_default(&self) -> bool {
2842        matches!(self, Self::Gateway)
2843    }
2844}
2845
2846impl FamilyEndpoint for KiloModelProviderConfig {
2847    fn endpoint_uri(&self) -> Option<&'static str> {
2848        Some(self.endpoint.uri())
2849    }
2850}
2851
2852// ── Custom (user-supplied URL, no canonical default) ──
2853
2854/// Custom catch-all for operator-defined endpoints. The endpoint variant has
2855/// no canonical URL — operators must always set `base.uri`. The trait return
2856/// is a sentinel string; the runtime constructor must verify `base.uri` is
2857/// set for `custom` entries and fail with a clear error if not.
2858#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2859#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2860#[serde(rename_all = "snake_case")]
2861pub enum CustomEndpoint {
2862    #[default]
2863    OperatorSupplied,
2864}
2865impl ModelEndpoint for CustomEndpoint {
2866    fn uri(&self) -> &'static str {
2867        match self {
2868            Self::OperatorSupplied => "operator-supplied:set-cfg-uri",
2869        }
2870    }
2871}
2872#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2873#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2874#[prefix = "providers.models.custom"]
2875pub struct CustomModelProviderConfig {
2876    #[nested]
2877    #[serde(flatten)]
2878    pub base: ModelProviderConfig,
2879}
2880
2881// ── Bedrock (computed-endpoint exemplar, AWS region template) ──
2882
2883/// AWS Bedrock endpoint template. Single variant; the URL is computed at
2884/// runtime by substituting `{region}` from the typed config field.
2885#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2886#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2887#[serde(rename_all = "snake_case")]
2888pub enum BedrockEndpoint {
2889    #[default]
2890    Default,
2891}
2892
2893impl ModelEndpoint for BedrockEndpoint {
2894    fn uri(&self) -> &'static str {
2895        match self {
2896            // Bedrock URI is a template — substitution happens in the
2897            // BedrockModelProvider runtime constructor against cfg.region.
2898            Self::Default => "https://bedrock-runtime.{region}.amazonaws.com",
2899        }
2900    }
2901}
2902
2903/// AWS Bedrock model model_provider config. Carries the AWS region (the URI
2904/// template substitutes `{region}` from this field). Bedrock auth is
2905/// SigV4 — credentials come from the standard AWS credential chain
2906/// (env vars, instance metadata, profile), not from `api_key`.
2907#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
2908#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
2909#[prefix = "providers.models.bedrock"]
2910pub struct BedrockModelProviderConfig {
2911    #[nested]
2912    #[serde(flatten)]
2913    pub base: ModelProviderConfig,
2914    /// AWS region for the Bedrock endpoint (e.g. `us-east-1`, `eu-west-1`).
2915    #[serde(default, skip_serializing_if = "Option::is_none")]
2916    pub region: Option<String>,
2917}
2918
2919// ── FamilyEndpoint default impls (single-endpoint families) ─────
2920//
2921// Multi-endpoint families (Moonshot, Qwen, Glm, Minimax, Zai, Stepfun) define
2922// their own `impl FamilyEndpoint` next to the struct. Every other family
2923// gets the `None` default via this list. The list is exhaustive: a new
2924// family with no impl here AND no manual impl elsewhere will fail to
2925// compile against `ModelProviders::resolved_endpoint_uri`, which expands
2926// `endpoint_uri()` per slot through `for_each_model_provider_slot!`.
2927
2928macro_rules! impl_default_family_endpoint {
2929    ($($t:ty),+ $(,)?) => {
2930        $( impl FamilyEndpoint for $t {} )+
2931    };
2932}
2933
2934impl_default_family_endpoint! {
2935    OpenAIModelProviderConfig,
2936    AzureModelProviderConfig,
2937    AnthropicModelProviderConfig,
2938    AtomicChatModelProviderConfig,
2939    OpenRouterModelProviderConfig,
2940    OllamaModelProviderConfig,
2941    TogetherModelProviderConfig,
2942    FireworksModelProviderConfig,
2943    GroqModelProviderConfig,
2944    MistralModelProviderConfig,
2945    DeepseekModelProviderConfig,
2946    CohereModelProviderConfig,
2947    PerplexityModelProviderConfig,
2948    XaiModelProviderConfig,
2949    CerebrasModelProviderConfig,
2950    SambanovaModelProviderConfig,
2951    HyperbolicModelProviderConfig,
2952    DeepinfraModelProviderConfig,
2953    HuggingfaceModelProviderConfig,
2954    Ai21ModelProviderConfig,
2955    RekaModelProviderConfig,
2956    BasetenModelProviderConfig,
2957    NscaleModelProviderConfig,
2958    AnyscaleModelProviderConfig,
2959    NebiusModelProviderConfig,
2960    FriendliModelProviderConfig,
2961    AihubmixModelProviderConfig,
2962    SiliconflowModelProviderConfig,
2963    AstraiModelProviderConfig,
2964    AvianModelProviderConfig,
2965    DeepmystModelProviderConfig,
2966    VeniceModelProviderConfig,
2967    NovitaModelProviderConfig,
2968    NvidiaModelProviderConfig,
2969    TelnyxModelProviderConfig,
2970    VercelModelProviderConfig,
2971    CloudflareModelProviderConfig,
2972    OvhModelProviderConfig,
2973    CopilotModelProviderConfig,
2974    DoubaoModelProviderConfig,
2975    YiModelProviderConfig,
2976    HunyuanModelProviderConfig,
2977    QianfanModelProviderConfig,
2978    BaichuanModelProviderConfig,
2979    GeminiModelProviderConfig,
2980    GeminiCliModelProviderConfig,
2981    LmstudioModelProviderConfig,
2982    LlamacppModelProviderConfig,
2983    SglangModelProviderConfig,
2984    VllmModelProviderConfig,
2985    OsaurusModelProviderConfig,
2986    LitellmModelProviderConfig,
2987    LeptonModelProviderConfig,
2988    MorphModelProviderConfig,
2989    GithubModelsModelProviderConfig,
2990    UpstageModelProviderConfig,
2991    FeatherlessModelProviderConfig,
2992    ArceeModelProviderConfig,
2993    LambdaAiModelProviderConfig,
2994    InceptionModelProviderConfig,
2995    SyntheticModelProviderConfig,
2996    OpencodeModelProviderConfig,
2997    KiloCliModelProviderConfig,
2998    CustomModelProviderConfig,
2999    BedrockModelProviderConfig,
3000}
3001
3002// ── Delegate Tool Configuration ─────────────────────────────────
3003
3004/// Global delegate tool configuration for default timeout values.
3005#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3006#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3007#[prefix = "delegate"]
3008pub struct DelegateToolConfig {
3009    /// Default timeout in seconds for non-agentic sub-agent model_provider calls.
3010    /// Can be overridden per-agent in `[agents.<name>]` config.
3011    /// Default: 120 seconds.
3012    #[serde(default = "default_delegate_timeout_secs")]
3013    pub timeout_secs: u64,
3014    /// Default timeout in seconds for agentic sub-agent runs.
3015    /// Can be overridden per-agent in `[agents.<name>]` config.
3016    /// Default: 300 seconds.
3017    #[serde(default = "default_delegate_agentic_timeout_secs")]
3018    pub agentic_timeout_secs: u64,
3019}
3020
3021impl Default for DelegateToolConfig {
3022    fn default() -> Self {
3023        Self {
3024            timeout_secs: DEFAULT_DELEGATE_TIMEOUT_SECS,
3025            agentic_timeout_secs: DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS,
3026        }
3027    }
3028}
3029
3030// ── Aliased Agents ───────────────────────────────────────────────
3031
3032/// Runtime tunables resolved from the agent's runtime profile. Populated
3033/// by `Config::resolved_agent_config`; never deserialized from the agent
3034/// table. The runtime profile is the sole config surface for these.
3035#[derive(Debug, Clone)]
3036pub struct ResolvedRuntime {
3037    pub compact_context: bool,
3038    pub max_tool_iterations: usize,
3039    pub max_history_messages: usize,
3040    pub max_context_tokens: usize,
3041    pub parallel_tools: bool,
3042    pub tool_dispatcher: String,
3043    pub strict_tool_parsing: bool,
3044    pub tool_call_dedup_exempt: Vec<String>,
3045    pub tool_filter_groups: Vec<ToolFilterGroup>,
3046    pub max_system_prompt_chars: usize,
3047    pub thinking: crate::scattered_types::ThinkingConfig,
3048    pub history_pruning: crate::scattered_types::HistoryPrunerConfig,
3049    pub context_aware_tools: bool,
3050    pub eval: crate::scattered_types::EvalConfig,
3051    pub auto_classify: Option<crate::scattered_types::AutoClassifyConfig>,
3052    pub context_compression: crate::scattered_types::ContextCompressionConfig,
3053    pub max_tool_result_chars: usize,
3054    pub keep_tool_context_turns: usize,
3055    pub tool_receipts: ToolReceiptsConfig,
3056}
3057
3058impl Default for ResolvedRuntime {
3059    fn default() -> Self {
3060        Self {
3061            compact_context: true,
3062            max_tool_iterations: 10,
3063            max_history_messages: 50,
3064            max_context_tokens: 32_000,
3065            parallel_tools: false,
3066            tool_dispatcher: default_agent_tool_dispatcher(),
3067            strict_tool_parsing: false,
3068            tool_call_dedup_exempt: Vec::new(),
3069            tool_filter_groups: Vec::new(),
3070            max_system_prompt_chars: default_max_system_prompt_chars(),
3071            thinking: crate::scattered_types::ThinkingConfig::default(),
3072            history_pruning: crate::scattered_types::HistoryPrunerConfig::default(),
3073            context_aware_tools: false,
3074            eval: crate::scattered_types::EvalConfig::default(),
3075            auto_classify: None,
3076            context_compression: crate::scattered_types::ContextCompressionConfig::default(),
3077            max_tool_result_chars: default_max_tool_result_chars(),
3078            keep_tool_context_turns: default_keep_tool_context_turns(),
3079            tool_receipts: ToolReceiptsConfig::default(),
3080        }
3081    }
3082}
3083
3084/// Configuration for an aliased agent. Each `[agents.<alias>]` TOML
3085/// block deserializes into one of these. The `DelegateTool` looks up
3086/// entries here to dispatch a subtask to a named sibling agent.
3087#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3088#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3089#[prefix = "delegate_agent"]
3090pub struct AliasedAgentConfig {
3091    /// Whether this agent is active. Set false to disable without removing the definition.
3092    #[tab(General)]
3093    #[serde(default = "default_true")]
3094    pub enabled: bool,
3095    /// Channel aliases this agent handles (e.g. `["telegram.<alias>", "discord.<alias>"]`).
3096    /// Each entry is a `ChannelRef` resolving through `[channels.<type>.<alias>]`;
3097    /// `Config::validate()` fails loud on dangling references.
3098    #[tab(Channels)]
3099    #[serde(default)]
3100    pub channels: Vec<crate::providers::ChannelRef>,
3101    /// Dotted model-provider alias (e.g. `"anthropic.<alias>"`).
3102    /// Resolves through `model_providers.<type>.<alias>` at runtime;
3103    /// `Config::validate()` fails loud on dangling references.
3104    #[tab(Providers)]
3105    #[serde(default)]
3106    pub model_provider: crate::providers::ModelProviderRef,
3107    /// Risk profile alias (e.g. `"default"`). Resolves delegation guardrails at runtime.
3108    #[tab(General)]
3109    #[serde(default)]
3110    pub risk_profile: String,
3111    /// Runtime profile alias (e.g. `"default"`). Resolves agentic/iteration settings.
3112    #[tab(General)]
3113    #[serde(default)]
3114    pub runtime_profile: String,
3115    /// Skill bundle aliases. Each entry resolves to
3116    /// `skill_bundles[key].directory` at runtime; the agent loads every
3117    /// listed bundle.
3118    #[tab(Bundles)]
3119    #[serde(default)]
3120    pub skill_bundles: Vec<String>,
3121    /// Knowledge bundle aliases. Additive: the agent loads every listed
3122    /// bundle.
3123    #[tab(Bundles)]
3124    #[serde(default)]
3125    pub knowledge_bundles: Vec<String>,
3126    /// MCP bundle aliases. Each entry references `mcp_bundles[key]`,
3127    /// itself a named group of MCP servers; agents pick which bundles to
3128    /// load.
3129    #[tab(Bundles)]
3130    #[serde(default)]
3131    pub mcp_bundles: Vec<String>,
3132    /// Cron job aliases. Each entry references `cron[key]`, a declarative
3133    /// scheduled job invoked by the scheduler on its configured trigger.
3134    /// When the cron fires, this agent is the actor that executes the job.
3135    #[tab(Cron)]
3136    #[serde(default)]
3137    pub cron_jobs: Vec<String>,
3138    /// TTS provider as a dotted alias reference (`<type>.<alias>`,
3139    /// e.g. `"openai.<alias>"`). Resolves through `tts_providers.<type>.<alias>`.
3140    /// Empty = no TTS for this agent (there is no global default-provider concept;
3141    /// every agent that wants TTS sets its own `tts_provider`).
3142    #[tab(Providers)]
3143    #[serde(default)]
3144    pub tts_provider: crate::providers::TtsProviderRef,
3145    /// Transcription / STT provider as a dotted alias reference
3146    /// (`<type>.<alias>`, e.g. `"groq.<alias>"`). Resolves through
3147    /// `transcription_providers.<type>.<alias>`. Empty = agent has no
3148    /// transcription preference; channels that ingest voice still need a
3149    /// resolved provider (there is no global default), so an inbound voice
3150    /// flow into an agent with empty `transcription_provider` errors loudly
3151    /// at the channel boundary.
3152    #[tab(Providers)]
3153    #[serde(default)]
3154    pub transcription_provider: crate::providers::TranscriptionProviderRef,
3155
3156    /// Optional override for the per-message LLM reply-intent classifier
3157    /// (`classify_channel_reply_intent` in zeroclaw-channels). When non-empty,
3158    /// the channel orchestrator routes the "should this message be replied to?"
3159    /// classification call to `[providers.models.<type>.<alias>]` referenced
3160    /// here, instead of reusing the main agent's `model_provider`.
3161    ///
3162    /// Source of truth for api_key / uri / model / temperature etc. is the
3163    /// referenced `[providers.models.<type>.<alias>]` entry. This field is
3164    /// a reference only (NEVER a copy), per AGENTS.md SINGLE SOURCE OF TRUTH.
3165    ///
3166    /// Empty (`Default`) = inherit the main agent's resolved provider+model
3167    /// (preserves pre-PR behavior; backward compatible).
3168    ///
3169    /// Use case: classification is a cheap REPLY/NO_REPLY decision, doesn't
3170    /// need a high-end model. Point this at a fast/free small model
3171    /// (e.g. `kimi-k2.5`, `qwen-turbo`) while `model_provider` stays on the
3172    /// expensive answering model (e.g. `qwen3.6-plus`).
3173    ///
3174    /// Note: TOML table names cannot contain `.`, so alias `kimi-k2.5`
3175    /// must be written as `[providers.models.custom.kimi-k2-5]`. The
3176    /// underlying `model = "kimi-k2.5"` string can still contain dots.
3177    ///
3178    /// ACP channels (IDE-direct) always reply and skip the classifier
3179    /// entirely, so this field has no effect on ACP traffic.
3180    #[tab(Providers)]
3181    #[serde(default)]
3182    pub classifier_provider: crate::providers::ModelProviderRef,
3183
3184    // ── Resolved runtime tunables (populated by `resolved_agent_config`
3185    // from the runtime profile; not config-settable on the agent). ──
3186    #[serde(skip)]
3187    pub resolved: ResolvedRuntime,
3188
3189    /// Per-agent workspace block (`[agents.<alias>.workspace]`).
3190    /// Holds the agent's filesystem path, cross-agent access allowlist,
3191    /// filesystem-escape boolean, and cross-agent memory allowlist.
3192    /// Default is fully jailed (no cross-agent access). See
3193    /// `crate::multi_agent::AgentWorkspaceConfig`.
3194    #[tab(Workspace)]
3195    #[serde(default)]
3196    #[nested]
3197    pub workspace: crate::multi_agent::AgentWorkspaceConfig,
3198
3199    /// Per-agent memory backend selection (`[agents.<alias>.memory]`).
3200    /// The `backend` field is locked at agent creation and immutable on
3201    /// subsequent loads. Defaults to `Sqlite`. See
3202    /// `crate::multi_agent::AgentMemoryConfig`.
3203    #[tab(Memory)]
3204    #[serde(default)]
3205    #[nested]
3206    pub memory: crate::multi_agent::AgentMemoryConfig,
3207
3208    /// Per-agent identity format (`[agents.<alias>.identity]`). Each
3209    /// agent renders its own IDENTITY.md / SOUL.md inside its
3210    /// per-agent workspace; this block selects the format (OpenClaw or
3211    /// AIEOS) and optional inline/file source for the agent's identity
3212    /// document.
3213    #[tab(Tuning)]
3214    #[serde(default)]
3215    #[nested]
3216    pub identity: IdentityConfig,
3217}
3218
3219impl Default for AliasedAgentConfig {
3220    fn default() -> Self {
3221        Self {
3222            enabled: true,
3223            channels: Vec::new(),
3224            model_provider: crate::providers::ModelProviderRef::default(),
3225            risk_profile: String::new(),
3226            runtime_profile: String::new(),
3227            skill_bundles: Vec::new(),
3228            knowledge_bundles: Vec::new(),
3229            mcp_bundles: Vec::new(),
3230            cron_jobs: Vec::new(),
3231            tts_provider: crate::providers::TtsProviderRef::default(),
3232            transcription_provider: crate::providers::TranscriptionProviderRef::default(),
3233            classifier_provider: crate::providers::ModelProviderRef::default(),
3234            resolved: ResolvedRuntime::default(),
3235            workspace: crate::multi_agent::AgentWorkspaceConfig::default(),
3236            memory: crate::multi_agent::AgentMemoryConfig::default(),
3237            identity: IdentityConfig::default(),
3238        }
3239    }
3240}
3241
3242impl AliasedAgentConfig {
3243    /// True when this agent has the bindings required to dispatch a turn:
3244    /// enabled, non-empty `model_provider`, `risk_profile`, and
3245    /// `runtime_profile`. `Config::validate()` emits the per-field errors
3246    /// that, when all passed, mean this returns `true`.
3247    #[must_use]
3248    pub fn is_dispatchable(&self) -> bool {
3249        self.enabled
3250            && !self.model_provider.is_empty()
3251            && !self.risk_profile.trim().is_empty()
3252            && !self.runtime_profile.trim().is_empty()
3253    }
3254}
3255
3256/// One `[channels.<type>.<alias>]` block, with the owning agent (if any)
3257/// resolved via `agents.<agent>.channels`. Returned by
3258/// `Config::channels_by_alias()`.
3259#[derive(Debug, Clone, Serialize, Deserialize)]
3260#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3261pub struct ChannelAliasInfo {
3262    /// Channel type as the schema emits it (kebab; e.g. `"discord"`,
3263    /// `"nextcloud-talk"`).
3264    pub channel_type: String,
3265    /// Per-alias HashMap key (e.g. `"loneliness"`).
3266    pub alias: String,
3267    /// The agent whose `channels` list contains `<type>.<alias>`. `None`
3268    /// when the block is orphaned (config error caught at startup).
3269    pub owning_agent: Option<String>,
3270    /// Resolved value of `[channels.<type>.<alias>].enabled` at scan time.
3271    /// `false` when the field is unset (matches the serde bool default).
3272    pub enabled: bool,
3273}
3274
3275impl Config {
3276    /// Return the first concrete `model` string available for use as a
3277    /// default. Scans every typed slot's entries (iteration order is
3278    /// the macro slot order) for one with `model` set. Returns `None`
3279    /// only when no model-provider entry has any model configured at
3280    /// all.
3281    #[must_use]
3282    pub fn resolve_default_model(&self) -> Option<String> {
3283        self.providers
3284            .models
3285            .iter_entries()
3286            .filter_map(|(_, _, base)| base.model.as_deref().map(str::trim))
3287            .find(|m| !m.is_empty())
3288            .map(ToString::to_string)
3289    }
3290
3291    /// Resolve the risk profile for an explicit agent alias.
3292    ///
3293    /// Each agent's `risk_profile` field names a `[risk_profiles.<alias>]`
3294    /// entry that gates its actions. There is no "global" risk profile in
3295    /// every callsite must come through an agent. When the agent has
3296    /// no profile set or names a missing entry, returns `None` and the
3297    /// caller decides how to handle it (validation rejects this shape at
3298    /// load time; the runtime treating `None` as a config error).
3299    #[must_use]
3300    pub fn risk_profile_for_agent(&self, agent_alias: &str) -> Option<&RiskProfileConfig> {
3301        let agent = self.agents.get(agent_alias)?;
3302        let profile_alias = agent.risk_profile.trim();
3303        if profile_alias.is_empty() {
3304            return None;
3305        }
3306        self.risk_profiles.get(profile_alias)
3307    }
3308
3309    /// Resolve the `[runtime_profiles.<alias>]` entry owned by an agent
3310    /// (via `agents.<alias>.runtime_profile`). Returns `None` when the
3311    /// agent has no runtime profile set or names a missing entry. Unlike
3312    /// `risk_profile_for_agent`, the missing case is not a hard error
3313    /// because runtime budgets and tunables fall back to global defaults.
3314    #[must_use]
3315    pub fn runtime_profile_for_agent(&self, agent_alias: &str) -> Option<&RuntimeProfileConfig> {
3316        let agent = self.agents.get(agent_alias)?;
3317        let profile_alias = agent.runtime_profile.trim();
3318        if profile_alias.is_empty() {
3319            return None;
3320        }
3321        self.runtime_profiles.get(profile_alias)
3322    }
3323
3324    // ── Effective per-agent runtime tunables ──────────────────────────
3325    //
3326    // Runtime tunables live on `[runtime_profiles.<profile>]`, referenced by an
3327    // agent via `agents.<alias>.runtime_profile`. A profile value that is
3328    // explicitly set (non-sentinel, i.e. `> 0` for `max_tool_iterations`) is
3329    // authoritative; otherwise the global default applies. Agent-inline copies
3330    // of these tunable keys are inert — superseded by runtime profiles (see the
3331    // `agent_level_tunable_keys_are_inert` test) — so they are deliberately not
3332    // consulted here as a fallback (#6877).
3333
3334    #[must_use]
3335    pub fn effective_max_tool_iterations(&self, agent_alias: &str) -> usize {
3336        self.runtime_profile_for_agent(agent_alias)
3337            .map(|p| p.max_tool_iterations)
3338            .filter(|&v| v > 0)
3339            .unwrap_or(10)
3340    }
3341
3342    #[must_use]
3343    pub fn effective_max_history_messages(&self, agent_alias: &str) -> usize {
3344        self.runtime_profile_for_agent(agent_alias)
3345            .and_then(|p| p.max_history_messages)
3346            .unwrap_or(50)
3347    }
3348
3349    #[must_use]
3350    pub fn effective_max_context_tokens(&self, agent_alias: &str) -> usize {
3351        self.runtime_profile_for_agent(agent_alias)
3352            .and_then(|p| p.max_context_tokens)
3353            .unwrap_or(32_000)
3354    }
3355
3356    #[must_use]
3357    pub fn effective_memory_recall_limit(&self, agent_alias: &str) -> usize {
3358        let raw = self
3359            .runtime_profile_for_agent(agent_alias)
3360            .and_then(|p| p.memory_recall_limit)
3361            .unwrap_or(5);
3362        if raw == 0 { usize::MAX } else { raw }
3363    }
3364
3365    #[must_use]
3366    pub fn effective_compact_context(&self, agent_alias: &str) -> bool {
3367        self.runtime_profile_for_agent(agent_alias)
3368            .and_then(|p| p.compact_context)
3369            .unwrap_or(true)
3370    }
3371
3372    #[must_use]
3373    pub fn effective_parallel_tools(&self, agent_alias: &str) -> bool {
3374        self.runtime_profile_for_agent(agent_alias)
3375            .and_then(|p| p.parallel_tools)
3376            .unwrap_or(false)
3377    }
3378
3379    #[must_use]
3380    pub fn effective_tool_dispatcher(&self, agent_alias: &str) -> String {
3381        self.runtime_profile_for_agent(agent_alias)
3382            .and_then(|p| p.tool_dispatcher.as_ref())
3383            .filter(|s| !s.trim().is_empty())
3384            .map_or_else(default_agent_tool_dispatcher, Clone::clone)
3385    }
3386
3387    #[must_use]
3388    pub fn effective_tool_call_dedup_exempt(&self, agent_alias: &str) -> Vec<String> {
3389        self.runtime_profile_for_agent(agent_alias)
3390            .map(|p| p.tool_call_dedup_exempt.clone())
3391            .unwrap_or_default()
3392    }
3393
3394    #[must_use]
3395    pub fn effective_max_system_prompt_chars(&self, agent_alias: &str) -> usize {
3396        self.runtime_profile_for_agent(agent_alias)
3397            .and_then(|p| p.max_system_prompt_chars)
3398            .unwrap_or_else(default_max_system_prompt_chars)
3399    }
3400
3401    #[must_use]
3402    pub fn effective_context_aware_tools(&self, agent_alias: &str) -> bool {
3403        self.runtime_profile_for_agent(agent_alias)
3404            .and_then(|p| p.context_aware_tools)
3405            .unwrap_or(false)
3406    }
3407
3408    #[must_use]
3409    pub fn effective_max_tool_result_chars(&self, agent_alias: &str) -> usize {
3410        self.runtime_profile_for_agent(agent_alias)
3411            .and_then(|p| p.max_tool_result_chars)
3412            .unwrap_or_else(default_max_tool_result_chars)
3413    }
3414
3415    #[must_use]
3416    pub fn effective_keep_tool_context_turns(&self, agent_alias: &str) -> usize {
3417        self.runtime_profile_for_agent(agent_alias)
3418            .and_then(|p| p.keep_tool_context_turns)
3419            .unwrap_or_else(default_keep_tool_context_turns)
3420    }
3421
3422    /// Return a clone of the named agent's `AliasedAgentConfig` with all
3423    /// runtime-profile overrides baked in. Use this when an `Agent` (or
3424    /// any other struct) needs to own a self-contained, already-resolved
3425    /// view of the agent's runtime knobs without holding a reference to
3426    /// the full `Config`.
3427    ///
3428    /// Returns `None` when `agent_alias` is not present in `agents`.
3429    ///
3430    /// Semantics: every field touched here mirrors the matching
3431    /// `effective_*` helper above. If you add a new runtime_profile knob,
3432    /// add it both to an `effective_*` helper *and* to this function so
3433    /// downstream consumers see consistent values regardless of which
3434    /// surface they read from.
3435    #[must_use]
3436    pub fn resolved_agent_config(&self, agent_alias: &str) -> Option<AliasedAgentConfig> {
3437        let mut out = self.agents.get(agent_alias)?.clone();
3438        let mut resolved = ResolvedRuntime {
3439            max_tool_iterations: self.effective_max_tool_iterations(agent_alias),
3440            max_history_messages: self.effective_max_history_messages(agent_alias),
3441            max_context_tokens: self.effective_max_context_tokens(agent_alias),
3442            compact_context: self.effective_compact_context(agent_alias),
3443            parallel_tools: self.effective_parallel_tools(agent_alias),
3444            tool_dispatcher: self.effective_tool_dispatcher(agent_alias),
3445            tool_call_dedup_exempt: self.effective_tool_call_dedup_exempt(agent_alias),
3446            max_system_prompt_chars: self.effective_max_system_prompt_chars(agent_alias),
3447            context_aware_tools: self.effective_context_aware_tools(agent_alias),
3448            max_tool_result_chars: self.effective_max_tool_result_chars(agent_alias),
3449            keep_tool_context_turns: self.effective_keep_tool_context_turns(agent_alias),
3450            ..ResolvedRuntime::default()
3451        };
3452        if let Some(profile) = self.runtime_profile_for_agent(agent_alias) {
3453            resolved.strict_tool_parsing = profile.strict_tool_parsing;
3454            resolved.thinking = profile.thinking.clone();
3455            resolved.history_pruning = profile.history_pruning.clone();
3456            resolved.eval = profile.eval.clone();
3457            resolved.auto_classify = profile.auto_classify.clone();
3458            resolved.context_compression = profile.context_compression.clone();
3459            resolved.tool_receipts = profile.tool_receipts.clone();
3460            resolved.tool_filter_groups = profile.tool_filter_groups.clone();
3461        }
3462        out.resolved = resolved;
3463        Some(out)
3464    }
3465
3466    /// Resolve an agent's `model_provider` reference (`"<type>.<alias>"`) to
3467    /// its concrete `ModelProviderConfig` entry. Returns `None` when the
3468    /// agent doesn't exist, the reference is unparseable, or the
3469    /// `<type>.<alias>` pair doesn't resolve in `providers.models`.
3470    ///
3471    /// This is the lookup the orchestrator uses to build per-agent
3472    /// model_provider runtime options via explicit `<type>.<alias>`
3473    /// resolution — there is no concept of a "first" or "default"
3474    /// provider. The matching split logic lives in
3475    /// `crates/zeroclaw-runtime/src/tools/delegate.rs::resolve_brain` for
3476    /// the delegation path; this helper exposes the same contract for the
3477    /// channel-server startup path.
3478    #[must_use]
3479    pub fn model_provider_for_agent(&self, agent_alias: &str) -> Option<&ModelProviderConfig> {
3480        let agent = self.agents.get(agent_alias)?;
3481        let (type_key, alias_key) = agent.model_provider.split_once('.')?;
3482        self.providers.models.find(type_key, alias_key)
3483    }
3484
3485    /// Resolve `(provider_type, provider_alias, &ModelProviderConfig)` for an
3486    /// agent. Same lookup as `model_provider_for_agent` but also returns the
3487    /// `'static` type key that downstream provider factories
3488    /// (`create_routed_model_provider_with_options`, etc.) need. Returns
3489    /// `None` when the agent has no `model_provider` set, when the reference
3490    /// is unparseable, or when the resolved entry has been deleted from
3491    /// `providers.models`.
3492    #[must_use]
3493    pub fn resolved_model_provider_for_agent(
3494        &self,
3495        agent_alias: &str,
3496    ) -> Option<(&'static str, &str, &ModelProviderConfig)> {
3497        let agent = self.agents.get(agent_alias)?;
3498        let (type_key, alias_key) = agent.model_provider.split_once('.')?;
3499        self.providers
3500            .models
3501            .iter_entries()
3502            .find(|(ty, al, _)| *ty == type_key && *al == alias_key)
3503    }
3504
3505    /// Reverse-lookup the agent alias that owns a configured channel
3506    /// (`<type>.<alias>`). Returns the first agent listing the channel in
3507    /// its `channels` field. `None` when no agent owns the channel —
3508    /// orphaned channels are a config error the orchestrator surfaces at
3509    /// startup.
3510    #[must_use]
3511    pub fn agent_for_channel(&self, channel_alias: &str) -> Option<&str> {
3512        self.agents
3513            .iter()
3514            .find(|(_, agent)| agent.enabled && agent.channels.iter().any(|c| c == channel_alias))
3515            .map(|(alias, _)| alias.as_str())
3516    }
3517
3518    /// Workspace dir a channel's inbound-media handler writes into. Resolves
3519    /// the channel's owning agent and returns `<install>/agents/<alias>/workspace/`;
3520    /// falls back to `data_dir` for orphan channels (no owning agent enabled).
3521    #[must_use]
3522    pub fn channel_workspace_dir(&self, channel_ref: &str) -> PathBuf {
3523        self.agent_for_channel(channel_ref)
3524            .map_or_else(|| self.data_dir.clone(), |a| self.agent_workspace_dir(a))
3525    }
3526
3527    /// Schema-walk: every populated `[channels.<type>.<alias>]` block.
3528    /// Type names come from the `prop_fields()` enumeration (kebab as the
3529    /// macro emits them) so adding a new channel type via the macro
3530    /// surfaces here without touching this code. Alias keys are HashMap
3531    /// keys; not kebab-converted.
3532    #[must_use]
3533    pub fn channels_by_alias(&self) -> Vec<ChannelAliasInfo> {
3534        use std::collections::BTreeMap;
3535        let mut seen: BTreeMap<(String, String), bool> = BTreeMap::new();
3536        for field in self.prop_fields() {
3537            let parts: Vec<&str> = field.name.split('.').collect();
3538            if parts.len() < 4 || parts[0] != "channels" {
3539                continue;
3540            }
3541            let key = (parts[1].to_string(), parts[2].to_string());
3542            let entry = seen.entry(key).or_insert(false);
3543            if parts.len() == 4 && parts[3] == "enabled" {
3544                *entry = field.display_value == "true";
3545            }
3546        }
3547        seen.into_iter()
3548            .map(|((channel_type, alias), enabled)| {
3549                let composite = format!("{channel_type}.{alias}");
3550                let owning_agent = self.agent_for_channel(&composite).map(str::to_string);
3551                ChannelAliasInfo {
3552                    channel_type,
3553                    alias,
3554                    owning_agent,
3555                    enabled,
3556                }
3557            })
3558            .collect()
3559    }
3560
3561    /// Reverse-lookup the agent alias that owns a declaratively-configured
3562    /// cron job (`[cron.<alias>]`). Returns the first agent listing the
3563    /// alias in its `cron_jobs` field. `None` when no agent claims the
3564    /// job — orphaned cron jobs are skipped at scheduler time with a
3565    /// warning. Imperative jobs (created at runtime via `cron_add`) have
3566    /// UUID-shaped ids that won't match any agent's `cron_jobs`; the
3567    /// scheduler treats those separately (carrying their owning agent
3568    /// alongside the DB row is a follow-up).
3569    #[must_use]
3570    pub fn agent_for_cron_job(&self, cron_alias: &str) -> Option<&str> {
3571        self.agents
3572            .iter()
3573            .find(|(_, agent)| agent.enabled && agent.cron_jobs.iter().any(|c| c == cron_alias))
3574            .map(|(alias, _)| alias.as_str())
3575    }
3576
3577    /// Resolve the per-agent workspace directory for `alias`.
3578    ///
3579    /// Returns the agent's `[agents.<alias>.workspace.path]` override
3580    /// when set (operator-explicit, e.g. for putting a workspace on a
3581    /// different disk), otherwise derives
3582    /// `<install>/agents/<alias>/workspace/` from the install root
3583    /// (the directory containing `config.toml`).
3584    ///
3585    /// Per-agent workspaces live under
3586    /// `<install>/agents/<alias>/workspace/` and hold the agent's
3587    /// markdown memory (MEMORY.md), identity files (IDENTITY.md,
3588    /// SOUL.md), and any other per-agent plaintext state. Shared
3589    /// databases (SQLite memory, sessions, cost records) live under
3590    /// `config.data_dir` instead and partition by agent at the row
3591    /// level. Per-agent overrides via `[agents.<alias>.workspace.path]`
3592    /// pin an arbitrary filesystem path (e.g. a different mount).
3593    #[must_use]
3594    pub fn agent_workspace_dir(&self, agent_alias: &str) -> std::path::PathBuf {
3595        if let Some(cfg) = self.agents.get(agent_alias)
3596            && let Some(custom) = cfg.workspace.path.as_ref()
3597        {
3598            return custom.clone();
3599        }
3600        self.install_root_dir()
3601            .join("agents")
3602            .join(agent_alias)
3603            .join("workspace")
3604    }
3605
3606    /// `<install>/shared/` — directory shared across every agent on this
3607    /// host. Holds skills, skill bundles, knowledge bundles, and any
3608    /// other content not scoped to a single agent's workspace. Distinct
3609    /// from `agent_workspace_dir(alias)` (per-agent state) and
3610    /// `data_dir` (databases + runtime state).
3611    #[must_use]
3612    pub fn shared_workspace_dir(&self) -> std::path::PathBuf {
3613        self.install_root_dir().join("shared")
3614    }
3615
3616    /// Install root: `<install>/` derived from `config_path`'s parent. Used
3617    /// to compute `<install>/shared/`, `<install>/agents/`, and the
3618    /// skill-bundle directory defaults. Public so consumers (gateway, CLI,
3619    /// SkillsService) share the same anchor.
3620    #[must_use]
3621    pub fn install_root_dir(&self) -> std::path::PathBuf {
3622        self.config_path
3623            .parent()
3624            .map(std::path::Path::to_path_buf)
3625            .unwrap_or_else(|| std::path::PathBuf::from("."))
3626    }
3627
3628    /// Resolve an aliased-agent config by alias. `None` when the alias
3629    /// isn't configured; callers should treat this as a config error
3630    /// rather than synthesizing a default.
3631    #[must_use]
3632    pub fn agent(&self, agent_alias: &str) -> Option<&AliasedAgentConfig> {
3633        self.agents.get(agent_alias)
3634    }
3635
3636    /// Resolve the runtime-active agent alias the orchestrator binds
3637    /// channels to. Mirrors the same selection logic as
3638    /// `start_channels()` in zeroclaw-channels: prefer the migration-
3639    /// synthesized `"default"` agent, otherwise fall back to the
3640    /// lexicographically-smallest enabled alias. Returns `None` only
3641    /// when no enabled agent is configured.
3642    ///
3643    /// Used by per-agent infrastructure (TtsManager, TranscriptionManager)
3644    /// to pick which agent's `tts_provider` / `transcription_provider`
3645    /// drives the manager's resolved alias. Until the per-channel
3646    /// dispatch refactor lands, the orchestrator runs in single-agent
3647    /// mode, so all manager instances share the same resolved agent.
3648    #[must_use]
3649    pub fn resolved_runtime_agent_alias(&self) -> Option<&str> {
3650        self.agents
3651            .keys()
3652            .find(|k| k.as_str() == "default")
3653            .map(String::as_str)
3654            .or_else(|| {
3655                self.agents
3656                    .iter()
3657                    .filter(|(_, a)| a.enabled)
3658                    .map(|(alias, _)| alias.as_str())
3659                    .min()
3660            })
3661    }
3662
3663    /// Resolve the active storage backend for the memory subsystem.
3664    ///
3665    /// `MemoryConfig.backend` is a dotted reference (`<backend>.<alias>`) into
3666    /// `Config.storage.<backend>.<alias>`. Bare backend names are interpreted
3667    /// as `<backend>.default` for back-compat.
3668    ///
3669    /// Returns `ActiveStorage::None` when no backend is configured, when the
3670    /// backend is `"none"`, or when the dotted alias does not resolve to a
3671    /// configured entry.
3672    pub fn resolve_active_storage(&self) -> ActiveStorage<'_> {
3673        let backend = self.memory.backend.trim();
3674        if backend.is_empty() || backend.eq_ignore_ascii_case("none") {
3675            return ActiveStorage::None;
3676        }
3677        let (kind, alias) = backend.split_once('.').unwrap_or((backend, "default"));
3678        match kind {
3679            "sqlite" => self
3680                .storage
3681                .sqlite
3682                .get(alias)
3683                .map(ActiveStorage::Sqlite)
3684                .unwrap_or(ActiveStorage::None),
3685            "postgres" => self
3686                .storage
3687                .postgres
3688                .get(alias)
3689                .map(ActiveStorage::Postgres)
3690                .unwrap_or(ActiveStorage::None),
3691            "qdrant" => self
3692                .storage
3693                .qdrant
3694                .get(alias)
3695                .map(ActiveStorage::Qdrant)
3696                .unwrap_or(ActiveStorage::None),
3697            "markdown" => self
3698                .storage
3699                .markdown
3700                .get(alias)
3701                .map(ActiveStorage::Markdown)
3702                .unwrap_or(ActiveStorage::None),
3703            "lucid" => self
3704                .storage
3705                .lucid
3706                .get(alias)
3707                .map(ActiveStorage::Lucid)
3708                .unwrap_or(ActiveStorage::None),
3709            _ => ActiveStorage::None,
3710        }
3711    }
3712}
3713
3714/// Resolved storage backend variant.
3715///
3716/// Returned from [`Config::resolve_active_storage`]. Each variant carries a
3717/// borrow of the typed config from the corresponding `Config.storage` map.
3718#[derive(Debug, Clone, Copy)]
3719pub enum ActiveStorage<'a> {
3720    /// No storage configured (`memory.backend = "none"` or unresolved alias).
3721    None,
3722    /// SQLite storage instance.
3723    Sqlite(&'a SqliteStorageConfig),
3724    /// PostgreSQL storage instance.
3725    Postgres(&'a PostgresStorageConfig),
3726    /// Qdrant storage instance.
3727    Qdrant(&'a QdrantStorageConfig),
3728    /// Markdown directory storage instance.
3729    Markdown(&'a MarkdownStorageConfig),
3730    /// Lucid CLI sync instance.
3731    Lucid(&'a LucidStorageConfig),
3732}
3733
3734impl ActiveStorage<'_> {
3735    /// Backend type name (`"sqlite"`, `"postgres"`, etc.); `"none"` for unconfigured.
3736    #[must_use]
3737    pub fn kind(&self) -> &'static str {
3738        match self {
3739            ActiveStorage::None => "none",
3740            ActiveStorage::Sqlite(_) => "sqlite",
3741            ActiveStorage::Postgres(_) => "postgres",
3742            ActiveStorage::Qdrant(_) => "qdrant",
3743            ActiveStorage::Markdown(_) => "markdown",
3744            ActiveStorage::Lucid(_) => "lucid",
3745        }
3746    }
3747}
3748
3749fn default_delegate_timeout_secs() -> u64 {
3750    DEFAULT_DELEGATE_TIMEOUT_SECS
3751}
3752
3753fn default_delegate_agentic_timeout_secs() -> u64 {
3754    DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS
3755}
3756
3757/// Valid temperature range for all paths (config, CLI, env override).
3758pub const TEMPERATURE_RANGE: std::ops::RangeInclusive<f64> = 0.0..=2.0;
3759
3760/// Defaults to 0 so configs without an explicit `schema_version` are recognized
3761/// as pre-versioning and get migrated.
3762fn default_schema_version() -> u32 {
3763    0
3764}
3765
3766/// Default delegate tool timeout for non-agentic calls: 120 seconds.
3767pub const DEFAULT_DELEGATE_TIMEOUT_SECS: u64 = 120;
3768
3769/// Default delegate tool timeout for agentic runs: 300 seconds.
3770pub const DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS: u64 = 300;
3771
3772/// Per-channel reply-pacing accessor. Implemented by every `*Config`
3773/// struct that participates in outbound pacing so validation and
3774/// wrapper construction can walk all of them through a single
3775/// abstraction rather than nine duplicated method calls.
3776pub trait HasReplyPacing {
3777    fn reply_min_interval_secs(&self) -> u64;
3778    fn reply_queue_depth_max(&self) -> u16;
3779}
3780
3781macro_rules! impl_reply_pacing {
3782    ($($ty:ty),+ $(,)?) => {
3783        $(impl HasReplyPacing for $ty {
3784            fn reply_min_interval_secs(&self) -> u64 { self.reply_min_interval_secs }
3785            fn reply_queue_depth_max(&self) -> u16 { self.reply_queue_depth_max }
3786        })+
3787    };
3788}
3789
3790/// Inclusive upper bound (seconds) for per-channel `reply_min_interval_secs`.
3791pub const REPLY_MIN_INTERVAL_MAX_SECS: u64 = 3600;
3792
3793/// Inclusive upper bound for per-channel `reply_queue_depth_max`. The lower
3794/// bound is `0`, where `0` means "use [`DEFAULT_REPLY_QUEUE_DEPTH`] at the
3795/// pacing-wrapper construction site." A non-zero value pins the bound
3796/// explicitly. Validator rejects values above this ceiling.
3797pub const REPLY_QUEUE_DEPTH_CEILING: u16 = 1024;
3798
3799/// Fallback queue depth applied at the pacing-wrapper construction site
3800/// when a channel's `reply_queue_depth_max` is left at `0`. Sized for the
3801/// AI-pacing use case: a paced channel buffering more than this is a sign
3802/// the agent is producing replies faster than the floor will ever drain
3803/// them and the overflow log is the right signal.
3804pub const DEFAULT_REPLY_QUEUE_DEPTH: u16 = 16;
3805
3806/// Idle-state LRU cap on the pacing wrapper's per-recipient rows.
3807/// Bounds growth when a bot legitimately serves many thousands of distinct
3808/// peers. Eviction only reclaims idle rows (no queued sends, no running
3809/// worker, no in-flight dispatch), so under a pathological all-active burst
3810/// the row count can temporarily exceed this target until rows become idle —
3811/// it is not an unconditional hard bound. Each recipient's queue depth stays
3812/// bounded regardless. Not exposed in config — promote to a schema field if
3813/// an operator reports hitting it.
3814pub const PACING_RECIPIENT_CAP: usize = 1024;
3815
3816/// Validate that a temperature value is within the allowed range.
3817pub fn validate_temperature(value: f64) -> std::result::Result<f64, String> {
3818    if TEMPERATURE_RANGE.contains(&value) {
3819        Ok(value)
3820    } else {
3821        Err(format!(
3822            "temperature {value} is out of range (expected {}..={})",
3823            TEMPERATURE_RANGE.start(),
3824            TEMPERATURE_RANGE.end()
3825        ))
3826    }
3827}
3828
3829fn normalize_reasoning_effort(value: &str) -> std::result::Result<String, String> {
3830    let normalized = value.trim().to_ascii_lowercase();
3831    match normalized.as_str() {
3832        "minimal" | "low" | "medium" | "high" | "xhigh" => Ok(normalized),
3833        _ => Err(format!(
3834            "reasoning_effort {value:?} is invalid (expected one of: minimal, low, medium, high, xhigh)"
3835        )),
3836    }
3837}
3838
3839fn deserialize_reasoning_effort_opt<'de, D>(
3840    deserializer: D,
3841) -> std::result::Result<Option<String>, D::Error>
3842where
3843    D: serde::Deserializer<'de>,
3844{
3845    let value: Option<String> = Option::deserialize(deserializer)?;
3846    value
3847        .map(|raw| normalize_reasoning_effort(&raw).map_err(serde::de::Error::custom))
3848        .transpose()
3849}
3850
3851/// Deserialize an `Option<String>` that maps an empty literal `""` to
3852/// `None`. Used by `JiraConfig::email` so a config that round-tripped
3853/// `email = ""` to disk (the legacy `email: String` had no
3854/// `skip_serializing_if`) doesn't deserialize as `Some("")` and silently
3855/// break Basic auth — the email-required validation was removed when
3856/// Server/DC Bearer-token support landed, so this is the last line of
3857/// defense.
3858fn deserialize_optional_email_skip_empty<'de, D>(
3859    deserializer: D,
3860) -> std::result::Result<Option<String>, D::Error>
3861where
3862    D: serde::Deserializer<'de>,
3863{
3864    let value: Option<String> = Option::deserialize(deserializer)?;
3865    Ok(value.filter(|s| !s.trim().is_empty()))
3866}
3867
3868// ── Hardware Config (wizard-driven) ─────────────────────────────
3869
3870/// Hardware transport mode.
3871#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
3872#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3873pub enum HardwareTransport {
3874    #[default]
3875    None,
3876    Native,
3877    Serial,
3878    Probe,
3879}
3880
3881impl std::fmt::Display for HardwareTransport {
3882    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
3883        match self {
3884            Self::None => write!(f, "none"),
3885            Self::Native => write!(f, "native"),
3886            Self::Serial => write!(f, "serial"),
3887            Self::Probe => write!(f, "probe"),
3888        }
3889    }
3890}
3891
3892/// Wizard-driven hardware configuration for physical world interaction.
3893#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3894#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3895#[prefix = "hardware"]
3896pub struct HardwareConfig {
3897    /// 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.
3898    #[serde(default)]
3899    pub enabled: bool,
3900    /// 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.
3901    #[serde(default)]
3902    pub transport: HardwareTransport,
3903    /// TTY path for the `serial` transport — e.g. `/dev/ttyACM0` on Linux, `/dev/tty.usbmodem1` on macOS, `COM3` on Windows. Ignored for other transports.
3904    #[serde(default)]
3905    pub serial_port: Option<String>,
3906    /// 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.
3907    #[serde(default = "default_baud_rate")]
3908    pub baud_rate: u32,
3909    /// 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.
3910    #[serde(default)]
3911    pub probe_target: Option<String>,
3912    /// 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.
3913    #[serde(default)]
3914    pub workspace_datasheets: bool,
3915}
3916
3917fn default_baud_rate() -> u32 {
3918    115_200
3919}
3920
3921impl HardwareConfig {
3922    /// Return the active transport mode.
3923    pub fn transport_mode(&self) -> HardwareTransport {
3924        self.transport.clone()
3925    }
3926}
3927
3928impl Default for HardwareConfig {
3929    fn default() -> Self {
3930        Self {
3931            enabled: false,
3932            transport: HardwareTransport::None,
3933            serial_port: None,
3934            baud_rate: default_baud_rate(),
3935            probe_target: None,
3936            workspace_datasheets: false,
3937        }
3938    }
3939}
3940
3941// ── Transcription ────────────────────────────────────────────────
3942
3943fn default_transcription_api_url() -> String {
3944    "https://api.groq.com/openai/v1/audio/transcriptions".into()
3945}
3946
3947fn default_transcription_model() -> String {
3948    "whisper-large-v3-turbo".into()
3949}
3950
3951fn default_transcription_max_duration_secs() -> u64 {
3952    120
3953}
3954
3955fn default_openai_stt_model() -> String {
3956    "whisper-1".into()
3957}
3958
3959fn default_deepgram_stt_model() -> String {
3960    "nova-2".into()
3961}
3962
3963fn default_google_stt_language_code() -> String {
3964    "en-US".into()
3965}
3966
3967/// Voice transcription configuration with multi-provider support.
3968///
3969/// The top-level `api_url`, `model`, and `api_key` fields remain for backward
3970/// compatibility with existing Groq-based configurations.
3971#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
3972#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
3973#[prefix = "transcription"]
3974pub struct TranscriptionConfig {
3975    /// Enable voice transcription for channels that support it.
3976    #[serde(default)]
3977    pub enabled: bool,
3978    /// API key used for transcription requests (Groq transcription provider).
3979    ///
3980    /// If unset, runtime falls back to `GROQ_API_KEY` for backward compatibility.
3981    #[serde(default)]
3982    #[secret]
3983    #[credential_class = "encrypted_secret"]
3984    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
3985    pub api_key: Option<String>,
3986    /// Whisper API endpoint URL (Groq transcription provider).
3987    #[serde(default = "default_transcription_api_url")]
3988    pub api_url: String,
3989    /// Whisper model name (Groq transcription provider).
3990    #[serde(default = "default_transcription_model")]
3991    pub model: String,
3992    /// Optional language hint (ISO-639-1, e.g. "en", "ru") for Groq transcription provider.
3993    #[serde(default)]
3994    pub language: Option<String>,
3995    /// Optional initial prompt to bias transcription toward expected vocabulary
3996    /// (proper nouns, technical terms, etc.). Sent as the `prompt` field in the
3997    /// Whisper API request.
3998    #[serde(default)]
3999    pub initial_prompt: Option<String>,
4000    /// Optional global audio size upper bound in bytes, enforced before
4001    /// dispatching to any transcription provider. Provider-specific caps still
4002    /// apply.
4003    #[serde(default)]
4004    pub max_audio_bytes: Option<usize>,
4005    /// Maximum voice duration in seconds (messages longer than this are skipped).
4006    #[serde(default = "default_transcription_max_duration_secs")]
4007    pub max_duration_secs: u64,
4008    /// OpenAI Whisper STT model_provider configuration.
4009    #[serde(default)]
4010    #[nested]
4011    pub openai: Option<OpenAiSttConfig>,
4012    /// Deepgram STT model_provider configuration.
4013    #[serde(default)]
4014    #[nested]
4015    pub deepgram: Option<DeepgramSttConfig>,
4016    /// AssemblyAI STT model_provider configuration.
4017    #[serde(default)]
4018    #[nested]
4019    pub assemblyai: Option<AssemblyAiSttConfig>,
4020    /// Google Cloud Speech-to-Text model_provider configuration.
4021    #[serde(default)]
4022    #[nested]
4023    pub google: Option<GoogleSttConfig>,
4024    /// Local/self-hosted Whisper-compatible STT model_provider.
4025    #[serde(default)]
4026    #[nested]
4027    pub local_whisper: Option<LocalWhisperConfig>,
4028    /// Also transcribe non-PTT (forwarded/regular) audio messages on WhatsApp,
4029    /// not just voice notes.  Default: `false` (preserves legacy behavior).
4030    #[serde(default)]
4031    pub transcribe_non_ptt_audio: bool,
4032}
4033
4034impl Default for TranscriptionConfig {
4035    fn default() -> Self {
4036        Self {
4037            enabled: false,
4038            api_key: None,
4039            api_url: default_transcription_api_url(),
4040            model: default_transcription_model(),
4041            language: None,
4042            initial_prompt: None,
4043            max_audio_bytes: None,
4044            max_duration_secs: default_transcription_max_duration_secs(),
4045            openai: None,
4046            deepgram: None,
4047            assemblyai: None,
4048            google: None,
4049            local_whisper: None,
4050            transcribe_non_ptt_audio: false,
4051        }
4052    }
4053}
4054
4055// ── MCP ─────────────────────────────────────────────────────────
4056
4057/// Transport type for MCP server connections.
4058#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
4059#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4060#[serde(rename_all = "lowercase")]
4061pub enum McpTransport {
4062    /// Spawn a local process and communicate over stdin/stdout.
4063    #[default]
4064    Stdio,
4065    /// Connect via HTTP POST.
4066    Http,
4067    /// Connect via HTTP + Server-Sent Events.
4068    Sse,
4069}
4070
4071/// Configuration for a single external MCP server.
4072#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
4073#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4074#[prefix = "mcp.servers"]
4075pub struct McpServerConfig {
4076    /// Display name used as a tool prefix (`<server>__<tool>`). Filled in
4077    /// from the supplied `map_key` when the entry is created via
4078    /// `create_map_key("mcp.servers", "<name>")`; `#[serde(default)]` lets
4079    /// the macro default-construct from `{}` before the name gets injected.
4080    #[serde(default)]
4081    pub name: String,
4082    /// Transport type (default: stdio).
4083    #[serde(default)]
4084    pub transport: McpTransport,
4085    /// URL for HTTP/SSE transports.
4086    #[serde(default)]
4087    pub url: Option<String>,
4088    /// Executable to spawn for stdio transport.
4089    #[serde(default)]
4090    pub command: String,
4091    /// Command arguments for stdio transport.
4092    #[serde(default)]
4093    pub args: Vec<String>,
4094    /// Optional environment variables for stdio transport.
4095    #[serde(default)]
4096    #[secret]
4097    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4098    pub env: HashMap<String, String>,
4099    /// Optional HTTP headers for HTTP/SSE transports. Treated as secret:
4100    /// the values commonly carry Bearer tokens for the upstream MCP server.
4101    #[serde(default)]
4102    #[secret]
4103    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4104    pub headers: HashMap<String, String>,
4105    /// Optional per-call timeout in seconds (hard capped in validation).
4106    #[serde(default)]
4107    pub tool_timeout_secs: Option<u64>,
4108}
4109
4110/// External MCP client configuration (`[mcp]` section).
4111#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4112#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4113#[prefix = "mcp"]
4114pub struct McpConfig {
4115    /// Enable MCP tool loading.
4116    #[tab(Settings)]
4117    #[serde(default = "default_mcp_enabled")]
4118    pub enabled: bool,
4119    /// Load MCP tool schemas on-demand via `tool_search` instead of eagerly
4120    /// including them in the LLM context window. When enabled, only tool names
4121    /// are listed in the system prompt; the LLM must call `tool_search` to fetch
4122    /// full schemas before invoking a deferred tool.
4123    #[tab(Settings)]
4124    #[serde(default = "default_deferred_loading")]
4125    pub deferred_loading: bool,
4126    /// Configured MCP servers. The `#[nested]` annotation makes the macro
4127    /// expose this as a List section in `map_key_sections()`, so the
4128    /// dashboard's `+ Add MCP server` affordance and the `POST
4129    /// /api/config/map-key?path=mcp.servers&key=<name>` endpoint pick it
4130    /// up automatically (no hand-table on the gateway side).
4131    #[tab(Servers)]
4132    #[serde(default, alias = "mcpServers")]
4133    #[nested]
4134    pub servers: Vec<McpServerConfig>,
4135}
4136
4137fn default_mcp_enabled() -> bool {
4138    true
4139}
4140
4141fn default_deferred_loading() -> bool {
4142    false
4143}
4144
4145impl Default for McpConfig {
4146    fn default() -> Self {
4147        Self {
4148            enabled: default_mcp_enabled(),
4149            deferred_loading: default_deferred_loading(),
4150            servers: Vec::new(),
4151        }
4152    }
4153}
4154
4155/// Verifiable Intent (VI) credential verification and issuance (`[verifiable_intent]` section).
4156#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4157#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4158#[prefix = "verifiable_intent"]
4159pub struct VerifiableIntentConfig {
4160    /// Enable VI credential verification on commerce tool calls (default: false).
4161    #[serde(default)]
4162    pub enabled: bool,
4163
4164    /// Strictness mode for constraint evaluation: "strict" (fail-closed on unknown
4165    /// constraint types) or "permissive" (skip unknown types with a warning).
4166    /// Default: "strict".
4167    #[serde(default = "default_vi_strictness")]
4168    pub strictness: String,
4169}
4170
4171fn default_vi_strictness() -> String {
4172    "strict".to_owned()
4173}
4174
4175impl Default for VerifiableIntentConfig {
4176    fn default() -> Self {
4177        Self {
4178            enabled: false,
4179            strictness: default_vi_strictness(),
4180        }
4181    }
4182}
4183
4184// ── Nodes (Dynamic Node Discovery) ───────────────────────────────
4185
4186/// Configuration for the dynamic node discovery system (`[nodes]`).
4187///
4188/// When enabled, external processes/devices can connect via WebSocket
4189/// at `/ws/nodes` and advertise their capabilities at runtime.
4190#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4191#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4192#[prefix = "nodes"]
4193pub struct NodesConfig {
4194    /// Enable dynamic node discovery endpoint.
4195    #[serde(default)]
4196    pub enabled: bool,
4197    /// Maximum number of concurrent node connections.
4198    #[serde(default = "default_max_nodes")]
4199    pub max_nodes: usize,
4200    /// Optional bearer token for node authentication.
4201    #[serde(default)]
4202    #[secret]
4203    #[credential_class = "encrypted_secret"]
4204    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4205    pub auth_token: Option<String>,
4206}
4207
4208fn default_max_nodes() -> usize {
4209    16
4210}
4211
4212impl Default for NodesConfig {
4213    fn default() -> Self {
4214        Self {
4215            enabled: false,
4216            max_nodes: default_max_nodes(),
4217            auth_token: None,
4218        }
4219    }
4220}
4221
4222// ── TTS (Text-to-Speech) ─────────────────────────────────────────
4223
4224fn default_tts_voice() -> String {
4225    "alloy".into()
4226}
4227
4228fn default_tts_format() -> String {
4229    "mp3".into()
4230}
4231
4232fn default_tts_max_text_length() -> usize {
4233    4096
4234}
4235
4236/// Text-to-Speech subsystem configuration (`[tts]`).
4237///
4238/// Per-instance TTS configs live under `[tts_providers.<type>.<alias>]`
4239/// (parallel to `providers.models`). What remains here are the global
4240/// runtime knobs that apply to every model_provider invocation.
4241#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4242#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4243#[prefix = "tts"]
4244pub struct TtsConfig {
4245    /// Enable TTS synthesis.
4246    #[serde(default)]
4247    pub enabled: bool,
4248    /// Default voice ID passed to the selected tts provider.
4249    #[serde(default = "default_tts_voice")]
4250    pub default_voice: String,
4251    /// Default audio output format (`"mp3"`, `"opus"`, `"wav"`).
4252    #[serde(default = "default_tts_format")]
4253    pub default_format: String,
4254    /// Maximum input text length in characters (default 4096).
4255    #[serde(default = "default_tts_max_text_length")]
4256    pub max_text_length: usize,
4257}
4258
4259impl Default for TtsConfig {
4260    fn default() -> Self {
4261        Self {
4262            enabled: false,
4263            default_voice: default_tts_voice(),
4264            default_format: default_tts_format(),
4265            max_text_length: default_tts_max_text_length(),
4266        }
4267    }
4268}
4269
4270/// Per-instance TTS model_provider configuration (`[tts_providers.<type>.<alias>]`).
4271///
4272/// Mirrors `ModelProviderConfig` in shape — one struct holds the union of
4273/// fields across backends. Only the fields relevant to the selected backend
4274/// (determined by the outer `<type>` map key) are read at runtime; others
4275/// are quietly ignored.
4276#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4277#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4278#[prefix = "tts_provider"]
4279#[serde(default)]
4280pub struct TtsProviderConfig {
4281    /// API key (openai, elevenlabs, google).
4282    #[secret]
4283    #[credential_class = "encrypted_secret"]
4284    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4285    pub api_key: Option<String>,
4286    /// Model name. OpenAI uses this for `tts-1`/`tts-1-hd`; elevenlabs uses
4287    /// it as the model_id (e.g. `eleven_monolingual_v1`).
4288    pub model: Option<String>,
4289    /// Voice override for this instance. When empty, falls back to
4290    /// `[tts].default_voice`.
4291    pub voice: Option<String>,
4292    /// Playback speed multiplier (openai only; default `1.0`).
4293    pub speed: Option<f64>,
4294    /// Voice stability for elevenlabs (0.0-1.0; default `0.5`).
4295    pub stability: Option<f64>,
4296    /// Similarity boost for elevenlabs (0.0-1.0; default `0.5`).
4297    pub similarity_boost: Option<f64>,
4298    /// Language code for google (e.g. `en-US`).
4299    pub language_code: Option<String>,
4300    /// Path to backend binary (edge-tts subprocess; piper local server).
4301    pub binary_path: Option<String>,
4302    /// Audio response format sent to the TTS backend (e.g. `"opus"`, `"mp3"`,
4303    /// `"wav"`). Defaults to `"opus"` for the OpenAI family. Override to
4304    /// `"wav"` for Orpheus-class models (e.g. `canopylabs/orpheus-v1-english`
4305    /// on Groq) or `"mp3"` for broader compatibility.
4306    pub response_format: Option<String>,
4307    /// Endpoint URI for HTTP-based backends. Overrides the family default
4308    /// when pointing at a compatible third-party API (Groq, Azure, self-hosted
4309    /// proxies). Set to the **full** URL — there is no separate path-suffix
4310    /// field. Renamed from `api_url` for parity with `ModelProviderConfig.uri`.
4311    #[serde(alias = "api_url")]
4312    pub uri: Option<String>,
4313}
4314
4315// ── TTS endpoint trait + per-family typed configs ──────────────────────────
4316//
4317// Mirrors the model provider typed-family pattern. Each TTS family carries
4318// its own typed config (composing TtsProviderConfig as the shared base via
4319// `#[serde(flatten)]`) and a single-variant `*TtsEndpoint` enum impl'ing
4320// `TtsEndpoint`. Edge and Piper skip the base — they're subprocess / local
4321// runtimes with no shared `api_key` / `voice` defaults.
4322
4323/// One trait per family-endpoint enum. Returns the URI for the chosen
4324/// variant. Mirrors `ModelEndpoint` for parity across model and TTS.
4325pub trait TtsEndpoint {
4326    fn uri(&self) -> &'static str;
4327}
4328
4329#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4330#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4331#[serde(rename_all = "snake_case")]
4332pub enum OpenAITtsEndpoint {
4333    #[default]
4334    Default,
4335}
4336impl TtsEndpoint for OpenAITtsEndpoint {
4337    fn uri(&self) -> &'static str {
4338        match self {
4339            Self::Default => "https://api.openai.com/v1/audio/speech",
4340        }
4341    }
4342}
4343
4344#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4345#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4346#[prefix = "providers.tts.openai"]
4347pub struct OpenAITtsProviderConfig {
4348    #[nested]
4349    #[serde(flatten)]
4350    pub base: TtsProviderConfig,
4351}
4352
4353#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4354#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4355#[serde(rename_all = "snake_case")]
4356pub enum ElevenLabsTtsEndpoint {
4357    #[default]
4358    Default,
4359}
4360impl TtsEndpoint for ElevenLabsTtsEndpoint {
4361    fn uri(&self) -> &'static str {
4362        match self {
4363            Self::Default => "https://api.elevenlabs.io/v1/text-to-speech",
4364        }
4365    }
4366}
4367
4368#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4369#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4370#[prefix = "providers.tts.elevenlabs"]
4371pub struct ElevenLabsTtsProviderConfig {
4372    #[nested]
4373    #[serde(flatten)]
4374    pub base: TtsProviderConfig,
4375}
4376
4377#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4378#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4379#[serde(rename_all = "snake_case")]
4380pub enum GoogleTtsEndpoint {
4381    #[default]
4382    Default,
4383}
4384impl TtsEndpoint for GoogleTtsEndpoint {
4385    fn uri(&self) -> &'static str {
4386        match self {
4387            Self::Default => "https://texttospeech.googleapis.com/v1/text:synthesize",
4388        }
4389    }
4390}
4391
4392#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4393#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4394#[prefix = "providers.tts.google"]
4395pub struct GoogleTtsProviderConfig {
4396    #[nested]
4397    #[serde(flatten)]
4398    pub base: TtsProviderConfig,
4399}
4400
4401#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4402#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4403#[serde(rename_all = "snake_case")]
4404pub enum EdgeTtsEndpoint {
4405    /// Subprocess — no remote endpoint. Sentinel for trait conformity.
4406    #[default]
4407    LocalSubprocess,
4408}
4409impl TtsEndpoint for EdgeTtsEndpoint {
4410    fn uri(&self) -> &'static str {
4411        match self {
4412            Self::LocalSubprocess => "subprocess://edge-tts",
4413        }
4414    }
4415}
4416
4417#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4418#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4419#[prefix = "providers.tts.edge"]
4420pub struct EdgeTtsProviderConfig {
4421    #[nested]
4422    #[serde(flatten)]
4423    pub base: TtsProviderConfig,
4424}
4425
4426#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4427#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4428#[serde(rename_all = "snake_case")]
4429pub enum PiperTtsEndpoint {
4430    #[default]
4431    LocalDefault,
4432}
4433impl TtsEndpoint for PiperTtsEndpoint {
4434    fn uri(&self) -> &'static str {
4435        match self {
4436            Self::LocalDefault => "http://127.0.0.1:5000/v1/audio/speech",
4437        }
4438    }
4439}
4440
4441#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4442#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4443#[prefix = "providers.tts.piper"]
4444pub struct PiperTtsProviderConfig {
4445    #[nested]
4446    #[serde(flatten)]
4447    pub base: TtsProviderConfig,
4448}
4449
4450// ── Transcription providers (typed-family split, mirrors models/tts) ────
4451//
4452// Six family slots: `groq`, `openai`, `deepgram`, `assemblyai`, `google`,
4453// `local_whisper`. Each is a `HashMap<String, *TranscriptionProviderConfig>`
4454// keyed by operator-chosen alias. The shared `TranscriptionProviderConfig`
4455// base carries `api_key` + `language` since every cloud STT family takes
4456// both; `local_whisper` skips the base because it's a self-hosted endpoint
4457// with its own auth token, not a vendor API key.
4458
4459/// Shared base for cloud transcription providers. Each cloud family
4460/// composes this via `#[serde(flatten)] base: TranscriptionProviderConfig`.
4461#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4462#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4463#[prefix = "providers.transcription"]
4464pub struct TranscriptionProviderConfig {
4465    /// API key for the transcription provider.
4466    #[serde(default)]
4467    #[secret]
4468    #[credential_class = "encrypted_secret"]
4469    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4470    pub api_key: Option<String>,
4471    /// Optional language hint passed to the provider (ISO-639-1 like `"en"` /
4472    /// `"ru"`, or BCP-47 like `"en-US"` for Google). Most providers auto-detect
4473    /// when this is unset.
4474    #[serde(default)]
4475    pub language: Option<String>,
4476    /// Whisper-style initial prompt to bias the model toward expected
4477    /// vocabulary (proper nouns, technical terms). Provider-specific support;
4478    /// silently ignored where not applicable.
4479    #[serde(default)]
4480    pub initial_prompt: Option<String>,
4481}
4482
4483/// Trait that every transcription endpoint enum implements. Mirrors
4484/// `ModelEndpoint` / `TtsEndpoint` for parity.
4485pub trait TranscriptionEndpoint {
4486    fn uri(&self) -> &'static str;
4487}
4488
4489#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4490#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4491#[serde(rename_all = "snake_case")]
4492pub enum GroqTranscriptionEndpoint {
4493    #[default]
4494    Default,
4495}
4496impl TranscriptionEndpoint for GroqTranscriptionEndpoint {
4497    fn uri(&self) -> &'static str {
4498        match self {
4499            Self::Default => "https://api.groq.com/openai/v1/audio/transcriptions",
4500        }
4501    }
4502}
4503
4504#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4505#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4506#[prefix = "providers.transcription.groq"]
4507pub struct GroqTranscriptionProviderConfig {
4508    #[nested]
4509    #[serde(flatten)]
4510    pub base: TranscriptionProviderConfig,
4511    /// Whisper model name (default: `"whisper-large-v3-turbo"`).
4512    #[serde(default)]
4513    pub model: Option<String>,
4514}
4515
4516#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4517#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4518#[serde(rename_all = "snake_case")]
4519pub enum OpenAiTranscriptionEndpoint {
4520    #[default]
4521    Default,
4522}
4523impl TranscriptionEndpoint for OpenAiTranscriptionEndpoint {
4524    fn uri(&self) -> &'static str {
4525        match self {
4526            Self::Default => "https://api.openai.com/v1/audio/transcriptions",
4527        }
4528    }
4529}
4530
4531#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4532#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4533#[prefix = "providers.transcription.openai"]
4534pub struct OpenAiTranscriptionProviderConfig {
4535    #[nested]
4536    #[serde(flatten)]
4537    pub base: TranscriptionProviderConfig,
4538    /// Whisper model name (default: `"whisper-1"`).
4539    #[serde(default)]
4540    pub model: Option<String>,
4541}
4542
4543#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4544#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4545#[serde(rename_all = "snake_case")]
4546pub enum DeepgramTranscriptionEndpoint {
4547    #[default]
4548    Default,
4549}
4550impl TranscriptionEndpoint for DeepgramTranscriptionEndpoint {
4551    fn uri(&self) -> &'static str {
4552        match self {
4553            Self::Default => "https://api.deepgram.com/v1/listen",
4554        }
4555    }
4556}
4557
4558#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4559#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4560#[prefix = "providers.transcription.deepgram"]
4561pub struct DeepgramTranscriptionProviderConfig {
4562    #[nested]
4563    #[serde(flatten)]
4564    pub base: TranscriptionProviderConfig,
4565    /// Deepgram model name (default: `"nova-2"`).
4566    #[serde(default)]
4567    pub model: Option<String>,
4568}
4569
4570#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4571#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4572#[serde(rename_all = "snake_case")]
4573pub enum AssemblyAiTranscriptionEndpoint {
4574    #[default]
4575    Default,
4576}
4577impl TranscriptionEndpoint for AssemblyAiTranscriptionEndpoint {
4578    fn uri(&self) -> &'static str {
4579        match self {
4580            Self::Default => "https://api.assemblyai.com/v2/transcript",
4581        }
4582    }
4583}
4584
4585#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4586#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4587#[prefix = "providers.transcription.assemblyai"]
4588pub struct AssemblyAiTranscriptionProviderConfig {
4589    #[nested]
4590    #[serde(flatten)]
4591    pub base: TranscriptionProviderConfig,
4592}
4593
4594#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4595#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4596#[serde(rename_all = "snake_case")]
4597pub enum GoogleTranscriptionEndpoint {
4598    #[default]
4599    Default,
4600}
4601impl TranscriptionEndpoint for GoogleTranscriptionEndpoint {
4602    fn uri(&self) -> &'static str {
4603        match self {
4604            Self::Default => "https://speech.googleapis.com/v1/speech:recognize",
4605        }
4606    }
4607}
4608
4609#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4610#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4611#[prefix = "providers.transcription.google"]
4612pub struct GoogleTranscriptionProviderConfig {
4613    #[nested]
4614    #[serde(flatten)]
4615    pub base: TranscriptionProviderConfig,
4616}
4617
4618#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4619#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4620#[serde(rename_all = "snake_case")]
4621pub enum LocalWhisperTranscriptionEndpoint {
4622    /// Self-hosted endpoint — no remote URL. Sentinel for trait conformity.
4623    /// The actual URL lives on `LocalWhisperTranscriptionProviderConfig.uri`.
4624    #[default]
4625    SelfHosted,
4626}
4627impl TranscriptionEndpoint for LocalWhisperTranscriptionEndpoint {
4628    fn uri(&self) -> &'static str {
4629        match self {
4630            Self::SelfHosted => "self-hosted",
4631        }
4632    }
4633}
4634
4635/// Local / self-hosted Whisper-compatible transcription endpoint. Skips the
4636/// shared `TranscriptionProviderConfig` base because it uses a bearer-token
4637/// scheme and a per-instance URL rather than a vendor API key.
4638#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4639#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4640#[prefix = "providers.transcription.local_whisper"]
4641pub struct LocalWhisperTranscriptionProviderConfig {
4642    /// Endpoint URL, e.g. `"http://10.10.0.1:8001/v1/transcribe"`.
4643    pub uri: String,
4644    /// Bearer token for endpoint authentication. Omit for unauthenticated
4645    /// local endpoints.
4646    #[serde(default)]
4647    #[secret]
4648    #[credential_class = "encrypted_secret"]
4649    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4650    pub bearer_token: Option<String>,
4651    /// Optional language hint (passed through to the local endpoint).
4652    #[serde(default)]
4653    pub language: Option<String>,
4654    /// Maximum audio file size in bytes accepted by this endpoint.
4655    /// Defaults to 25 MB to match the cloud cap; raise as needed.
4656    #[serde(default = "default_local_whisper_max_audio_bytes")]
4657    pub max_audio_bytes: usize,
4658    /// Request timeout in seconds.
4659    #[serde(default = "default_local_whisper_timeout_secs")]
4660    pub timeout_secs: u64,
4661}
4662
4663/// Determines when a `ToolFilterGroup` is active.
4664#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
4665#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4666#[serde(rename_all = "snake_case")]
4667pub enum ToolFilterGroupMode {
4668    /// Tools in this group are always included in every turn.
4669    Always,
4670    /// Tools in this group are included only when the user message contains
4671    /// at least one of the configured `keywords` (case-insensitive substring match).
4672    #[default]
4673    Dynamic,
4674}
4675
4676/// A named group of MCP tool patterns with an activation mode.
4677///
4678/// Each group lists glob patterns for MCP tool names (prefix `mcp_`) and an
4679/// optional set of keywords that trigger inclusion in `dynamic` mode.
4680/// Built-in (non-MCP) tools always pass through and are never affected by
4681/// `tool_filter_groups`.
4682///
4683/// # Example
4684/// ```toml
4685/// [[agent.tool_filter_groups]]
4686/// mode = "always"
4687/// tools = ["mcp_filesystem_*"]
4688/// keywords = []
4689///
4690/// [[agent.tool_filter_groups]]
4691/// mode = "dynamic"
4692/// tools = ["mcp_browser_*"]
4693/// keywords = ["browse", "website", "url", "search"]
4694/// ```
4695#[derive(Debug, Clone, Serialize, Deserialize)]
4696#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4697pub struct ToolFilterGroup {
4698    /// Activation mode: `"always"` or `"dynamic"`.
4699    #[serde(default)]
4700    pub mode: ToolFilterGroupMode,
4701    /// Glob patterns matching MCP tool names (single `*` wildcard supported).
4702    #[serde(default)]
4703    pub tools: Vec<String>,
4704    /// Keywords that activate this group in `dynamic` mode (case-insensitive substring).
4705    /// Ignored when `mode = "always"`.
4706    #[serde(default)]
4707    pub keywords: Vec<String>,
4708    /// When true, also filter built-in tools (not just MCP tools).
4709    #[serde(default)]
4710    pub filter_builtins: bool,
4711}
4712
4713/// OpenAI Whisper STT model_provider configuration (`[transcription.openai]`).
4714#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4715#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4716#[prefix = "transcription.openai"]
4717pub struct OpenAiSttConfig {
4718    /// OpenAI API key for Whisper transcription.
4719    #[serde(default)]
4720    #[secret]
4721    #[credential_class = "encrypted_secret"]
4722    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4723    pub api_key: Option<String>,
4724    /// Whisper model name (default: "whisper-1").
4725    #[serde(default = "default_openai_stt_model")]
4726    pub model: String,
4727}
4728
4729/// Deepgram STT model_provider configuration (`[transcription.deepgram]`).
4730#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4731#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4732#[prefix = "transcription.deepgram"]
4733pub struct DeepgramSttConfig {
4734    /// Deepgram API key.
4735    #[serde(default)]
4736    #[secret]
4737    #[credential_class = "encrypted_secret"]
4738    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4739    pub api_key: Option<String>,
4740    /// Deepgram model name (default: "nova-2").
4741    #[serde(default = "default_deepgram_stt_model")]
4742    pub model: String,
4743}
4744
4745/// AssemblyAI STT model_provider configuration (`[transcription.assemblyai]`).
4746#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4747#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4748#[prefix = "transcription.assemblyai"]
4749pub struct AssemblyAiSttConfig {
4750    /// AssemblyAI API key.
4751    #[serde(default)]
4752    #[secret]
4753    #[credential_class = "encrypted_secret"]
4754    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4755    pub api_key: Option<String>,
4756}
4757
4758/// Google Cloud Speech-to-Text model_provider configuration (`[transcription.google]`).
4759#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4760#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4761#[prefix = "transcription.google"]
4762pub struct GoogleSttConfig {
4763    /// Google Cloud API key.
4764    #[serde(default)]
4765    #[secret]
4766    #[credential_class = "encrypted_secret"]
4767    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4768    pub api_key: Option<String>,
4769    /// BCP-47 language code (default: "en-US").
4770    #[serde(default = "default_google_stt_language_code")]
4771    pub language_code: String,
4772}
4773
4774/// Local/self-hosted Whisper-compatible STT endpoint (`[transcription.local_whisper]`).
4775///
4776/// Configures a self-hosted STT endpoint. Can be on localhost, a private network host, or any reachable URL.
4777#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
4778#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4779#[prefix = "transcription.local_whisper"]
4780pub struct LocalWhisperConfig {
4781    /// HTTP or HTTPS endpoint URL, e.g. `"http://10.10.0.1:8001/v1/transcribe"`.
4782    pub url: String,
4783    /// Bearer token for endpoint authentication.
4784    /// Omit for unauthenticated local endpoints.
4785    #[serde(default)]
4786    #[secret]
4787    #[credential_class = "encrypted_secret"]
4788    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
4789    pub bearer_token: Option<String>,
4790    /// Maximum audio file size in bytes accepted by this endpoint.
4791    /// Defaults to 25 MB — matching the cloud API cap for a safe out-of-the-box
4792    /// experience. Self-hosted endpoints can accept much larger files; raise this
4793    /// as needed, but note that each transcription call clones the audio buffer
4794    /// into a multipart payload, so peak memory per request is ~2× this value.
4795    #[serde(default = "default_local_whisper_max_audio_bytes")]
4796    pub max_audio_bytes: usize,
4797    /// Request timeout in seconds. Defaults to 300 (large files on local GPU).
4798    #[serde(default = "default_local_whisper_timeout_secs")]
4799    pub timeout_secs: u64,
4800}
4801
4802fn default_local_whisper_max_audio_bytes() -> usize {
4803    25 * 1024 * 1024
4804}
4805
4806fn default_local_whisper_timeout_secs() -> u64 {
4807    300
4808}
4809
4810/// HMAC tool execution receipt configuration, per agent
4811/// (`[agents.<alias>.tool_receipts]`).
4812///
4813/// Receipts are short HMAC-SHA256 tags appended to tool results so the model
4814/// cannot claim it ran a tool that never actually executed. See
4815/// `docs/book/src/security/tool-receipts.md`.
4816#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4817#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4818#[prefix = "delegate_agent.tool_receipts"]
4819pub struct ToolReceiptsConfig {
4820    /// Generate HMAC receipts on every tool execution. Default: `false`.
4821    /// When false, the entire receipt subsystem is inert (no key, no
4822    /// generation, no append, no system-prompt addendum).
4823    #[serde(default)]
4824    pub enabled: bool,
4825    /// Append a trailing `Tool receipts:` block to user-visible replies so
4826    /// receipts are auditable from the channel surface, not just the
4827    /// internal history. Default: `false`.
4828    #[serde(default)]
4829    pub show_in_response: bool,
4830    /// Inject the receipt-echo instruction into the system prompt so the
4831    /// model carries receipts verbatim into its response. Default: `true`.
4832    /// No effect when `enabled = false`.
4833    #[serde(default = "default_inject_system_prompt")]
4834    pub inject_system_prompt: bool,
4835}
4836
4837fn default_inject_system_prompt() -> bool {
4838    true
4839}
4840
4841impl Default for ToolReceiptsConfig {
4842    fn default() -> Self {
4843        Self {
4844            enabled: false,
4845            show_in_response: false,
4846            inject_system_prompt: default_inject_system_prompt(),
4847        }
4848    }
4849}
4850
4851fn default_max_tool_result_chars() -> usize {
4852    50_000
4853}
4854
4855fn default_keep_tool_context_turns() -> usize {
4856    2
4857}
4858
4859fn default_agent_tool_dispatcher() -> String {
4860    "auto".into()
4861}
4862
4863fn default_max_system_prompt_chars() -> usize {
4864    0
4865}
4866
4867// ── Pacing ────────────────────────────────────────────────────────
4868
4869/// Pacing controls for slow/local LLM workloads (`[pacing]` section).
4870///
4871/// All fields are optional and default to values that preserve existing
4872/// behavior. When set, they extend — not replace — the existing timeout
4873/// and loop-detection subsystems.
4874#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
4875#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4876#[prefix = "pacing"]
4877pub struct PacingConfig {
4878    /// Per-step timeout in seconds: the maximum time allowed for a single
4879    /// LLM inference turn, independent of the total message budget.
4880    /// `None` means no per-step timeout (existing behavior).
4881    #[serde(default)]
4882    pub step_timeout_secs: Option<u64>,
4883
4884    /// Minimum elapsed seconds before loop detection activates.
4885    /// Tasks completing under this threshold get aggressive loop protection;
4886    /// longer-running tasks receive a grace period before the detector starts
4887    /// counting. `None` means loop detection is always active (existing behavior).
4888    #[serde(default)]
4889    pub loop_detection_min_elapsed_secs: Option<u64>,
4890
4891    /// Tool names excluded from identical-output / alternating-pattern loop
4892    /// detection. Useful for browser workflows where `browser_screenshot`
4893    /// structurally resembles a loop even when making progress.
4894    #[serde(default)]
4895    pub loop_ignore_tools: Vec<String>,
4896
4897    /// Override for the hardcoded timeout scaling cap (default: 4).
4898    /// The channel message timeout budget is computed as:
4899    ///   `message_timeout_secs * min(max_tool_iterations, message_timeout_scale_max)`
4900    /// Raising this value lets long multi-step tasks with slow local models
4901    /// receive a proportionally larger budget without inflating the base timeout.
4902    #[serde(default)]
4903    pub message_timeout_scale_max: Option<u64>,
4904
4905    /// Enable pattern-based loop detection (exact repeat, ping-pong,
4906    /// no-progress). Defaults to `true`.
4907    #[serde(default = "default_loop_detection_enabled")]
4908    pub loop_detection_enabled: bool,
4909
4910    /// Sliding window size for the pattern-based loop detector.
4911    /// Defaults to 20.
4912    #[serde(default = "default_loop_detection_window_size")]
4913    pub loop_detection_window_size: usize,
4914
4915    /// Number of consecutive identical tool+args calls before the first
4916    /// escalation (Warning). Defaults to 3.
4917    #[serde(default = "default_loop_detection_max_repeats")]
4918    pub loop_detection_max_repeats: usize,
4919}
4920
4921fn default_loop_detection_enabled() -> bool {
4922    true
4923}
4924
4925fn default_loop_detection_window_size() -> usize {
4926    20
4927}
4928
4929fn default_loop_detection_max_repeats() -> usize {
4930    3
4931}
4932
4933impl Default for PacingConfig {
4934    fn default() -> Self {
4935        Self {
4936            step_timeout_secs: None,
4937            loop_detection_min_elapsed_secs: None,
4938            loop_ignore_tools: Vec::new(),
4939            message_timeout_scale_max: None,
4940            loop_detection_enabled: default_loop_detection_enabled(),
4941            loop_detection_window_size: default_loop_detection_window_size(),
4942            loop_detection_max_repeats: default_loop_detection_max_repeats(),
4943        }
4944    }
4945}
4946
4947/// Skills loading configuration (`[skills]` section).
4948#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
4949#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4950#[serde(rename_all = "snake_case")]
4951pub enum SkillsPromptInjectionMode {
4952    /// Inline full skill instructions and tool metadata into the system prompt.
4953    #[default]
4954    Full,
4955    /// Inline only compact skill metadata (name/description/location) and load details on demand.
4956    Compact,
4957}
4958
4959/// Skills loading configuration (`[skills]` section).
4960#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
4961#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
4962#[prefix = "skills"]
4963pub struct SkillsConfig {
4964    /// Enable loading and syncing the community open-skills repository.
4965    /// Default: `false` (opt-in).
4966    #[serde(default)]
4967    pub open_skills_enabled: bool,
4968    /// Optional path to a local open-skills repository.
4969    /// If unset, defaults to `$HOME/open-skills` when enabled.
4970    #[serde(default)]
4971    pub open_skills_dir: Option<String>,
4972    /// Allow script-like files in skills (`.sh`, `.bash`, `.ps1`, shebang shell files).
4973    /// Default: `false` (secure by default).
4974    #[serde(default)]
4975    pub allow_scripts: bool,
4976    /// URL of the skills registry repository for bare-name installs.
4977    /// Default: `https://github.com/zeroclaw-labs/zeroclaw-skills`
4978    #[serde(default)]
4979    pub registry_url: Option<String>,
4980    /// Controls how skills are injected into the system prompt.
4981    /// `full` preserves legacy behavior. `compact` keeps context small and loads skills on demand.
4982    #[serde(default)]
4983    pub prompt_injection_mode: SkillsPromptInjectionMode,
4984    /// Autonomous skill creation from successful multi-step task executions.
4985    #[serde(default)]
4986    #[nested]
4987    pub skill_creation: SkillCreationConfig,
4988    /// Prompt-triggered install suggestions for missing skills.
4989    #[serde(default, alias = "install-suggestions")]
4990    #[nested]
4991    pub install_suggestions: SkillInstallSuggestionsConfig,
4992    /// Automatic skill self-improvement after successful skill usage.
4993    #[serde(default)]
4994    #[nested]
4995    pub skill_improvement: SkillImprovementConfig,
4996}
4997
4998/// Autonomous skill creation configuration (`[skills.skill_creation]` section).
4999#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5000#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5001#[prefix = "skills.skill_creation"]
5002#[serde(default)]
5003pub struct SkillCreationConfig {
5004    /// Enable automatic skill creation after successful multi-step tasks.
5005    /// Default: `false`.
5006    pub enabled: bool,
5007    /// Maximum number of auto-generated skills to keep.
5008    /// When exceeded, the oldest auto-generated skill is removed (LRU eviction).
5009    pub max_skills: usize,
5010    /// Embedding similarity threshold for deduplication.
5011    /// Skills with descriptions more similar than this value are skipped.
5012    pub similarity_threshold: f64,
5013}
5014
5015impl Default for SkillCreationConfig {
5016    fn default() -> Self {
5017        Self {
5018            enabled: false,
5019            max_skills: 500,
5020            similarity_threshold: 0.85,
5021        }
5022    }
5023}
5024
5025/// Prompt-triggered skill install suggestions (`[skills.install_suggestions]` section).
5026#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
5027#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5028#[prefix = "skills.install_suggestions"]
5029#[serde(default)]
5030pub struct SkillInstallSuggestionsConfig {
5031    /// Enable suggestions for installable skills before normal agent turns.
5032    /// Default: `false`.
5033    pub enabled: bool,
5034}
5035
5036/// Skill self-improvement configuration (`[skills.auto_improve]` section).
5037#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5038#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5039#[prefix = "skills.skill_improvement"]
5040pub struct SkillImprovementConfig {
5041    /// Enable automatic skill improvement after successful skill usage.
5042    /// Default: `true`.
5043    #[serde(default = "default_true")]
5044    pub enabled: bool,
5045    /// Minimum interval (in seconds) between improvements for the same skill.
5046    /// Default: `3600` (1 hour).
5047    #[serde(default = "default_skill_improvement_cooldown")]
5048    pub cooldown_secs: u64,
5049}
5050
5051fn default_skill_improvement_cooldown() -> u64 {
5052    3600
5053}
5054
5055impl Default for SkillImprovementConfig {
5056    fn default() -> Self {
5057        Self {
5058            enabled: true,
5059            cooldown_secs: 3600,
5060        }
5061    }
5062}
5063
5064/// Pipeline tool configuration (`[pipeline]` section).
5065#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5066#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5067#[prefix = "pipeline"]
5068pub struct PipelineConfig {
5069    /// Enable the `execute_pipeline` meta-tool.
5070    /// Default: `false`.
5071    #[serde(default)]
5072    pub enabled: bool,
5073    /// Maximum number of steps allowed in a single pipeline invocation.
5074    /// Default: `20`.
5075    #[serde(default = "default_pipeline_max_steps")]
5076    pub max_steps: usize,
5077    /// Tools allowed in pipeline steps. Steps referencing tools not on this
5078    /// list are rejected before execution.
5079    #[serde(default)]
5080    pub allowed_tools: Vec<String>,
5081}
5082
5083fn default_pipeline_max_steps() -> usize {
5084    20
5085}
5086
5087impl Default for PipelineConfig {
5088    fn default() -> Self {
5089        Self {
5090            enabled: false,
5091            max_steps: 20,
5092            allowed_tools: Vec::new(),
5093        }
5094    }
5095}
5096
5097/// Multimodal (image) handling configuration (`[multimodal]` section).
5098///
5099/// # Privacy and cost note
5100///
5101/// Tool results that print real local image paths (e.g. shell tools doing
5102/// `ls /pictures` or `find . -name '*.png'`) are canonicalized into
5103/// `[IMAGE:...]` markers and base64-inlined into the next provider request.
5104/// This means image bytes that previously stayed local will be uploaded to
5105/// the configured provider when surfaced by a tool.
5106///
5107/// `max_images` (and the `trim_old_images` LRU policy) bounds the per-request
5108/// image budget, but operators running shell-style tools over directories of
5109/// personal or sensitive images should be aware of the upload semantics. See
5110/// `docs/book/src/contributing/privacy.md` for the project's privacy stance.
5111#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5112#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5113#[prefix = "multimodal"]
5114pub struct MultimodalConfig {
5115    /// Maximum number of image attachments accepted per request.
5116    ///
5117    /// Caps the total number of `[IMAGE:...]` markers that survive into the
5118    /// provider request after multimodal preprocessing. Older images are
5119    /// dropped first when the cumulative count exceeds this limit. Acts as
5120    /// the upper bound on per-turn upload cost when tool outputs surface
5121    /// local image paths.
5122    #[serde(default = "default_multimodal_max_images")]
5123    pub max_images: usize,
5124    /// Maximum image payload size in MiB before base64 encoding.
5125    #[serde(default = "default_multimodal_max_image_size_mb")]
5126    pub max_image_size_mb: usize,
5127    /// Maximum age of images in conversation turns.
5128    ///
5129    /// When non-zero, images in user messages that are more than this many
5130    /// turns back from the end of history are stripped before the request is
5131    /// sent to the provider. This prevents a single screenshot from being
5132    /// re-encoded and re-uploaded on every subsequent turn indefinitely.
5133    /// Tool-result images are already managed by the stale-tool-result
5134    /// mechanism and are not affected by this setting.
5135    ///
5136    /// `0` (the default) disables age-based trimming entirely — images are
5137    /// only evicted by the `max_images` count cap.
5138    #[serde(default)]
5139    pub max_image_turns: usize,
5140    /// Allow fetching remote image URLs (http/https). Disabled by default.
5141    #[serde(default)]
5142    pub allow_remote_fetch: bool,
5143    /// ModelProvider name to use for vision/image messages (e.g. `"ollama"`).
5144    /// When set, messages containing `[IMAGE:]` markers are routed to this
5145    /// model_provider instead of the default text model_provider.
5146    #[serde(default)]
5147    pub vision_model_provider: Option<String>,
5148    /// Model to use when routing to the vision model_provider (e.g. `"llava:7b"`).
5149    /// Only used when `vision_model_provider` is set.
5150    #[serde(default)]
5151    pub vision_model: Option<String>,
5152}
5153
5154fn default_multimodal_max_images() -> usize {
5155    4
5156}
5157
5158fn default_multimodal_max_image_size_mb() -> usize {
5159    5
5160}
5161
5162impl MultimodalConfig {
5163    /// Clamp configured values to safe runtime bounds.
5164    pub fn effective_limits(&self) -> (usize, usize) {
5165        let max_images = self.max_images.clamp(1, 16);
5166        let max_image_size_mb = self.max_image_size_mb.clamp(1, 20);
5167        (max_images, max_image_size_mb)
5168    }
5169}
5170
5171impl Default for MultimodalConfig {
5172    fn default() -> Self {
5173        Self {
5174            max_images: default_multimodal_max_images(),
5175            max_image_size_mb: default_multimodal_max_image_size_mb(),
5176            max_image_turns: 0,
5177            allow_remote_fetch: false,
5178            vision_model_provider: None,
5179            vision_model: None,
5180        }
5181    }
5182}
5183
5184// ── Media Pipeline ──────────────────────────────────────────────
5185
5186/// Automatic media understanding pipeline configuration (`[media_pipeline]`).
5187///
5188/// When enabled, inbound channel messages with media attachments are
5189/// pre-processed before reaching the agent: audio is transcribed, images are
5190/// annotated, and videos are summarised.
5191#[allow(clippy::struct_excessive_bools)]
5192#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5193#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5194#[prefix = "media_pipeline"]
5195pub struct MediaPipelineConfig {
5196    /// Master toggle for the media pipeline (default: false).
5197    #[serde(default)]
5198    pub enabled: bool,
5199
5200    /// Transcribe audio attachments using the configured transcription model_provider.
5201    #[serde(default = "default_true")]
5202    pub transcribe_audio: bool,
5203
5204    /// Add image descriptions when a vision-capable model is active.
5205    #[serde(default = "default_true")]
5206    pub describe_images: bool,
5207
5208    /// Summarize video attachments (placeholder — requires external API).
5209    #[serde(default = "default_true")]
5210    pub summarize_video: bool,
5211}
5212
5213impl Default for MediaPipelineConfig {
5214    fn default() -> Self {
5215        Self {
5216            enabled: false,
5217            transcribe_audio: true,
5218            describe_images: true,
5219            summarize_video: true,
5220        }
5221    }
5222}
5223
5224// ── Identity (AIEOS / OpenClaw format) ──────────────────────────
5225
5226/// Identity format configuration (`[identity]` section).
5227///
5228/// Supports `"openclaw"` (default) or `"aieos"` identity documents.
5229#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5230#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5231#[prefix = "identity"]
5232pub struct IdentityConfig {
5233    /// Identity format: "openclaw" (default) or "aieos"
5234    #[serde(default = "default_identity_format")]
5235    pub format: String,
5236    /// Path to AIEOS JSON file (relative to workspace)
5237    #[serde(default)]
5238    pub aieos_path: Option<String>,
5239    /// Inline AIEOS JSON (alternative to file path)
5240    #[serde(default)]
5241    pub aieos_inline: Option<String>,
5242}
5243
5244fn default_identity_format() -> String {
5245    "openclaw".into()
5246}
5247
5248impl Default for IdentityConfig {
5249    fn default() -> Self {
5250        Self {
5251            format: default_identity_format(),
5252            aieos_path: None,
5253            aieos_inline: None,
5254        }
5255    }
5256}
5257
5258// ── Cost tracking and budget enforcement ───────────────────────────
5259
5260/// Cost tracking and budget enforcement configuration (`[cost]` section).
5261#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5262#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5263#[prefix = "cost"]
5264pub struct CostConfig {
5265    /// Enable cost tracking (default: true)
5266    #[tab(Limits)]
5267    #[serde(default = "default_cost_enabled")]
5268    pub enabled: bool,
5269
5270    /// Daily spending limit in USD (default: 10.00)
5271    #[tab(Limits)]
5272    #[serde(default = "default_daily_limit")]
5273    pub daily_limit_usd: f64,
5274
5275    /// Monthly spending limit in USD (default: 100.00)
5276    #[tab(Limits)]
5277    #[serde(default = "default_monthly_limit")]
5278    pub monthly_limit_usd: f64,
5279
5280    /// Warn when spending reaches this percentage of limit (default: 80)
5281    #[tab(Limits)]
5282    #[serde(default = "default_warn_percent")]
5283    pub warn_at_percent: u8,
5284
5285    /// Allow requests to exceed budget with --override flag (default: false)
5286    #[tab(Limits)]
5287    #[serde(default)]
5288    pub allow_override: bool,
5289
5290    /// Cost enforcement behavior when budget limits are approached or exceeded.
5291    #[tab(Limits)]
5292    #[serde(default)]
5293    #[nested]
5294    pub enforcement: CostEnforcementConfig,
5295
5296    /// Stamp each recorded cost entry with the originating agent alias so
5297    /// `/api/cost?agent=<alias>` and CLI rollups can attribute spend to a
5298    /// specific agent. Disable on high-volume deployments if the extra
5299    /// HashMap aggregation shows up in profiles (default: true).
5300    #[tab(Limits)]
5301    #[serde(default = "default_track_per_agent")]
5302    pub track_per_agent: bool,
5303
5304    /// Operator-managed rate sheet at `[cost.rates.*]`. Sections mirror
5305    /// the `[providers.*]` dotted-path exactly with the trailing `alias`
5306    /// segment replaced by the resource the rate applies to (model id,
5307    /// tool name, …). Layout:
5308    ///
5309    /// ```toml
5310    /// [cost.rates.providers.models.anthropic."claude-opus-4-7"]
5311    /// input_per_mtok        = 15.0
5312    /// output_per_mtok       = 75.0
5313    /// cached_input_per_mtok = 1.5
5314    ///
5315    /// [cost.rates.providers.tts.openai."tts-1-hd"]
5316    /// per_mchar = 30.0
5317    ///
5318    /// [cost.rates.providers.transcription.openai.whisper-1]
5319    /// per_minute = 0.006
5320    ///
5321    /// [cost.rates.tools.web_search]
5322    /// per_call = 0.005
5323    /// ```
5324    #[tab(Costs)]
5325    #[serde(default)]
5326    #[nested]
5327    pub rates: CostRatesConfig,
5328}
5329
5330/// Configuration for cost enforcement behavior when budget limits are reached.
5331#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5332#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5333#[prefix = "cost.enforcement"]
5334pub struct CostEnforcementConfig {
5335    /// Enforcement mode: "warn", "block", or "route_down".
5336    #[serde(default = "default_cost_enforcement_mode")]
5337    pub mode: String,
5338    /// Model hint to route to when budget is exceeded (used with "route_down" mode).
5339    #[serde(default)]
5340    pub route_down_model: Option<String>,
5341    /// Reserve this percentage of budget for critical operations.
5342    #[serde(default = "default_reserve_percent")]
5343    pub reserve_percent: u8,
5344}
5345
5346fn default_cost_enforcement_mode() -> String {
5347    "warn".to_string()
5348}
5349
5350fn default_reserve_percent() -> u8 {
5351    10
5352}
5353
5354impl Default for CostEnforcementConfig {
5355    fn default() -> Self {
5356        Self {
5357            mode: default_cost_enforcement_mode(),
5358            route_down_model: None,
5359            reserve_percent: default_reserve_percent(),
5360        }
5361    }
5362}
5363
5364fn default_daily_limit() -> f64 {
5365    10.0
5366}
5367
5368fn default_monthly_limit() -> f64 {
5369    100.0
5370}
5371
5372fn default_warn_percent() -> u8 {
5373    80
5374}
5375
5376fn default_cost_enabled() -> bool {
5377    true
5378}
5379
5380fn default_track_per_agent() -> bool {
5381    true
5382}
5383
5384/// `[cost.rates]` — top-level rate-sheet namespace. Mirrors the
5385/// `[providers.*]` shape so each subsection here points at the same
5386/// kind of resource its `[providers.*]` counterpart configures.
5387#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5388#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5389#[prefix = "cost.rates"]
5390pub struct CostRatesConfig {
5391    /// `[cost.rates.providers.*]` — rates for everything under
5392    /// `[providers.*]` (models, TTS, transcription, …).
5393    #[serde(default)]
5394    #[nested]
5395    pub providers: ProviderCostRates,
5396
5397    /// `[cost.rates.tools.<name>]` — per-call rates for tools that
5398    /// hit paid APIs. Keyed by the tool's registered name.
5399    #[serde(default)]
5400    #[nested]
5401    #[resource_key]
5402    pub tools: std::collections::HashMap<String, ToolCostRates>,
5403}
5404
5405impl CostRatesConfig {
5406    /// Lookup model token rates by `(provider_type, model)`. Dispatch
5407    /// lives on the typed wrapper — see [`crate::providers::ModelCostRatesByProvider`].
5408    #[must_use]
5409    pub fn model_rates(&self, provider_type: &str, model: &str) -> Option<&ModelCostRates> {
5410        self.providers.models.get(provider_type, model)
5411    }
5412
5413    /// Lookup TTS rates by `(provider_type, voice)`.
5414    #[must_use]
5415    pub fn tts_rates(&self, provider_type: &str, voice: &str) -> Option<&TtsCostRates> {
5416        self.providers.tts.get(provider_type, voice)
5417    }
5418
5419    /// Lookup transcription rates by `(provider_type, model)`.
5420    #[must_use]
5421    pub fn transcription_rates(
5422        &self,
5423        provider_type: &str,
5424        model: &str,
5425    ) -> Option<&TranscriptionCostRates> {
5426        self.providers.transcription.get(provider_type, model)
5427    }
5428
5429    /// Lookup tool per-call rate by registered name.
5430    #[must_use]
5431    pub fn tool_rates(&self, tool_name: &str) -> Option<&ToolCostRates> {
5432        self.tools.get(tool_name)
5433    }
5434}
5435
5436/// `[cost.rates.providers.*]` — provider-shaped rate sheets. Each field
5437/// here mirrors a corresponding field on `[providers.*]` with the
5438/// trailing alias segment replaced by the resource the rate prices.
5439/// The inner typed wrappers carry the per-provider-type slot layout
5440/// and own dispatch (their slot list is the single source of truth,
5441/// shared with their providers counterpart via the `for_each_*_provider_slot!`
5442/// macros in [`crate::providers`]).
5443#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5444#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5445#[prefix = "cost.rates.providers"]
5446pub struct ProviderCostRates {
5447    /// `[cost.rates.providers.models.<type>.<model>]`.
5448    #[serde(default)]
5449    #[nested]
5450    pub models: crate::providers::ModelCostRatesByProvider,
5451    /// `[cost.rates.providers.tts.<type>.<voice>]`.
5452    #[serde(default)]
5453    #[nested]
5454    pub tts: crate::providers::TtsCostRatesByProvider,
5455    /// `[cost.rates.providers.transcription.<type>.<model>]`.
5456    #[serde(default)]
5457    #[nested]
5458    pub transcription: crate::providers::TranscriptionCostRatesByProvider,
5459}
5460
5461/// Token-cost rates for a single chat / completion model, in USD per
5462/// 1M tokens. Every field optional so partial sheets work without
5463/// ceremony (an operator who only knows the input rate can record it).
5464#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5465#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5466#[prefix = "cost.rates.providers.models"]
5467pub struct ModelCostRates {
5468    /// Input tokens (USD per 1M).
5469    #[serde(default, skip_serializing_if = "Option::is_none")]
5470    pub input_per_mtok: Option<f64>,
5471    /// Output tokens (USD per 1M).
5472    #[serde(default, skip_serializing_if = "Option::is_none")]
5473    pub output_per_mtok: Option<f64>,
5474    /// Cached input tokens (USD per 1M). Optional — leave unset on
5475    /// providers that don't charge separately for prompt cache hits.
5476    #[serde(default, skip_serializing_if = "Option::is_none")]
5477    pub cached_input_per_mtok: Option<f64>,
5478}
5479
5480/// Rates for a TTS model, in USD per 1M characters.
5481#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5482#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5483#[prefix = "cost.rates.providers.tts"]
5484pub struct TtsCostRates {
5485    /// Characters synthesised (USD per 1M).
5486    #[serde(default, skip_serializing_if = "Option::is_none")]
5487    pub per_mchar: Option<f64>,
5488}
5489
5490/// Rates for a transcription model, in USD per minute of audio.
5491#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5492#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5493#[prefix = "cost.rates.providers.transcription"]
5494pub struct TranscriptionCostRates {
5495    /// Audio transcribed (USD per minute).
5496    #[serde(default, skip_serializing_if = "Option::is_none")]
5497    pub per_minute: Option<f64>,
5498}
5499
5500/// Rates for a tool that hits a paid external API. Keyed in
5501/// `[cost.rates.tools.<name>]` by the tool's registered name.
5502#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5503#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5504#[prefix = "cost.rates.tools"]
5505pub struct ToolCostRates {
5506    /// Per-call cost (USD).
5507    #[serde(default, skip_serializing_if = "Option::is_none")]
5508    pub per_call: Option<f64>,
5509}
5510
5511impl Default for CostConfig {
5512    fn default() -> Self {
5513        Self {
5514            enabled: true,
5515            daily_limit_usd: default_daily_limit(),
5516            monthly_limit_usd: default_monthly_limit(),
5517            warn_at_percent: default_warn_percent(),
5518            allow_override: false,
5519            enforcement: CostEnforcementConfig::default(),
5520            track_per_agent: default_track_per_agent(),
5521            rates: CostRatesConfig::default(),
5522        }
5523    }
5524}
5525
5526// ── Peripherals (hardware: STM32, RPi GPIO, etc.) ────────────────────────
5527
5528/// Peripheral board integration configuration (`[peripherals]` section).
5529///
5530/// Boards become agent tools when enabled.
5531#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
5532#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5533#[prefix = "peripherals"]
5534pub struct PeripheralsConfig {
5535    /// Enable peripheral support (boards become agent tools)
5536    #[serde(default)]
5537    pub enabled: bool,
5538    /// Board configurations (nucleo-f401re, rpi-gpio, etc.)
5539    #[serde(default)]
5540    pub boards: Vec<PeripheralBoardConfig>,
5541    /// Path to datasheet docs (relative to workspace) for RAG retrieval.
5542    /// Place .md/.txt files named by board (e.g. nucleo-f401re.md, rpi-gpio.md).
5543    #[serde(default)]
5544    pub datasheet_dir: Option<String>,
5545}
5546
5547/// Configuration for a single peripheral board (e.g. STM32, RPi GPIO).
5548#[derive(Debug, Clone, Serialize, Deserialize)]
5549#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5550pub struct PeripheralBoardConfig {
5551    /// Board type: "nucleo-f401re", "rpi-gpio", "esp32", etc.
5552    pub board: String,
5553    /// Transport: "serial", "native", "websocket"
5554    #[serde(default = "default_peripheral_transport")]
5555    pub transport: String,
5556    /// Path for serial: "/dev/ttyACM0", "/dev/ttyUSB0"
5557    #[serde(default)]
5558    pub path: Option<String>,
5559    /// Baud rate for serial (default: 115200)
5560    #[serde(default = "default_peripheral_baud")]
5561    pub baud: u32,
5562}
5563
5564fn default_peripheral_transport() -> String {
5565    "serial".into()
5566}
5567
5568fn default_peripheral_baud() -> u32 {
5569    115_200
5570}
5571
5572impl Default for PeripheralBoardConfig {
5573    fn default() -> Self {
5574        Self {
5575            board: String::new(),
5576            transport: default_peripheral_transport(),
5577            path: None,
5578            baud: default_peripheral_baud(),
5579        }
5580    }
5581}
5582
5583// ── Gateway security ─────────────────────────────────────────────
5584
5585/// Gateway server configuration (`[gateway]` section).
5586///
5587/// Controls the HTTP gateway for webhook and pairing endpoints.
5588#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5589#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5590#[prefix = "gateway"]
5591#[allow(clippy::struct_excessive_bools)]
5592pub struct GatewayConfig {
5593    /// Gateway port (default: 42617)
5594    #[serde(default = "default_gateway_port")]
5595    pub port: u16,
5596    /// Gateway host (default: 127.0.0.1)
5597    #[serde(default = "default_gateway_host")]
5598    pub host: String,
5599    /// Require pairing before accepting requests (default: true)
5600    #[serde(default = "default_true")]
5601    pub require_pairing: bool,
5602    /// Allow binding to non-localhost without a tunnel (default: false)
5603    #[serde(default)]
5604    pub allow_public_bind: bool,
5605    /// Allow authenticated remote callers to use admin endpoints that are
5606    /// otherwise localhost-only. Currently this gates `POST /admin/reload`.
5607    /// When false (default), those endpoints reject any non-loopback peer.
5608    /// When true, a non-loopback request is accepted only if it also passes
5609    /// pairing authentication — which requires `require_pairing = true`; with
5610    /// pairing off a remote caller cannot be authenticated and is rejected, so
5611    /// this flag never exposes an anonymous remote reload. `/admin/shutdown`
5612    /// and the pairing-code endpoints stay localhost-only regardless.
5613    /// (default: false)
5614    #[serde(default)]
5615    pub allow_remote_admin: bool,
5616    /// Paired bearer tokens (managed automatically, not user-edited)
5617    #[serde(default)]
5618    #[secret]
5619    #[credential_class = "encrypted_secret"]
5620    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5621    pub paired_tokens: Vec<String>,
5622
5623    /// Max `/pair` requests per minute per client key.
5624    #[serde(default = "default_pair_rate_limit")]
5625    pub pair_rate_limit_per_minute: u32,
5626
5627    /// Max `/webhook` requests per minute per client key.
5628    #[serde(default = "default_webhook_rate_limit")]
5629    pub webhook_rate_limit_per_minute: u32,
5630
5631    /// Trust proxy-forwarded client IP headers (`X-Forwarded-For`, `X-Real-IP`).
5632    /// Disabled by default; enable only behind a trusted reverse proxy.
5633    #[serde(default)]
5634    #[credential_class = "public_value"]
5635    pub trust_forwarded_headers: bool,
5636
5637    /// Optional URL path prefix for reverse-proxy deployments.
5638    /// When set, all gateway routes are served under this prefix.
5639    /// Must start with `/` and must not end with `/`.
5640    #[serde(default)]
5641    pub path_prefix: Option<String>,
5642
5643    /// Maximum distinct client keys tracked by gateway rate limiter maps.
5644    #[serde(default = "default_gateway_rate_limit_max_keys")]
5645    pub rate_limit_max_keys: usize,
5646
5647    /// TTL for webhook idempotency keys.
5648    #[serde(default = "default_idempotency_ttl_secs")]
5649    pub idempotency_ttl_secs: u64,
5650
5651    /// Maximum distinct idempotency keys retained in memory.
5652    #[serde(default = "default_gateway_idempotency_max_keys")]
5653    pub idempotency_max_keys: usize,
5654
5655    /// Persist gateway WebSocket chat sessions to SQLite. Default: true.
5656    #[serde(default = "default_true")]
5657    pub session_persistence: bool,
5658
5659    /// Auto-archive stale gateway sessions older than N hours. 0 = disabled. Default: 0.
5660    #[serde(default)]
5661    pub session_ttl_hours: u32,
5662
5663    /// Pairing dashboard configuration
5664    #[serde(default)]
5665    #[nested]
5666    pub pairing_dashboard: PairingDashboardConfig,
5667
5668    /// Path to the web dashboard `dist` directory.  When set, the gateway
5669    /// serves the compiled frontend from the filesystem instead of requiring
5670    /// it to be embedded in the binary.  Accepts absolute paths or paths
5671    /// relative to the working directory.  When omitted the gateway runs in
5672    /// API-only mode (no web dashboard) unless auto-detection finds it.
5673    #[serde(default)]
5674    pub web_dist_dir: Option<String>,
5675
5676    /// TLS configuration for the gateway server (`[gateway.tls]`).
5677    #[serde(default)]
5678    #[nested]
5679    pub tls: Option<GatewayTlsConfig>,
5680
5681    /// HTTP request timeout (seconds) for gateway routes other than the
5682    /// long-running cron-trigger endpoint. Default: 30s.
5683    #[serde(default = "default_gateway_request_timeout_secs")]
5684    pub request_timeout_secs: u64,
5685
5686    /// HTTP request timeout (seconds) for `POST /api/cron/{id}/run`, which
5687    /// runs jobs synchronously and routinely exceeds the 30s default.
5688    /// Default: 600s (10 minutes).
5689    #[serde(default = "default_gateway_long_running_request_timeout_secs")]
5690    pub long_running_request_timeout_secs: u64,
5691}
5692
5693fn default_gateway_port() -> u16 {
5694    42617
5695}
5696
5697fn default_gateway_request_timeout_secs() -> u64 {
5698    30
5699}
5700
5701fn default_gateway_long_running_request_timeout_secs() -> u64 {
5702    600
5703}
5704
5705fn default_gateway_host() -> String {
5706    "127.0.0.1".into()
5707}
5708
5709fn default_pair_rate_limit() -> u32 {
5710    10
5711}
5712
5713fn default_webhook_rate_limit() -> u32 {
5714    60
5715}
5716
5717fn default_idempotency_ttl_secs() -> u64 {
5718    300
5719}
5720
5721fn default_gateway_rate_limit_max_keys() -> usize {
5722    10_000
5723}
5724
5725fn default_gateway_idempotency_max_keys() -> usize {
5726    10_000
5727}
5728
5729fn default_true() -> bool {
5730    true
5731}
5732
5733fn default_false() -> bool {
5734    false
5735}
5736
5737impl Default for GatewayConfig {
5738    fn default() -> Self {
5739        Self {
5740            port: default_gateway_port(),
5741            host: default_gateway_host(),
5742            require_pairing: true,
5743            allow_public_bind: false,
5744            allow_remote_admin: false,
5745            paired_tokens: Vec::new(),
5746            pair_rate_limit_per_minute: default_pair_rate_limit(),
5747            webhook_rate_limit_per_minute: default_webhook_rate_limit(),
5748            trust_forwarded_headers: false,
5749            path_prefix: None,
5750            rate_limit_max_keys: default_gateway_rate_limit_max_keys(),
5751            idempotency_ttl_secs: default_idempotency_ttl_secs(),
5752            idempotency_max_keys: default_gateway_idempotency_max_keys(),
5753            session_persistence: true,
5754            session_ttl_hours: 0,
5755            pairing_dashboard: PairingDashboardConfig::default(),
5756            web_dist_dir: None,
5757            tls: None,
5758            request_timeout_secs: default_gateway_request_timeout_secs(),
5759            long_running_request_timeout_secs: default_gateway_long_running_request_timeout_secs(),
5760        }
5761    }
5762}
5763
5764/// Pairing dashboard configuration (`[gateway.pairing_dashboard]`).
5765#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5766#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5767#[prefix = "gateway.pairing_dashboard"]
5768pub struct PairingDashboardConfig {
5769    /// Length of pairing codes (default: 8)
5770    #[serde(default = "default_pairing_code_length")]
5771    pub code_length: usize,
5772    /// Time-to-live for pending pairing codes in seconds (default: 3600)
5773    #[serde(default = "default_pairing_ttl")]
5774    pub code_ttl_secs: u64,
5775    /// Maximum concurrent pending pairing codes (default: 3)
5776    #[serde(default = "default_max_pending_codes")]
5777    pub max_pending_codes: usize,
5778    /// Maximum failed pairing attempts before lockout (default: 5)
5779    #[serde(default = "default_max_failed_attempts")]
5780    pub max_failed_attempts: u32,
5781    /// Lockout duration in seconds after max attempts (default: 300)
5782    #[serde(default = "default_pairing_lockout_secs")]
5783    pub lockout_secs: u64,
5784}
5785
5786fn default_pairing_code_length() -> usize {
5787    8
5788}
5789fn default_pairing_ttl() -> u64 {
5790    3600
5791}
5792fn default_max_pending_codes() -> usize {
5793    3
5794}
5795fn default_max_failed_attempts() -> u32 {
5796    5
5797}
5798fn default_pairing_lockout_secs() -> u64 {
5799    300
5800}
5801
5802impl Default for PairingDashboardConfig {
5803    fn default() -> Self {
5804        Self {
5805            code_length: default_pairing_code_length(),
5806            code_ttl_secs: default_pairing_ttl(),
5807            max_pending_codes: default_max_pending_codes(),
5808            max_failed_attempts: default_max_failed_attempts(),
5809            lockout_secs: default_pairing_lockout_secs(),
5810        }
5811    }
5812}
5813
5814/// TLS configuration for the gateway server (`[gateway.tls]`).
5815#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
5816#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5817#[prefix = "gateway.tls"]
5818pub struct GatewayTlsConfig {
5819    /// Enable TLS for the gateway (default: false).
5820    #[serde(default)]
5821    pub enabled: bool,
5822    /// Path to the PEM-encoded server certificate file.
5823    pub cert_path: String,
5824    /// Path to the PEM-encoded server private key file.
5825    pub key_path: String,
5826    /// Client certificate authentication (mutual TLS) settings.
5827    #[serde(default)]
5828    #[nested]
5829    pub client_auth: Option<GatewayClientAuthConfig>,
5830}
5831
5832/// Client certificate authentication (mTLS) configuration (`[gateway.tls.client_auth]`).
5833#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5834#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5835#[prefix = "gateway.tls.client_auth"]
5836pub struct GatewayClientAuthConfig {
5837    /// Enable client certificate verification (default: false).
5838    #[serde(default)]
5839    pub enabled: bool,
5840    /// Path to the PEM-encoded CA certificate used to verify client certs.
5841    #[serde(default)]
5842    pub ca_cert_path: String,
5843    /// Reject connections that do not present a valid client certificate (default: true).
5844    #[serde(default = "default_true")]
5845    pub require_client_cert: bool,
5846    /// Optional SHA-256 fingerprints for certificate pinning.
5847    /// When non-empty, only client certs matching one of these fingerprints are accepted.
5848    #[serde(default)]
5849    pub pinned_certs: Vec<String>,
5850}
5851
5852impl Default for GatewayClientAuthConfig {
5853    fn default() -> Self {
5854        Self {
5855            enabled: false,
5856            ca_cert_path: String::new(),
5857            require_client_cert: default_true(),
5858            pinned_certs: Vec::new(),
5859        }
5860    }
5861}
5862
5863/// WebSocket Secure (WSS) transport for remote TUI-to-daemon connections (`[wss]`).
5864///
5865/// When enabled, the daemon listens for TLS-encrypted WebSocket connections
5866/// on the configured bind address and port. TUI clients connect via
5867/// `--connect wss://host:port`.
5868#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5869#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5870#[prefix = "wss"]
5871pub struct WssConfig {
5872    /// Enable the WSS listener (default: false).
5873    #[serde(default)]
5874    pub enabled: bool,
5875    /// Bind address for the WSS listener (default: "0.0.0.0").
5876    #[serde(default = "default_wss_bind")]
5877    pub bind: String,
5878    /// Port for the WSS listener (default: 9781).
5879    #[serde(default = "default_wss_port")]
5880    pub port: u16,
5881    /// Path to the PEM-encoded server certificate file.
5882    #[serde(default)]
5883    pub cert_path: String,
5884    /// Path to the PEM-encoded server private key file.
5885    #[serde(default)]
5886    pub key_path: String,
5887}
5888
5889impl Default for WssConfig {
5890    fn default() -> Self {
5891        Self {
5892            enabled: false,
5893            bind: default_wss_bind(),
5894            port: default_wss_port(),
5895            cert_path: String::new(),
5896            key_path: String::new(),
5897        }
5898    }
5899}
5900
5901fn default_wss_bind() -> String {
5902    "0.0.0.0".into()
5903}
5904
5905fn default_wss_port() -> u16 {
5906    9781
5907}
5908
5909/// Secure transport configuration for inter-node communication (`[node_transport]`).
5910#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5911#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5912#[prefix = "node_transport"]
5913pub struct NodeTransportConfig {
5914    /// Enable the secure transport layer.
5915    #[serde(default = "default_node_transport_enabled")]
5916    pub enabled: bool,
5917    /// Shared secret for HMAC authentication between nodes.
5918    #[serde(default)]
5919    #[secret]
5920    #[credential_class = "encrypted_secret"]
5921    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5922    pub shared_secret: String,
5923    /// Maximum age of signed requests in seconds (replay protection).
5924    #[serde(default = "default_max_request_age")]
5925    pub max_request_age_secs: i64,
5926    /// Require HTTPS for all node communication.
5927    #[serde(default = "default_require_https")]
5928    pub require_https: bool,
5929    /// Allow specific node IPs/CIDRs.
5930    #[serde(default)]
5931    pub allowed_peers: Vec<String>,
5932    /// Path to TLS certificate file.
5933    #[serde(default)]
5934    pub tls_cert_path: Option<String>,
5935    /// Path to TLS private key file.
5936    #[serde(default)]
5937    pub tls_key_path: Option<String>,
5938    /// Require client certificates (mutual TLS).
5939    #[serde(default)]
5940    pub mutual_tls: bool,
5941    /// Maximum number of connections per peer.
5942    #[serde(default = "default_connection_pool_size")]
5943    pub connection_pool_size: usize,
5944}
5945
5946fn default_node_transport_enabled() -> bool {
5947    true
5948}
5949fn default_max_request_age() -> i64 {
5950    300
5951}
5952fn default_require_https() -> bool {
5953    true
5954}
5955fn default_connection_pool_size() -> usize {
5956    4
5957}
5958
5959impl Default for NodeTransportConfig {
5960    fn default() -> Self {
5961        Self {
5962            enabled: default_node_transport_enabled(),
5963            shared_secret: String::new(),
5964            max_request_age_secs: default_max_request_age(),
5965            require_https: default_require_https(),
5966            allowed_peers: Vec::new(),
5967            tls_cert_path: None,
5968            tls_key_path: None,
5969            mutual_tls: false,
5970            connection_pool_size: default_connection_pool_size(),
5971        }
5972    }
5973}
5974
5975// ── Composio (managed tool surface) ─────────────────────────────
5976
5977/// Composio managed OAuth tools integration (`[composio]` section).
5978///
5979/// Provides access to 1000+ OAuth-connected tools via the Composio platform.
5980#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
5981#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
5982#[prefix = "composio"]
5983pub struct ComposioConfig {
5984    /// Enable Composio integration for 1000+ OAuth tools
5985    #[serde(default, alias = "enable")]
5986    pub enabled: bool,
5987    /// Composio API key (stored encrypted when secrets.encrypt = true)
5988    #[serde(default)]
5989    #[secret]
5990    #[credential_class = "encrypted_secret"]
5991    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
5992    pub api_key: Option<String>,
5993    /// Default entity ID for multi-user setups
5994    #[serde(default = "default_entity_id")]
5995    pub entity_id: String,
5996}
5997
5998fn default_entity_id() -> String {
5999    "default".into()
6000}
6001
6002impl Default for ComposioConfig {
6003    fn default() -> Self {
6004        Self {
6005            enabled: false,
6006            api_key: None,
6007            entity_id: default_entity_id(),
6008        }
6009    }
6010}
6011
6012// ── Microsoft 365 (Graph API integration) ───────────────────────
6013
6014/// Microsoft 365 integration via Microsoft Graph API (`[microsoft365]` section).
6015///
6016/// Provides access to Outlook mail, Teams messages, Calendar events,
6017/// OneDrive files, and SharePoint search.
6018#[derive(Clone, Serialize, Deserialize, Configurable)]
6019#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6020#[prefix = "ms365"]
6021pub struct Microsoft365Config {
6022    /// Enable Microsoft 365 integration
6023    #[serde(default, alias = "enable")]
6024    pub enabled: bool,
6025    /// Azure AD tenant ID
6026    #[serde(default)]
6027    pub tenant_id: Option<String>,
6028    /// Azure AD application (client) ID
6029    #[serde(default)]
6030    pub client_id: Option<String>,
6031    /// Azure AD client secret (stored encrypted when secrets.encrypt = true)
6032    #[serde(default)]
6033    #[secret]
6034    #[credential_class = "encrypted_secret"]
6035    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6036    pub client_secret: Option<String>,
6037    /// Authentication flow: "client_credentials" or "device_code"
6038    #[serde(default = "default_ms365_auth_flow")]
6039    pub auth_flow: String,
6040    /// OAuth scopes to request
6041    #[serde(default = "default_ms365_scopes")]
6042    pub scopes: Vec<String>,
6043    /// Encrypt the token cache file on disk
6044    #[serde(default = "default_true")]
6045    pub token_cache_encrypted: bool,
6046    /// User principal name or "me" (for delegated flows)
6047    #[serde(default)]
6048    pub user_id: Option<String>,
6049}
6050
6051fn default_ms365_auth_flow() -> String {
6052    "client_credentials".to_string()
6053}
6054
6055fn default_ms365_scopes() -> Vec<String> {
6056    vec!["https://graph.microsoft.com/.default".to_string()]
6057}
6058
6059impl std::fmt::Debug for Microsoft365Config {
6060    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
6061        f.debug_struct("Microsoft365Config")
6062            .field("enabled", &self.enabled)
6063            .field("tenant_id", &self.tenant_id)
6064            .field("client_id", &self.client_id)
6065            .field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
6066            .field("auth_flow", &self.auth_flow)
6067            .field("scopes", &self.scopes)
6068            .field("token_cache_encrypted", &self.token_cache_encrypted)
6069            .field("user_id", &self.user_id)
6070            .finish()
6071    }
6072}
6073
6074impl Default for Microsoft365Config {
6075    fn default() -> Self {
6076        Self {
6077            enabled: false,
6078            tenant_id: None,
6079            client_id: None,
6080            client_secret: None,
6081            auth_flow: default_ms365_auth_flow(),
6082            scopes: default_ms365_scopes(),
6083            token_cache_encrypted: true,
6084            user_id: None,
6085        }
6086    }
6087}
6088
6089// ── Secrets (encrypted credential store) ────────────────────────
6090
6091/// Secrets encryption configuration (`[secrets]` section).
6092#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6093#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6094#[prefix = "secrets"]
6095pub struct SecretsConfig {
6096    /// Enable encryption for API keys and tokens at rest
6097    #[serde(default = "default_true")]
6098    #[credential_class = "public_value"]
6099    pub encrypt: bool,
6100}
6101
6102impl Default for SecretsConfig {
6103    fn default() -> Self {
6104        Self { encrypt: true }
6105    }
6106}
6107
6108// ── Browser (friendly-service browsing only) ───────────────────
6109
6110/// Computer-use sidecar configuration (`[browser.computer_use]` section).
6111///
6112/// Delegates OS-level mouse, keyboard, and screenshot actions to a local sidecar.
6113#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6114#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6115#[prefix = "browser.computer_use"]
6116pub struct BrowserComputerUseConfig {
6117    /// Sidecar endpoint for computer-use actions (OS-level mouse/keyboard/screenshot)
6118    #[serde(default = "default_browser_computer_use_endpoint")]
6119    pub endpoint: String,
6120    /// Optional bearer token for computer-use sidecar
6121    #[serde(default)]
6122    #[secret]
6123    #[credential_class = "encrypted_secret"]
6124    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6125    pub api_key: Option<String>,
6126    /// Per-action request timeout in milliseconds
6127    #[serde(default = "default_browser_computer_use_timeout_ms")]
6128    pub timeout_ms: u64,
6129    /// Allow remote/public endpoint for computer-use sidecar (default: false)
6130    #[serde(default)]
6131    pub allow_remote_endpoint: bool,
6132    /// Optional window title/process allowlist forwarded to sidecar policy
6133    #[serde(default)]
6134    pub window_allowlist: Vec<String>,
6135    /// Optional X-axis boundary for coordinate-based actions
6136    #[serde(default)]
6137    pub max_coordinate_x: Option<i64>,
6138    /// Optional Y-axis boundary for coordinate-based actions
6139    #[serde(default)]
6140    pub max_coordinate_y: Option<i64>,
6141}
6142
6143fn default_browser_computer_use_endpoint() -> String {
6144    "http://127.0.0.1:8787/v1/actions".into()
6145}
6146
6147fn default_browser_computer_use_timeout_ms() -> u64 {
6148    15_000
6149}
6150
6151impl Default for BrowserComputerUseConfig {
6152    fn default() -> Self {
6153        Self {
6154            endpoint: default_browser_computer_use_endpoint(),
6155            api_key: None,
6156            timeout_ms: default_browser_computer_use_timeout_ms(),
6157            allow_remote_endpoint: false,
6158            window_allowlist: Vec::new(),
6159            max_coordinate_x: None,
6160            max_coordinate_y: None,
6161        }
6162    }
6163}
6164
6165/// Browser automation configuration (`[browser]` section).
6166///
6167/// Controls the `browser_open` tool and browser automation backends.
6168#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6169#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6170#[prefix = "browser"]
6171#[integration(
6172    category = "ToolsAutomation",
6173    display_name = "Browser",
6174    description = "Chrome/Chromium control",
6175    status_field = "enabled"
6176)]
6177pub struct BrowserConfig {
6178    /// Enable `browser_open` tool (opens URLs in the system browser without scraping)
6179    #[serde(default = "default_true")]
6180    pub enabled: bool,
6181    /// Allowed domains for `browser_open` (exact or subdomain match)
6182    #[serde(default = "default_browser_allowed_domains")]
6183    pub allowed_domains: Vec<String>,
6184    /// Browser session name (for agent-browser automation)
6185    #[serde(default)]
6186    pub session_name: Option<String>,
6187    /// Browser automation backend: "agent_browser" | "rust_native" | "computer_use" | "auto"
6188    #[serde(default = "default_browser_backend")]
6189    pub backend: String,
6190    /// Show browser window for agent_browser backend. When unset, inherits AGENT_BROWSER_HEADED.
6191    #[serde(default)]
6192    pub headed: Option<bool>,
6193    /// Headless mode for rust-native backend
6194    #[serde(default = "default_true")]
6195    pub native_headless: bool,
6196    /// WebDriver endpoint URL for rust-native backend (e.g. `http://127.0.0.1:9515`)
6197    #[serde(default = "default_browser_webdriver_url")]
6198    pub native_webdriver_url: String,
6199    /// Optional Chrome/Chromium executable path for rust-native backend
6200    #[serde(default)]
6201    pub native_chrome_path: Option<String>,
6202    /// Computer-use sidecar configuration
6203    #[serde(default)]
6204    #[nested]
6205    pub computer_use: BrowserComputerUseConfig,
6206}
6207
6208fn default_browser_allowed_domains() -> Vec<String> {
6209    vec!["*".into()]
6210}
6211
6212fn default_browser_backend() -> String {
6213    "agent_browser".into()
6214}
6215
6216fn default_browser_webdriver_url() -> String {
6217    "http://127.0.0.1:9515".into()
6218}
6219
6220impl Default for BrowserConfig {
6221    fn default() -> Self {
6222        Self {
6223            enabled: true,
6224            allowed_domains: vec!["*".into()],
6225            session_name: None,
6226            backend: default_browser_backend(),
6227            headed: None,
6228            native_headless: default_true(),
6229            native_webdriver_url: default_browser_webdriver_url(),
6230            native_chrome_path: None,
6231            computer_use: BrowserComputerUseConfig::default(),
6232        }
6233    }
6234}
6235
6236// ── HTTP request tool ───────────────────────────────────────────
6237
6238/// HTTP request tool configuration (`[http_request]` section).
6239///
6240/// Domain filtering: `allowed_domains` controls which hosts are reachable (use `["*"]`
6241/// for all public hosts, which is the default). If `allowed_domains` is empty, all
6242/// requests are rejected.
6243#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6244#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6245#[prefix = "http_request"]
6246pub struct HttpRequestConfig {
6247    /// Enable `http_request` tool for API interactions
6248    #[serde(default)]
6249    pub enabled: bool,
6250    /// Allowed domains for HTTP requests (exact or subdomain match)
6251    #[serde(default)]
6252    pub allowed_domains: Vec<String>,
6253    /// Maximum response size in bytes (default: 1MB, 0 = unlimited)
6254    #[serde(default = "default_http_max_response_size")]
6255    pub max_response_size: usize,
6256    /// Request timeout in seconds (default: 30)
6257    #[serde(default = "default_http_timeout_secs")]
6258    pub timeout_secs: u64,
6259    /// Allow requests to private/LAN hosts (RFC 1918, loopback, link-local, .local).
6260    /// Default: false (deny private hosts for SSRF protection).
6261    #[serde(default)]
6262    pub allow_private_hosts: bool,
6263    /// Private/internal hosts explicitly allowed to bypass SSRF protection.
6264    /// Exact and subdomain matches are supported; `*` permits all private/local hosts.
6265    #[serde(default)]
6266    pub allowed_private_hosts: Vec<String>,
6267    /// Named authorization secrets for `auth_secret` requests.
6268    #[serde(default)]
6269    #[secret]
6270    #[credential_class = "encrypted_secret"]
6271    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6272    pub secrets: HashMap<String, String>,
6273}
6274
6275impl Default for HttpRequestConfig {
6276    fn default() -> Self {
6277        Self {
6278            enabled: true,
6279            allowed_domains: vec!["*".into()],
6280            max_response_size: default_http_max_response_size(),
6281            timeout_secs: default_http_timeout_secs(),
6282            allow_private_hosts: false,
6283            allowed_private_hosts: vec![],
6284            secrets: HashMap::new(),
6285        }
6286    }
6287}
6288
6289fn default_http_max_response_size() -> usize {
6290    1_000_000 // 1MB
6291}
6292
6293fn default_http_timeout_secs() -> u64 {
6294    30
6295}
6296
6297// ── Web fetch ────────────────────────────────────────────────────
6298
6299/// Web fetch tool configuration (`[web_fetch]` section).
6300///
6301/// Fetches web pages and converts HTML to plain text for LLM consumption.
6302/// Domain filtering: `allowed_domains` controls which hosts are reachable (use `["*"]`
6303/// for all public hosts). `blocked_domains` takes priority over `allowed_domains`.
6304/// If `allowed_domains` is empty, all requests are rejected (deny-by-default).
6305#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6306#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6307#[prefix = "web_fetch"]
6308pub struct WebFetchConfig {
6309    /// Enable `web_fetch` tool for fetching web page content
6310    #[serde(default)]
6311    pub enabled: bool,
6312    /// Allowed domains for web fetch (exact or subdomain match; `["*"]` = all public hosts)
6313    #[serde(default = "default_web_fetch_allowed_domains")]
6314    pub allowed_domains: Vec<String>,
6315    /// Blocked domains (exact or subdomain match; always takes priority over allowed_domains)
6316    #[serde(default)]
6317    pub blocked_domains: Vec<String>,
6318    /// Private/internal hosts allowed to bypass SSRF protection (e.g. `["192.168.1.10", "internal.local"]`)
6319    #[serde(default)]
6320    pub allowed_private_hosts: Vec<String>,
6321    /// Maximum response size in bytes (default: 500KB, plain text is much smaller than raw HTML)
6322    #[serde(default = "default_web_fetch_max_response_size")]
6323    pub max_response_size: usize,
6324    /// Request timeout in seconds (default: 30)
6325    #[serde(default = "default_web_fetch_timeout_secs")]
6326    pub timeout_secs: u64,
6327    /// Firecrawl fallback configuration (`[web_fetch.firecrawl]`)
6328    #[serde(default)]
6329    #[nested]
6330    pub firecrawl: FirecrawlConfig,
6331}
6332
6333/// Firecrawl fallback mode: scrape a single page or crawl linked pages.
6334#[derive(Debug, Default, Clone, Serialize, Deserialize, PartialEq, Eq)]
6335#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6336#[serde(rename_all = "lowercase")]
6337pub enum FirecrawlMode {
6338    #[default]
6339    Scrape,
6340    /// Reserved for future multi-page crawl support. Accepted in config
6341    /// deserialization to avoid breaking existing files, but not yet
6342    /// implemented — `fetch_via_firecrawl` always uses the `/scrape` endpoint.
6343    Crawl,
6344}
6345
6346/// Firecrawl fallback configuration for JS-heavy and bot-blocked sites.
6347///
6348/// When enabled, if the standard web fetch fails (HTTP error, empty body, or
6349/// body shorter than 100 characters suggesting a JS-only page), the tool
6350/// falls back to the Firecrawl API for stealth content extraction.
6351#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6352#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6353#[prefix = "web_fetch.firecrawl"]
6354pub struct FirecrawlConfig {
6355    /// Enable Firecrawl fallback
6356    #[serde(default)]
6357    pub enabled: bool,
6358    /// Environment variable name for the Firecrawl API key
6359    #[serde(default = "default_firecrawl_api_key_env")]
6360    #[credential_class = "legacy_env_path"]
6361    pub api_key_env: String,
6362    /// Firecrawl API base URL
6363    #[serde(default = "default_firecrawl_api_url")]
6364    pub api_url: String,
6365    /// Firecrawl extraction mode
6366    #[serde(default)]
6367    pub mode: FirecrawlMode,
6368}
6369
6370fn default_firecrawl_api_key_env() -> String {
6371    "FIRECRAWL_API_KEY".into()
6372}
6373
6374fn default_firecrawl_api_url() -> String {
6375    "https://api.firecrawl.dev/v1".into()
6376}
6377
6378impl Default for FirecrawlConfig {
6379    fn default() -> Self {
6380        Self {
6381            enabled: false,
6382            api_key_env: default_firecrawl_api_key_env(),
6383            api_url: default_firecrawl_api_url(),
6384            mode: FirecrawlMode::default(),
6385        }
6386    }
6387}
6388
6389fn default_web_fetch_max_response_size() -> usize {
6390    500_000 // 500KB
6391}
6392
6393fn default_web_fetch_timeout_secs() -> u64 {
6394    30
6395}
6396
6397fn default_web_fetch_allowed_domains() -> Vec<String> {
6398    vec!["*".into()]
6399}
6400
6401impl Default for WebFetchConfig {
6402    fn default() -> Self {
6403        Self {
6404            enabled: true,
6405            allowed_domains: vec!["*".into()],
6406            blocked_domains: vec![],
6407            allowed_private_hosts: vec![],
6408            max_response_size: default_web_fetch_max_response_size(),
6409            timeout_secs: default_web_fetch_timeout_secs(),
6410            firecrawl: FirecrawlConfig::default(),
6411        }
6412    }
6413}
6414
6415// ── Link enricher ─────────────────────────────────────────────────
6416
6417/// Automatic link understanding for inbound channel messages (`[link_enricher]`).
6418///
6419/// When enabled, URLs in incoming messages are automatically fetched and
6420/// summarised. The summary is prepended to the message before the agent
6421/// processes it, giving the LLM context about linked pages without an
6422/// explicit tool call.
6423#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6424#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6425#[prefix = "link_enricher"]
6426pub struct LinkEnricherConfig {
6427    /// Enable the link enricher pipeline stage (default: false)
6428    #[serde(default)]
6429    pub enabled: bool,
6430    /// Maximum number of links to fetch per message (default: 3)
6431    #[serde(default = "default_link_enricher_max_links")]
6432    pub max_links: usize,
6433    /// Per-link fetch timeout in seconds (default: 10)
6434    #[serde(default = "default_link_enricher_timeout_secs")]
6435    pub timeout_secs: u64,
6436}
6437
6438fn default_link_enricher_max_links() -> usize {
6439    3
6440}
6441
6442fn default_link_enricher_timeout_secs() -> u64 {
6443    10
6444}
6445
6446impl Default for LinkEnricherConfig {
6447    fn default() -> Self {
6448        Self {
6449            enabled: false,
6450            max_links: default_link_enricher_max_links(),
6451            timeout_secs: default_link_enricher_timeout_secs(),
6452        }
6453    }
6454}
6455
6456// ── Text browser ─────────────────────────────────────────────────
6457
6458/// Text browser tool configuration (`[text_browser]` section).
6459///
6460/// Uses text-based browsers (lynx, links, w3m) to render web pages as plain
6461/// text. Designed for headless/SSH environments without graphical browsers.
6462#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6463#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6464#[prefix = "text_browser"]
6465pub struct TextBrowserConfig {
6466    /// Enable `text_browser` tool
6467    #[serde(default)]
6468    pub enabled: bool,
6469    /// Preferred text browser ("lynx", "links", or "w3m"). If unset, auto-detects.
6470    #[serde(default)]
6471    pub preferred_browser: Option<String>,
6472    /// Request timeout in seconds (default: 30)
6473    #[serde(default = "default_text_browser_timeout_secs")]
6474    pub timeout_secs: u64,
6475}
6476
6477fn default_text_browser_timeout_secs() -> u64 {
6478    30
6479}
6480
6481impl Default for TextBrowserConfig {
6482    fn default() -> Self {
6483        Self {
6484            enabled: false,
6485            preferred_browser: None,
6486            timeout_secs: default_text_browser_timeout_secs(),
6487        }
6488    }
6489}
6490
6491// ── Shell tool ───────────────────────────────────────────────────
6492
6493/// Shell tool configuration (`[shell_tool]` section).
6494///
6495/// Controls the behaviour of the `shell` execution tool. The main
6496/// tunable is `timeout_secs` — the maximum wall-clock time a single
6497/// shell command may run before it is killed.
6498#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6499#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6500#[prefix = "shell_tool"]
6501pub struct ShellToolConfig {
6502    /// Maximum shell command execution time in seconds (default: 60).
6503    #[serde(default = "default_shell_tool_timeout_secs")]
6504    pub timeout_secs: u64,
6505}
6506
6507fn default_shell_tool_timeout_secs() -> u64 {
6508    60
6509}
6510
6511impl Default for ShellToolConfig {
6512    fn default() -> Self {
6513        Self {
6514            timeout_secs: default_shell_tool_timeout_secs(),
6515        }
6516    }
6517}
6518
6519// ── Escalation routing ───────────────────────────────────────────
6520
6521/// Escalation routing configuration (`[escalation]` section).
6522///
6523/// Controls which channels receive alert notifications when
6524/// `escalate_to_human` is called with high or critical urgency.
6525/// Channels are identified by name (e.g. `"telegram"`, `"slack"`).
6526/// Alerts are sent best-effort and do not block the escalation.
6527#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
6528#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6529#[prefix = "escalation"]
6530pub struct EscalationConfig {
6531    /// Channel names to alert on high/critical escalations (default: empty).
6532    ///
6533    /// Each name must match a configured channel. Unrecognised names are
6534    /// logged at WARN level and skipped.
6535    #[serde(default)]
6536    pub alert_channels: Vec<String>,
6537}
6538
6539// ── Web search ───────────────────────────────────────────────────
6540
6541/// Web search tool configuration (`[web_search]` section).
6542#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6543#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6544#[prefix = "web_search"]
6545pub struct WebSearchConfig {
6546    /// Enable `web_search_tool` for web searches
6547    #[serde(default)]
6548    pub enabled: bool,
6549    /// Search provider: "duckduckgo" (free), "brave" (requires API key), "tavily" (requires API key), "searxng" (self-hosted), or "jina" (requires API key)
6550    #[serde(default = "default_web_search_provider")]
6551    pub search_provider: String,
6552    /// Brave Search API key (required if search_provider is "brave")
6553    #[serde(default)]
6554    #[secret]
6555    #[credential_class = "encrypted_secret"]
6556    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6557    pub brave_api_key: Option<String>,
6558    /// Tavily Search API key (required if search_provider is "tavily")
6559    #[serde(default)]
6560    #[secret]
6561    #[credential_class = "encrypted_secret"]
6562    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6563    pub tavily_api_key: Option<String>,
6564    /// Jina AI API key (required if search_provider is "jina")
6565    #[serde(default)]
6566    #[secret]
6567    #[credential_class = "encrypted_secret"]
6568    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
6569    pub jina_api_key: Option<String>,
6570    /// SearXNG instance URL (required if search_provider is `"searxng"`), e.g. `"https://searx.example.com"`.
6571    #[serde(default)]
6572    pub searxng_instance_url: Option<String>,
6573    /// Maximum results per search (1-10)
6574    #[serde(default = "default_web_search_max_results")]
6575    pub max_results: usize,
6576    /// Request timeout in seconds
6577    #[serde(default = "default_web_search_timeout_secs")]
6578    pub timeout_secs: u64,
6579}
6580
6581fn default_web_search_provider() -> String {
6582    "duckduckgo".into()
6583}
6584
6585fn default_web_search_max_results() -> usize {
6586    5
6587}
6588
6589fn default_web_search_timeout_secs() -> u64 {
6590    15
6591}
6592
6593impl Default for WebSearchConfig {
6594    fn default() -> Self {
6595        Self {
6596            enabled: true,
6597            search_provider: default_web_search_provider(),
6598            brave_api_key: None,
6599            tavily_api_key: None,
6600            jina_api_key: None,
6601            searxng_instance_url: None,
6602            max_results: default_web_search_max_results(),
6603            timeout_secs: default_web_search_timeout_secs(),
6604        }
6605    }
6606}
6607
6608// ── Project Intelligence ────────────────────────────────────────
6609
6610/// Project delivery intelligence configuration (`[project_intel]` section).
6611#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6612#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6613#[prefix = "project_intel"]
6614pub struct ProjectIntelConfig {
6615    /// Enable the project_intel tool. Default: false.
6616    #[serde(default)]
6617    pub enabled: bool,
6618    /// Default report language (en, de, fr, it). Default: "en".
6619    #[serde(default = "default_project_intel_language")]
6620    pub default_language: String,
6621    /// Output directory for generated reports.
6622    #[serde(default = "default_project_intel_report_dir")]
6623    pub report_output_dir: String,
6624    /// Optional custom templates directory.
6625    #[serde(default)]
6626    pub templates_dir: Option<String>,
6627    /// Risk detection sensitivity: low, medium, high. Default: "medium".
6628    #[serde(default = "default_project_intel_risk_sensitivity")]
6629    pub risk_sensitivity: String,
6630    /// Include git log data in reports. Default: true.
6631    #[serde(default = "default_true")]
6632    pub include_git_data: bool,
6633    /// Include Jira data in reports. Default: false.
6634    #[serde(default)]
6635    pub include_jira_data: bool,
6636    /// Jira instance base URL (required if include_jira_data is true).
6637    #[serde(default)]
6638    pub jira_base_url: Option<String>,
6639}
6640
6641fn default_project_intel_language() -> String {
6642    "en".into()
6643}
6644
6645fn default_project_intel_report_dir() -> String {
6646    default_path_under_config_dir("project-reports")
6647}
6648
6649fn default_project_intel_risk_sensitivity() -> String {
6650    "medium".into()
6651}
6652
6653impl Default for ProjectIntelConfig {
6654    fn default() -> Self {
6655        Self {
6656            enabled: false,
6657            default_language: default_project_intel_language(),
6658            report_output_dir: default_project_intel_report_dir(),
6659            templates_dir: None,
6660            risk_sensitivity: default_project_intel_risk_sensitivity(),
6661            include_git_data: true,
6662            include_jira_data: false,
6663            jira_base_url: None,
6664        }
6665    }
6666}
6667
6668// ── Backup ──────────────────────────────────────────────────────
6669
6670/// Backup tool configuration (`[backup]` section).
6671#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6672#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6673#[prefix = "backup"]
6674pub struct BackupConfig {
6675    /// Enable the `backup` tool.
6676    #[serde(default = "default_true")]
6677    pub enabled: bool,
6678    /// Maximum number of backups to keep (oldest are pruned).
6679    #[serde(default = "default_backup_max_keep")]
6680    pub max_keep: usize,
6681    /// Workspace subdirectories to include in backups.
6682    #[serde(default = "default_backup_include_dirs")]
6683    pub include_dirs: Vec<String>,
6684    /// Output directory for backup archives (relative to workspace root).
6685    #[serde(default = "default_backup_destination_dir")]
6686    pub destination_dir: String,
6687    /// Optional cron expression for scheduled automatic backups.
6688    #[serde(default)]
6689    pub schedule_cron: Option<String>,
6690    /// IANA timezone for `schedule_cron`.
6691    #[serde(default)]
6692    pub schedule_timezone: Option<String>,
6693    /// Compress backup archives.
6694    #[serde(default = "default_true")]
6695    pub compress: bool,
6696    /// Encrypt backup archives (requires a configured secret store key).
6697    #[serde(default)]
6698    pub encrypt: bool,
6699}
6700
6701fn default_backup_max_keep() -> usize {
6702    10
6703}
6704
6705fn default_backup_include_dirs() -> Vec<String> {
6706    vec![
6707        "config".into(),
6708        "memory".into(),
6709        "audit".into(),
6710        "knowledge".into(),
6711    ]
6712}
6713
6714fn default_backup_destination_dir() -> String {
6715    "state/backups".into()
6716}
6717
6718impl Default for BackupConfig {
6719    fn default() -> Self {
6720        Self {
6721            enabled: true,
6722            max_keep: default_backup_max_keep(),
6723            include_dirs: default_backup_include_dirs(),
6724            destination_dir: default_backup_destination_dir(),
6725            schedule_cron: None,
6726            schedule_timezone: None,
6727            compress: true,
6728            encrypt: false,
6729        }
6730    }
6731}
6732
6733// ── Data Retention ──────────────────────────────────────────────
6734
6735/// Data retention and purge configuration (`[data_retention]` section).
6736#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6737#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6738#[prefix = "data_retention"]
6739pub struct DataRetentionConfig {
6740    /// Enable the `data_management` tool.
6741    #[serde(default)]
6742    pub enabled: bool,
6743    /// Days of data to retain before purge eligibility.
6744    #[serde(default = "default_retention_days")]
6745    pub retention_days: u64,
6746    /// Preview what would be deleted without actually removing anything.
6747    #[serde(default)]
6748    pub dry_run: bool,
6749    /// Limit retention enforcement to specific data categories (empty = all).
6750    #[serde(default)]
6751    pub categories: Vec<String>,
6752}
6753
6754fn default_retention_days() -> u64 {
6755    90
6756}
6757
6758impl Default for DataRetentionConfig {
6759    fn default() -> Self {
6760        Self {
6761            enabled: false,
6762            retention_days: default_retention_days(),
6763            dry_run: false,
6764            categories: Vec::new(),
6765        }
6766    }
6767}
6768
6769// ── Google Workspace ─────────────────────────────────────────────
6770
6771/// Built-in default service allowlist for the `google_workspace` tool.
6772///
6773/// Applied when `allowed_services` is empty. Defined here (not in the tool layer)
6774/// so that config validation can cross-check `allowed_operations` entries against
6775/// the effective service set in all cases, including when the operator relies on
6776/// the default.
6777pub const DEFAULT_GWS_SERVICES: &[&str] = &[
6778    "drive",
6779    "sheets",
6780    "gmail",
6781    "calendar",
6782    "docs",
6783    "slides",
6784    "tasks",
6785    "people",
6786    "chat",
6787    "classroom",
6788    "forms",
6789    "keep",
6790    "meet",
6791    "events",
6792];
6793
6794/// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]` section).
6795///
6796/// ## Defaults
6797/// - `enabled`: `false` (tool is not registered unless explicitly opted-in).
6798/// - `allowed_services`: empty vector, which grants access to the full default
6799///   service set: `drive`, `sheets`, `gmail`, `calendar`, `docs`, `slides`,
6800///   `tasks`, `people`, `chat`, `classroom`, `forms`, `keep`, `meet`, `events`.
6801/// - `credentials_path`: `None` (uses default `gws` credential discovery).
6802/// - `default_account`: `None` (uses the `gws` active account).
6803/// - `rate_limit_per_minute`: `60`.
6804/// - `timeout_secs`: `30`.
6805/// - `audit_log`: `false`.
6806/// - `credentials_path`: `None` (uses default `gws` credential discovery).
6807/// - `default_account`: `None` (uses the `gws` active account).
6808/// - `rate_limit_per_minute`: `60`.
6809/// - `timeout_secs`: `30`.
6810/// - `audit_log`: `false`.
6811///
6812/// ## Compatibility
6813/// Configs that omit the `[google_workspace]` section entirely are treated as
6814/// `GoogleWorkspaceConfig::default()` (disabled, all defaults allowed). Adding
6815/// the section is purely opt-in and does not affect other config sections.
6816///
6817/// ## Rollback / Migration
6818/// To revert, remove the `[google_workspace]` section from the config file (or
6819/// set `enabled = false`). No data migration is required; the tool simply stops
6820/// being registered.
6821#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
6822#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6823pub struct GoogleWorkspaceAllowedOperation {
6824    /// Google Workspace service ID (for example `gmail` or `drive`).
6825    pub service: String,
6826    /// Top-level resource name for the service (for example `users` for Gmail or `files` for Drive).
6827    pub resource: String,
6828    /// Optional sub-resource for 4-segment gws commands
6829    /// (for example `messages` or `drafts` under `gmail users`).
6830    /// When present, the entry only matches calls that include this exact sub_resource.
6831    /// When absent, the entry only matches calls with no sub_resource.
6832    #[serde(default)]
6833    pub sub_resource: Option<String>,
6834    /// Allowed methods for the service/resource/sub_resource combination.
6835    #[serde(default)]
6836    pub methods: Vec<String>,
6837}
6838
6839/// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]` section).
6840///
6841/// ## Defaults
6842/// - `enabled`: `false` (tool is not registered unless explicitly opted-in).
6843/// - `allowed_services`: empty vector, which grants access to the full default
6844///   service set: `drive`, `sheets`, `gmail`, `calendar`, `docs`, `slides`,
6845///   `tasks`, `people`, `chat`, `classroom`, `forms`, `keep`, `meet`, `events`.
6846/// - `allowed_operations`: empty vector, which preserves the legacy behavior of
6847///   allowing any resource/method under the allowed service set.
6848/// - `credentials_path`: `None` (uses default `gws` credential discovery).
6849/// - `default_account`: `None` (uses the `gws` active account).
6850/// - `rate_limit_per_minute`: `60`.
6851/// - `timeout_secs`: `30`.
6852/// - `audit_log`: `false`.
6853///
6854/// ## Compatibility
6855/// Configs that omit the `[google_workspace]` section entirely are treated as
6856/// `GoogleWorkspaceConfig::default()` (disabled, all defaults allowed). Adding
6857/// the section is purely opt-in and does not affect other config sections.
6858///
6859/// ## Rollback / Migration
6860/// To revert, remove the `[google_workspace]` section from the config file (or
6861/// set `enabled = false`). No data migration is required; the tool simply stops
6862/// being registered.
6863#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6864#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6865#[prefix = "google_workspace"]
6866#[integration(
6867    category = "ToolsAutomation",
6868    display_name = "Google Workspace",
6869    description = "Drive, Gmail, Calendar, Sheets, Docs via gws CLI",
6870    status_field = "enabled"
6871)]
6872pub struct GoogleWorkspaceConfig {
6873    /// Enable the `google_workspace` tool. Default: `false`.
6874    #[serde(default)]
6875    pub enabled: bool,
6876    /// Restrict which Google Workspace services the agent can access.
6877    ///
6878    /// When empty (the default), the full default service set is allowed (see
6879    /// struct-level docs). When non-empty, only the listed service IDs are
6880    /// permitted. Each entry must be non-empty, lowercase alphanumeric with
6881    /// optional underscores/hyphens, and unique.
6882    #[serde(default)]
6883    pub allowed_services: Vec<String>,
6884    /// Restrict which resource/method combinations the agent can access.
6885    ///
6886    /// When empty (the default), all methods under `allowed_services` remain
6887    /// available for backward compatibility. When non-empty, the runtime denies
6888    /// any `(service, resource, sub_resource, method)` combination that is not
6889    /// explicitly listed. `sub_resource` is optional per entry: an entry without
6890    /// it matches only 3-segment `gws` calls; an entry with it matches only calls
6891    /// that supply that exact sub_resource value.
6892    ///
6893    /// Each entry's `service` must appear in `allowed_services` when that list is
6894    /// non-empty; config validation rejects entries that would never match at
6895    /// runtime.
6896    #[serde(default)]
6897    pub allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
6898    /// Path to service account JSON or OAuth client credentials file.
6899    ///
6900    /// When `None`, the tool relies on the default `gws` credential discovery
6901    /// (`gws auth login`). Set this to point at a service-account key or an
6902    /// OAuth client-secrets JSON for headless / CI environments.
6903    #[serde(default)]
6904    pub credentials_path: Option<String>,
6905    /// Default Google account email to pass to `gws --account`.
6906    ///
6907    /// When `None`, the currently active `gws` account is used.
6908    #[serde(default)]
6909    pub default_account: Option<String>,
6910    /// Maximum number of `gws` API calls allowed per minute. Default: `60`.
6911    #[serde(default = "default_gws_rate_limit")]
6912    pub rate_limit_per_minute: u32,
6913    /// Command execution timeout in seconds. Default: `30`.
6914    #[serde(default = "default_gws_timeout_secs")]
6915    pub timeout_secs: u64,
6916    /// Enable audit logging of every `gws` invocation (service, resource,
6917    /// method, timestamp). Default: `false`.
6918    #[serde(default)]
6919    pub audit_log: bool,
6920}
6921
6922fn default_gws_rate_limit() -> u32 {
6923    60
6924}
6925
6926fn default_gws_timeout_secs() -> u64 {
6927    30
6928}
6929
6930impl Default for GoogleWorkspaceConfig {
6931    fn default() -> Self {
6932        Self {
6933            enabled: false,
6934            allowed_services: Vec::new(),
6935            allowed_operations: Vec::new(),
6936            credentials_path: None,
6937            default_account: None,
6938            rate_limit_per_minute: default_gws_rate_limit(),
6939            timeout_secs: default_gws_timeout_secs(),
6940            audit_log: false,
6941        }
6942    }
6943}
6944
6945// ── Knowledge ───────────────────────────────────────────────────
6946
6947/// Knowledge graph configuration for capturing and reusing expertise.
6948#[allow(clippy::struct_excessive_bools)]
6949#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6950#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6951#[prefix = "knowledge"]
6952pub struct KnowledgeConfig {
6953    /// Enable the knowledge graph tool. Default: false.
6954    #[serde(default)]
6955    pub enabled: bool,
6956    /// Path to the knowledge graph SQLite database.
6957    #[serde(default = "default_knowledge_db_path")]
6958    pub db_path: String,
6959    /// Maximum number of knowledge nodes. Default: 100000.
6960    #[serde(default = "default_knowledge_max_nodes")]
6961    pub max_nodes: usize,
6962    /// Automatically capture knowledge from conversations. Default: false.
6963    #[serde(default)]
6964    pub auto_capture: bool,
6965    /// Proactively suggest relevant knowledge on queries. Default: true.
6966    #[serde(default = "default_true")]
6967    pub suggest_on_query: bool,
6968}
6969
6970fn default_knowledge_db_path() -> String {
6971    default_path_under_config_dir("knowledge.db")
6972}
6973
6974fn default_knowledge_max_nodes() -> usize {
6975    100_000
6976}
6977
6978impl Default for KnowledgeConfig {
6979    fn default() -> Self {
6980        Self {
6981            enabled: false,
6982            db_path: default_knowledge_db_path(),
6983            max_nodes: default_knowledge_max_nodes(),
6984            auto_capture: false,
6985            suggest_on_query: true,
6986        }
6987    }
6988}
6989
6990// ── LinkedIn ────────────────────────────────────────────────────
6991
6992/// LinkedIn integration configuration (`[linkedin]` section).
6993///
6994/// When enabled, the `linkedin` tool is registered in the agent tool surface.
6995/// Requires `LINKEDIN_*` credentials in the workspace `.env` file.
6996#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
6997#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
6998#[prefix = "linkedin"]
6999pub struct LinkedInConfig {
7000    /// Enable the LinkedIn tool.
7001    #[serde(default)]
7002    pub enabled: bool,
7003
7004    /// LinkedIn REST API version header (YYYYMM format).
7005    #[serde(default = "default_linkedin_api_version")]
7006    pub api_version: String,
7007
7008    /// Content strategy for automated posting.
7009    #[serde(default)]
7010    #[nested]
7011    pub content: LinkedInContentConfig,
7012
7013    /// Image generation for posts (`[linkedin.image]`).
7014    #[serde(default)]
7015    #[nested]
7016    pub image: LinkedInImageConfig,
7017}
7018
7019impl Default for LinkedInConfig {
7020    fn default() -> Self {
7021        Self {
7022            enabled: false,
7023            api_version: default_linkedin_api_version(),
7024            content: LinkedInContentConfig::default(),
7025            image: LinkedInImageConfig::default(),
7026        }
7027    }
7028}
7029
7030fn default_linkedin_api_version() -> String {
7031    "202602".to_string()
7032}
7033
7034/// Plugin system configuration.
7035#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7036#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7037#[prefix = "plugins"]
7038pub struct PluginsConfig {
7039    /// Enable the plugin system (default: false)
7040    #[serde(default)]
7041    pub enabled: bool,
7042    /// Directory where plugins are stored
7043    #[serde(default = "default_plugins_dir")]
7044    pub plugins_dir: String,
7045    /// Auto-discover and load plugins on startup
7046    #[serde(default)]
7047    pub auto_discover: bool,
7048    /// Maximum number of plugins that can be loaded
7049    #[serde(default = "default_max_plugins")]
7050    pub max_plugins: usize,
7051    /// Plugin signature verification security settings
7052    #[serde(default)]
7053    #[nested]
7054    pub security: PluginSecurityConfig,
7055}
7056
7057/// Plugin signature verification configuration (`[plugins.security]`).
7058///
7059/// Controls Ed25519 signature verification for plugin manifests.
7060/// In `strict` mode, only plugins signed by a trusted publisher key are loaded.
7061/// In `permissive` mode, unsigned or untrusted plugins produce warnings but are
7062/// still loaded. In `disabled` mode (the default), no signature checking occurs.
7063#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7064#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7065#[prefix = "plugins.security"]
7066pub struct PluginSecurityConfig {
7067    /// Signature enforcement mode: "disabled", "permissive", or "strict".
7068    #[serde(default = "default_signature_mode")]
7069    pub signature_mode: String,
7070    /// Hex-encoded Ed25519 public keys of trusted plugin publishers.
7071    #[serde(default)]
7072    pub trusted_publisher_keys: Vec<String>,
7073}
7074
7075fn default_signature_mode() -> String {
7076    "disabled".to_string()
7077}
7078
7079impl Default for PluginSecurityConfig {
7080    fn default() -> Self {
7081        Self {
7082            signature_mode: default_signature_mode(),
7083            trusted_publisher_keys: Vec::new(),
7084        }
7085    }
7086}
7087
7088fn default_plugins_dir() -> String {
7089    default_path_under_config_dir("plugins")
7090}
7091
7092fn default_max_plugins() -> usize {
7093    50
7094}
7095
7096impl Default for PluginsConfig {
7097    fn default() -> Self {
7098        Self {
7099            enabled: false,
7100            plugins_dir: default_plugins_dir(),
7101            auto_discover: false,
7102            max_plugins: default_max_plugins(),
7103            security: PluginSecurityConfig::default(),
7104        }
7105    }
7106}
7107
7108/// Content strategy configuration for LinkedIn auto-posting (`[linkedin.content]`).
7109///
7110/// The agent reads this via the `linkedin get_content_strategy` action to know
7111/// what feeds to check, which repos to highlight, and how to write posts.
7112#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
7113#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7114#[prefix = "linkedin.content"]
7115pub struct LinkedInContentConfig {
7116    /// RSS feed URLs to monitor for topic inspiration (titles only).
7117    #[serde(default)]
7118    pub rss_feeds: Vec<String>,
7119
7120    /// GitHub usernames whose public activity to reference.
7121    #[serde(default)]
7122    pub github_users: Vec<String>,
7123
7124    /// GitHub repositories to highlight (format: `owner/repo`).
7125    #[serde(default)]
7126    pub github_repos: Vec<String>,
7127
7128    /// Topics of expertise and interest for post themes.
7129    #[serde(default)]
7130    pub topics: Vec<String>,
7131
7132    /// Professional persona description (name, role, expertise).
7133    #[serde(default)]
7134    pub persona: String,
7135
7136    /// Freeform posting instructions for the AI agent.
7137    #[serde(default)]
7138    pub instructions: String,
7139}
7140
7141/// Image generation configuration for LinkedIn posts (`[linkedin.image]`).
7142#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7143#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7144#[prefix = "linkedin.image"]
7145pub struct LinkedInImageConfig {
7146    /// Enable image generation for posts.
7147    #[serde(default)]
7148    pub enabled: bool,
7149
7150    /// ModelProvider priority order. Tried in sequence; first success wins.
7151    #[serde(default = "default_image_providers")]
7152    pub providers: Vec<String>,
7153
7154    /// Generate a branded SVG text card when all AI model_providers fail.
7155    #[serde(default = "default_true")]
7156    pub fallback_card: bool,
7157
7158    /// Accent color for the fallback card (CSS hex).
7159    #[serde(default = "default_card_accent_color")]
7160    pub card_accent_color: String,
7161
7162    /// Temp directory for generated images, relative to workspace.
7163    #[serde(default = "default_image_temp_dir")]
7164    pub temp_dir: String,
7165
7166    /// Stability AI model_provider settings.
7167    #[serde(default)]
7168    #[nested]
7169    pub stability: ImageProviderStabilityConfig,
7170
7171    /// Google Imagen (Vertex AI) model_provider settings.
7172    #[serde(default)]
7173    #[nested]
7174    pub imagen: ImageProviderImagenConfig,
7175
7176    /// OpenAI DALL-E model_provider settings.
7177    #[serde(default)]
7178    #[nested]
7179    pub dalle: ImageProviderDalleConfig,
7180
7181    /// Flux (fal.ai) model_provider settings.
7182    #[serde(default)]
7183    #[nested]
7184    pub flux: ImageProviderFluxConfig,
7185}
7186
7187fn default_image_providers() -> Vec<String> {
7188    vec![
7189        "stability".into(),
7190        "imagen".into(),
7191        "dalle".into(),
7192        "flux".into(),
7193    ]
7194}
7195
7196fn default_card_accent_color() -> String {
7197    "#0A66C2".into()
7198}
7199
7200fn default_image_temp_dir() -> String {
7201    "linkedin/images".into()
7202}
7203
7204impl Default for LinkedInImageConfig {
7205    fn default() -> Self {
7206        Self {
7207            enabled: false,
7208            providers: default_image_providers(),
7209            fallback_card: true,
7210            card_accent_color: default_card_accent_color(),
7211            temp_dir: default_image_temp_dir(),
7212            stability: ImageProviderStabilityConfig::default(),
7213            imagen: ImageProviderImagenConfig::default(),
7214            dalle: ImageProviderDalleConfig::default(),
7215            flux: ImageProviderFluxConfig::default(),
7216        }
7217    }
7218}
7219
7220/// Stability AI image generation settings (`[linkedin.image.stability]`).
7221#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7222#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7223#[prefix = "linkedin.image.stability"]
7224pub struct ImageProviderStabilityConfig {
7225    /// Environment variable name holding the API key.
7226    #[serde(default = "default_stability_api_key_env")]
7227    #[credential_class = "legacy_env_path"]
7228    pub api_key_env: String,
7229    /// Stability model identifier.
7230    #[serde(default = "default_stability_model")]
7231    pub model: String,
7232}
7233
7234fn default_stability_api_key_env() -> String {
7235    "STABILITY_API_KEY".into()
7236}
7237fn default_stability_model() -> String {
7238    "stable-diffusion-xl-1024-v1-0".into()
7239}
7240
7241impl Default for ImageProviderStabilityConfig {
7242    fn default() -> Self {
7243        Self {
7244            api_key_env: default_stability_api_key_env(),
7245            model: default_stability_model(),
7246        }
7247    }
7248}
7249
7250/// Google Imagen (Vertex AI) settings (`[linkedin.image.imagen]`).
7251#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7252#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7253#[prefix = "linkedin.image.imagen"]
7254pub struct ImageProviderImagenConfig {
7255    /// Environment variable name holding the API key.
7256    #[serde(default = "default_imagen_api_key_env")]
7257    #[credential_class = "legacy_env_path"]
7258    pub api_key_env: String,
7259    /// Environment variable for the Google Cloud project ID.
7260    #[serde(default = "default_imagen_project_id_env")]
7261    #[credential_class = "legacy_env_path"]
7262    pub project_id_env: String,
7263    /// Vertex AI region.
7264    #[serde(default = "default_imagen_region")]
7265    pub region: String,
7266}
7267
7268fn default_imagen_api_key_env() -> String {
7269    "GOOGLE_VERTEX_API_KEY".into()
7270}
7271fn default_imagen_project_id_env() -> String {
7272    "GOOGLE_CLOUD_PROJECT".into()
7273}
7274fn default_imagen_region() -> String {
7275    "us-central1".into()
7276}
7277
7278impl Default for ImageProviderImagenConfig {
7279    fn default() -> Self {
7280        Self {
7281            api_key_env: default_imagen_api_key_env(),
7282            project_id_env: default_imagen_project_id_env(),
7283            region: default_imagen_region(),
7284        }
7285    }
7286}
7287
7288/// OpenAI DALL-E settings (`[linkedin.image.dalle]`).
7289#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7290#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7291#[prefix = "linkedin.image.dalle"]
7292pub struct ImageProviderDalleConfig {
7293    /// Environment variable name holding the OpenAI API key.
7294    #[serde(default = "default_dalle_api_key_env")]
7295    #[credential_class = "legacy_env_path"]
7296    pub api_key_env: String,
7297    /// DALL-E model identifier.
7298    #[serde(default = "default_dalle_model")]
7299    pub model: String,
7300    /// Image dimensions.
7301    #[serde(default = "default_dalle_size")]
7302    pub size: String,
7303}
7304
7305fn default_dalle_api_key_env() -> String {
7306    "OPENAI_API_KEY".into()
7307}
7308fn default_dalle_model() -> String {
7309    "dall-e-3".into()
7310}
7311fn default_dalle_size() -> String {
7312    "1024x1024".into()
7313}
7314
7315impl Default for ImageProviderDalleConfig {
7316    fn default() -> Self {
7317        Self {
7318            api_key_env: default_dalle_api_key_env(),
7319            model: default_dalle_model(),
7320            size: default_dalle_size(),
7321        }
7322    }
7323}
7324
7325/// Flux (fal.ai) image generation settings (`[linkedin.image.flux]`).
7326#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7327#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7328#[prefix = "linkedin.image.flux"]
7329pub struct ImageProviderFluxConfig {
7330    /// Environment variable name holding the fal.ai API key.
7331    #[serde(default = "default_flux_api_key_env")]
7332    #[credential_class = "legacy_env_path"]
7333    pub api_key_env: String,
7334    /// Flux model identifier.
7335    #[serde(default = "default_flux_model")]
7336    pub model: String,
7337}
7338
7339fn default_flux_api_key_env() -> String {
7340    "FAL_API_KEY".into()
7341}
7342fn default_flux_model() -> String {
7343    "fal-ai/flux/schnell".into()
7344}
7345
7346impl Default for ImageProviderFluxConfig {
7347    fn default() -> Self {
7348        Self {
7349            api_key_env: default_flux_api_key_env(),
7350            model: default_flux_model(),
7351        }
7352    }
7353}
7354
7355// ── Standalone Image Generation ─────────────────────────────────
7356
7357/// Standalone image generation tool configuration (`[image_gen]`).
7358///
7359/// When enabled, registers an `image_gen` tool that generates images via
7360/// fal.ai's synchronous API (Flux / Nano Banana models) and saves them
7361/// to the workspace `images/` directory.
7362#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7363#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7364#[prefix = "image_gen"]
7365pub struct ImageGenConfig {
7366    /// Enable the standalone image generation tool. Default: false.
7367    #[serde(default)]
7368    pub enabled: bool,
7369
7370    /// Default fal.ai model identifier.
7371    #[serde(default = "default_image_gen_model")]
7372    pub default_model: String,
7373
7374    /// Environment variable name holding the fal.ai API key.
7375    #[serde(default = "default_image_gen_api_key_env")]
7376    #[credential_class = "legacy_env_path"]
7377    pub api_key_env: String,
7378}
7379
7380fn default_image_gen_model() -> String {
7381    "fal-ai/flux/schnell".into()
7382}
7383
7384fn default_image_gen_api_key_env() -> String {
7385    "FAL_API_KEY".into()
7386}
7387
7388impl Default for ImageGenConfig {
7389    fn default() -> Self {
7390        Self {
7391            enabled: false,
7392            default_model: default_image_gen_model(),
7393            api_key_env: default_image_gen_api_key_env(),
7394        }
7395    }
7396}
7397
7398// ── File Upload ─────────────────────────────────────────────────
7399
7400/// Standalone file upload tool configuration (`[file_upload]`).
7401///
7402/// When `url` is set to a non-empty value, registers a `file_upload` tool that
7403/// POSTs files from the agent's local filesystem to the configured endpoint
7404/// using `multipart/form-data`. The LLM provides only a file path; the host
7405/// reads the bytes and uploads them without ever including file content in
7406/// the model context.
7407///
7408/// When `url` is `None` or empty, the tool is not registered.
7409#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7410#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7411#[prefix = "file_upload"]
7412pub struct FileUploadConfig {
7413    /// Upload endpoint URL. Tool is disabled when this is `None` or empty.
7414    #[serde(default)]
7415    pub url: Option<String>,
7416
7417    /// HTTP method. Only `POST` (default) and `PUT` are accepted.
7418    #[serde(default = "default_file_upload_method")]
7419    pub method: String,
7420
7421    /// Multipart form-field name for the file part. Default: `file`.
7422    #[serde(default = "default_file_upload_field_name")]
7423    pub field_name: String,
7424
7425    /// Maximum file size in bytes. Larger files are rejected before any
7426    /// bytes hit the network. Default: 25 MiB.
7427    #[serde(default = "default_file_upload_max_size_bytes")]
7428    pub max_file_size_bytes: u64,
7429
7430    /// Request timeout in seconds. Default: 60.
7431    #[serde(default = "default_file_upload_timeout_secs")]
7432    pub timeout_secs: u64,
7433
7434    /// Static HTTP headers attached to every upload request. Same shape as
7435    /// `[mcp.servers.*.headers]`.
7436    #[serde(default)]
7437    #[secret]
7438    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
7439    pub headers: HashMap<String, String>,
7440}
7441
7442fn default_file_upload_method() -> String {
7443    "POST".into()
7444}
7445
7446fn default_file_upload_field_name() -> String {
7447    "file".into()
7448}
7449
7450fn default_file_upload_max_size_bytes() -> u64 {
7451    25 * 1024 * 1024
7452}
7453
7454fn default_file_upload_timeout_secs() -> u64 {
7455    60
7456}
7457
7458impl Default for FileUploadConfig {
7459    fn default() -> Self {
7460        Self {
7461            url: None,
7462            method: default_file_upload_method(),
7463            field_name: default_file_upload_field_name(),
7464            max_file_size_bytes: default_file_upload_max_size_bytes(),
7465            timeout_secs: default_file_upload_timeout_secs(),
7466            headers: HashMap::new(),
7467        }
7468    }
7469}
7470
7471// ── File Upload Bundle ──────────────────────────────────────────
7472
7473/// Standalone multi-file bundle upload tool configuration
7474/// (`[file_upload_bundle]`).
7475///
7476/// When `url` is set to a non-empty value, registers a `file_upload_bundle`
7477/// tool that POSTs N files from the agent's local filesystem to the
7478/// configured endpoint as a single `multipart/form-data` request. The LLM
7479/// provides only file paths; the host reads the bytes.
7480///
7481/// When `url` is `None` or empty, the tool is not registered.
7482#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7483#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7484#[prefix = "file-upload-bundle"]
7485pub struct FileUploadBundleConfig {
7486    /// Upload endpoint URL. Tool is disabled when this is `None` or empty.
7487    #[serde(default)]
7488    pub url: Option<String>,
7489
7490    /// HTTP method. Only `POST` (default) and `PUT` are accepted.
7491    #[serde(default = "default_file_upload_bundle_method")]
7492    pub method: String,
7493
7494    /// Multipart form-field name reused across every file part. Default: `file`.
7495    #[serde(default = "default_file_upload_bundle_field_name")]
7496    pub field_name: String,
7497
7498    /// Maximum per-file size in bytes. Default: 10 MiB.
7499    #[serde(default = "default_file_upload_bundle_max_file_size_bytes")]
7500    pub max_file_size_bytes: u64,
7501
7502    /// Maximum cumulative size across every file in one call. Default: 32 MiB.
7503    #[serde(default = "default_file_upload_bundle_max_total_size_bytes")]
7504    pub max_total_size_bytes: u64,
7505
7506    /// Maximum number of files per call. Default: 16.
7507    #[serde(default = "default_file_upload_bundle_max_files")]
7508    pub max_files: u32,
7509
7510    /// Request timeout in seconds. Default: 120.
7511    #[serde(default = "default_file_upload_bundle_timeout_secs")]
7512    pub timeout_secs: u64,
7513
7514    /// Maximum response body bytes to read from the upload endpoint.
7515    /// Prevents unbounded memory use from a malicious or verbose receiver.
7516    /// Default: 4096 (4 KiB).
7517    #[serde(default = "default_file_upload_bundle_max_response_body_bytes")]
7518    pub max_response_body_bytes: usize,
7519
7520    /// Static HTTP headers attached to every upload request.
7521    #[serde(default)]
7522    pub headers: HashMap<String, String>,
7523}
7524
7525fn default_file_upload_bundle_method() -> String {
7526    "POST".into()
7527}
7528
7529fn default_file_upload_bundle_field_name() -> String {
7530    "file".into()
7531}
7532
7533fn default_file_upload_bundle_max_file_size_bytes() -> u64 {
7534    10 * 1024 * 1024
7535}
7536
7537fn default_file_upload_bundle_max_total_size_bytes() -> u64 {
7538    32 * 1024 * 1024
7539}
7540
7541fn default_file_upload_bundle_max_files() -> u32 {
7542    16
7543}
7544
7545fn default_file_upload_bundle_timeout_secs() -> u64 {
7546    120
7547}
7548
7549fn default_file_upload_bundle_max_response_body_bytes() -> usize {
7550    4 * 1024
7551}
7552
7553impl Default for FileUploadBundleConfig {
7554    fn default() -> Self {
7555        Self {
7556            url: None,
7557            method: default_file_upload_bundle_method(),
7558            field_name: default_file_upload_bundle_field_name(),
7559            max_file_size_bytes: default_file_upload_bundle_max_file_size_bytes(),
7560            max_total_size_bytes: default_file_upload_bundle_max_total_size_bytes(),
7561            max_files: default_file_upload_bundle_max_files(),
7562            timeout_secs: default_file_upload_bundle_timeout_secs(),
7563            max_response_body_bytes: default_file_upload_bundle_max_response_body_bytes(),
7564            headers: HashMap::new(),
7565        }
7566    }
7567}
7568
7569// ── File Download ───────────────────────────────────────────────
7570
7571/// Standalone file download tool configuration (`[file_download]`).
7572///
7573/// When `url` is set to a non-empty value, registers a `file_download` tool
7574/// that GETs a file from the configured endpoint and writes it to the agent's
7575/// workspace filesystem. The LLM supplies only a document identifier and a
7576/// workspace-relative destination path; the endpoint URL comes solely from this
7577/// config and is never model-controlled. Response bytes are streamed to disk
7578/// and never loaded into model context.
7579///
7580/// When `url` is `None` or empty, the tool is not registered.
7581#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7582#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7583#[prefix = "file-download"]
7584pub struct FileDownloadConfig {
7585    /// Download endpoint URL. Tool is disabled when this is `None` or empty.
7586    /// The file to fetch is selected by the `document_id` query parameter.
7587    #[serde(default)]
7588    pub url: Option<String>,
7589
7590    /// Maximum download size in bytes. Enforced while streaming: the transfer
7591    /// is aborted and the partial file removed once this ceiling is exceeded,
7592    /// so an oversized or unbounded body never fully buffers in memory or lands
7593    /// on disk. Default: 25 MiB.
7594    #[serde(default = "default_file_download_max_size_bytes")]
7595    pub max_file_size_bytes: u64,
7596
7597    /// Request timeout in seconds. Default: 120.
7598    #[serde(default = "default_file_download_timeout_secs")]
7599    pub timeout_secs: u64,
7600
7601    /// Static HTTP headers attached to every download request — typically an
7602    /// `Authorization: Bearer …` token for the upstream endpoint. Same shape as
7603    /// `[mcp.servers.*.headers]`.
7604    #[serde(default)]
7605    pub headers: HashMap<String, String>,
7606}
7607
7608fn default_file_download_max_size_bytes() -> u64 {
7609    25 * 1024 * 1024
7610}
7611
7612fn default_file_download_timeout_secs() -> u64 {
7613    120
7614}
7615
7616impl Default for FileDownloadConfig {
7617    fn default() -> Self {
7618        Self {
7619            url: None,
7620            max_file_size_bytes: default_file_download_max_size_bytes(),
7621            timeout_secs: default_file_download_timeout_secs(),
7622            headers: HashMap::new(),
7623        }
7624    }
7625}
7626
7627// ── Claude Code ─────────────────────────────────────────────────
7628
7629/// Claude Code CLI tool configuration (`[claude_code]` section).
7630///
7631/// Delegates coding tasks to the `claude -p` CLI. Authentication uses the
7632/// binary's own OAuth session (Max subscription) by default — no API key
7633/// needed unless `env_passthrough` includes `ANTHROPIC_API_KEY`.
7634#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7635#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7636#[prefix = "claude_code"]
7637pub struct ClaudeCodeConfig {
7638    /// Enable the `claude_code` tool
7639    #[serde(default)]
7640    pub enabled: bool,
7641    /// Maximum execution time in seconds (coding tasks can be long)
7642    #[serde(default = "default_claude_code_timeout_secs")]
7643    pub timeout_secs: u64,
7644    /// Claude Code tools the subprocess is allowed to use
7645    #[serde(default = "default_claude_code_allowed_tools")]
7646    pub allowed_tools: Vec<String>,
7647    /// Optional system prompt appended to Claude Code invocations
7648    #[serde(default)]
7649    pub system_prompt: Option<String>,
7650    /// Maximum output size in bytes (2MB default)
7651    #[serde(default = "default_claude_code_max_output_bytes")]
7652    pub max_output_bytes: usize,
7653    /// Extra env vars passed to the claude subprocess (e.g. ANTHROPIC_API_KEY for API-key billing)
7654    #[serde(default)]
7655    #[credential_class = "legacy_env_path"]
7656    pub env_passthrough: Vec<String>,
7657}
7658
7659fn default_claude_code_timeout_secs() -> u64 {
7660    600
7661}
7662
7663fn default_claude_code_allowed_tools() -> Vec<String> {
7664    vec!["Read".into(), "Edit".into(), "Bash".into(), "Write".into()]
7665}
7666
7667fn default_claude_code_max_output_bytes() -> usize {
7668    2_097_152
7669}
7670
7671impl Default for ClaudeCodeConfig {
7672    fn default() -> Self {
7673        Self {
7674            enabled: false,
7675            timeout_secs: default_claude_code_timeout_secs(),
7676            allowed_tools: default_claude_code_allowed_tools(),
7677            system_prompt: None,
7678            max_output_bytes: default_claude_code_max_output_bytes(),
7679            env_passthrough: Vec::new(),
7680        }
7681    }
7682}
7683
7684// ── Claude Code Runner ──────────────────────────────────────────
7685
7686/// Claude Code task runner configuration (`[claude_code_runner]` section).
7687///
7688/// Spawns Claude Code in a tmux session with HTTP hooks that POST tool
7689/// execution events back to ZeroClaw's gateway, updating a Slack message
7690/// in-place with progress plus an SSH handoff link.
7691#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7692#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7693#[prefix = "claude_code_runner"]
7694pub struct ClaudeCodeRunnerConfig {
7695    /// Enable the `claude_code_runner` tool
7696    #[serde(default)]
7697    pub enabled: bool,
7698    /// SSH host for session handoff links (e.g. "myhost.example.com")
7699    #[serde(default)]
7700    pub ssh_host: Option<String>,
7701    /// Prefix for tmux session names (default: "zc-claude-")
7702    #[serde(default = "default_claude_code_runner_tmux_prefix")]
7703    pub tmux_prefix: String,
7704    /// Session time-to-live in seconds before auto-cleanup (default: 3600)
7705    #[serde(default = "default_claude_code_runner_session_ttl")]
7706    pub session_ttl: u64,
7707}
7708
7709fn default_claude_code_runner_tmux_prefix() -> String {
7710    "zc-claude-".into()
7711}
7712
7713fn default_claude_code_runner_session_ttl() -> u64 {
7714    3600
7715}
7716
7717impl Default for ClaudeCodeRunnerConfig {
7718    fn default() -> Self {
7719        Self {
7720            enabled: false,
7721            ssh_host: None,
7722            tmux_prefix: default_claude_code_runner_tmux_prefix(),
7723            session_ttl: default_claude_code_runner_session_ttl(),
7724        }
7725    }
7726}
7727
7728// ── Codex CLI ───────────────────────────────────────────────────
7729
7730/// Codex CLI tool configuration (`[codex_cli]` section).
7731///
7732/// Delegates coding tasks to the `codex exec` CLI. Authentication uses the
7733/// binary's own session by default — no API key needed unless
7734/// `env_passthrough` includes `OPENAI_API_KEY`.
7735#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7736#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7737#[prefix = "codex_cli"]
7738pub struct CodexCliConfig {
7739    /// Enable the `codex_cli` tool
7740    #[serde(default)]
7741    pub enabled: bool,
7742    /// Maximum execution time in seconds (coding tasks can be long)
7743    #[serde(default = "default_codex_cli_timeout_secs")]
7744    pub timeout_secs: u64,
7745    /// Maximum output size in bytes (2MB default)
7746    #[serde(default = "default_codex_cli_max_output_bytes")]
7747    pub max_output_bytes: usize,
7748    /// Extra env vars passed to the codex subprocess (e.g. OPENAI_API_KEY)
7749    #[serde(default)]
7750    #[credential_class = "legacy_env_path"]
7751    pub env_passthrough: Vec<String>,
7752    /// Extra CLI arguments appended to `codex exec` before the prompt.
7753    ///
7754    /// Values come from operator-controlled config (same trust level as
7755    /// `env_passthrough`) and are not validated — the operator is responsible
7756    /// for understanding the implications of flags passed here.
7757    ///
7758    /// **Warning:** `--sandbox=danger-full-access` disables Codex's bubblewrap
7759    /// isolation; only use in environments where the container itself provides
7760    /// isolation (e.g. Kubernetes pods with restricted PSS).
7761    ///
7762    /// Example: `["--sandbox=danger-full-access", "--skip-git-repo-check"]`
7763    #[serde(default)]
7764    pub extra_args: Vec<String>,
7765}
7766
7767fn default_codex_cli_timeout_secs() -> u64 {
7768    600
7769}
7770
7771fn default_codex_cli_max_output_bytes() -> usize {
7772    2_097_152
7773}
7774
7775impl Default for CodexCliConfig {
7776    fn default() -> Self {
7777        Self {
7778            enabled: false,
7779            timeout_secs: default_codex_cli_timeout_secs(),
7780            max_output_bytes: default_codex_cli_max_output_bytes(),
7781            env_passthrough: Vec::new(),
7782            extra_args: Vec::new(),
7783        }
7784    }
7785}
7786
7787// ── Gemini CLI ──────────────────────────────────────────────────
7788
7789/// Gemini CLI tool configuration (`[gemini_cli]` section).
7790///
7791/// Delegates coding tasks to the `gemini -p` CLI. Authentication uses the
7792/// binary's own session by default — no API key needed unless
7793/// `env_passthrough` includes `GOOGLE_API_KEY`.
7794#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7795#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7796#[prefix = "gemini_cli"]
7797pub struct GeminiCliConfig {
7798    /// Enable the `gemini_cli` tool
7799    #[serde(default)]
7800    pub enabled: bool,
7801    /// Maximum execution time in seconds (coding tasks can be long)
7802    #[serde(default = "default_gemini_cli_timeout_secs")]
7803    pub timeout_secs: u64,
7804    /// Maximum output size in bytes (2MB default)
7805    #[serde(default = "default_gemini_cli_max_output_bytes")]
7806    pub max_output_bytes: usize,
7807    /// Extra env vars passed to the gemini subprocess (e.g. GOOGLE_API_KEY)
7808    #[serde(default)]
7809    #[credential_class = "legacy_env_path"]
7810    pub env_passthrough: Vec<String>,
7811}
7812
7813fn default_gemini_cli_timeout_secs() -> u64 {
7814    600
7815}
7816
7817fn default_gemini_cli_max_output_bytes() -> usize {
7818    2_097_152
7819}
7820
7821impl Default for GeminiCliConfig {
7822    fn default() -> Self {
7823        Self {
7824            enabled: false,
7825            timeout_secs: default_gemini_cli_timeout_secs(),
7826            max_output_bytes: default_gemini_cli_max_output_bytes(),
7827            env_passthrough: Vec::new(),
7828        }
7829    }
7830}
7831
7832// ── OpenCode CLI ───────────────────────────────────────────────
7833
7834/// OpenCode CLI tool configuration (`[opencode_cli]` section).
7835///
7836/// Delegates coding tasks to the `opencode run` CLI. Authentication uses the
7837/// binary's own session by default — no API key needed unless
7838/// `env_passthrough` includes provider-specific keys.
7839#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7840#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7841#[prefix = "opencode_cli"]
7842pub struct OpenCodeCliConfig {
7843    /// Enable the `opencode_cli` tool
7844    #[serde(default)]
7845    pub enabled: bool,
7846    /// Maximum execution time in seconds (coding tasks can be long)
7847    #[serde(default = "default_opencode_cli_timeout_secs")]
7848    pub timeout_secs: u64,
7849    /// Maximum output size in bytes (2MB default)
7850    #[serde(default = "default_opencode_cli_max_output_bytes")]
7851    pub max_output_bytes: usize,
7852    /// Extra env vars passed to the opencode subprocess
7853    #[serde(default)]
7854    #[credential_class = "legacy_env_path"]
7855    pub env_passthrough: Vec<String>,
7856}
7857
7858fn default_opencode_cli_timeout_secs() -> u64 {
7859    600
7860}
7861
7862fn default_opencode_cli_max_output_bytes() -> usize {
7863    2_097_152
7864}
7865
7866impl Default for OpenCodeCliConfig {
7867    fn default() -> Self {
7868        Self {
7869            enabled: false,
7870            timeout_secs: default_opencode_cli_timeout_secs(),
7871            max_output_bytes: default_opencode_cli_max_output_bytes(),
7872            env_passthrough: Vec::new(),
7873        }
7874    }
7875}
7876
7877// ── Proxy ───────────────────────────────────────────────────────
7878
7879/// Proxy application scope — determines which outbound traffic uses the proxy.
7880#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
7881#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7882#[serde(rename_all = "snake_case")]
7883pub enum ProxyScope {
7884    /// Use system environment proxy variables only.
7885    Environment,
7886    /// Apply proxy to all ZeroClaw-managed HTTP traffic (default).
7887    #[default]
7888    Zeroclaw,
7889    /// Apply proxy only to explicitly listed service selectors.
7890    Services,
7891}
7892
7893/// Proxy configuration for outbound HTTP/HTTPS/SOCKS5 traffic (`[proxy]` section).
7894#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
7895#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
7896#[prefix = "proxy"]
7897pub struct ProxyConfig {
7898    /// Enable proxy support for selected scope.
7899    #[serde(default)]
7900    pub enabled: bool,
7901    /// Proxy URL for HTTP requests (supports http, https, socks5, socks5h).
7902    #[serde(default)]
7903    pub http_proxy: Option<String>,
7904    /// Proxy URL for HTTPS requests (supports http, https, socks5, socks5h).
7905    #[serde(default)]
7906    pub https_proxy: Option<String>,
7907    /// Fallback proxy URL for all schemes.
7908    #[serde(default)]
7909    pub all_proxy: Option<String>,
7910    /// No-proxy bypass list. Same format as NO_PROXY.
7911    #[serde(default)]
7912    pub no_proxy: Vec<String>,
7913    /// Proxy application scope.
7914    #[serde(default)]
7915    pub scope: ProxyScope,
7916    /// Service selectors used when scope = "services".
7917    #[serde(default)]
7918    pub services: Vec<String>,
7919}
7920
7921impl Default for ProxyConfig {
7922    fn default() -> Self {
7923        Self {
7924            enabled: false,
7925            http_proxy: None,
7926            https_proxy: None,
7927            all_proxy: None,
7928            no_proxy: Vec::new(),
7929            scope: ProxyScope::Zeroclaw,
7930            services: Vec::new(),
7931        }
7932    }
7933}
7934
7935impl ProxyConfig {
7936    pub fn supported_service_keys() -> &'static [&'static str] {
7937        SUPPORTED_PROXY_SERVICE_KEYS
7938    }
7939
7940    pub fn supported_service_selectors() -> &'static [&'static str] {
7941        SUPPORTED_PROXY_SERVICE_SELECTORS
7942    }
7943
7944    pub fn has_any_proxy_url(&self) -> bool {
7945        normalize_proxy_url_option(self.http_proxy.as_deref()).is_some()
7946            || normalize_proxy_url_option(self.https_proxy.as_deref()).is_some()
7947            || normalize_proxy_url_option(self.all_proxy.as_deref()).is_some()
7948    }
7949
7950    pub fn normalized_services(&self) -> Vec<String> {
7951        normalize_service_list(self.services.clone())
7952    }
7953
7954    pub fn normalized_no_proxy(&self) -> Vec<String> {
7955        normalize_no_proxy_list(self.no_proxy.clone())
7956    }
7957
7958    pub fn validate(&self) -> Result<()> {
7959        for (field, value) in [
7960            ("http_proxy", self.http_proxy.as_deref()),
7961            ("https_proxy", self.https_proxy.as_deref()),
7962            ("all_proxy", self.all_proxy.as_deref()),
7963        ] {
7964            if let Some(url) = normalize_proxy_url_option(value) {
7965                validate_proxy_url(field, &url)?;
7966            }
7967        }
7968
7969        for selector in self.normalized_services() {
7970            if !is_supported_proxy_service_selector(&selector) {
7971                anyhow::bail!(
7972                    "Unsupported proxy service selector '{selector}'. Use tool `proxy_config` action `list_services` for valid values"
7973                );
7974            }
7975        }
7976
7977        if self.enabled && !self.has_any_proxy_url() {
7978            anyhow::bail!(
7979                "Proxy is enabled but no proxy URL is configured. Set at least one of http_proxy, https_proxy, or all_proxy"
7980            );
7981        }
7982
7983        if self.enabled
7984            && self.scope == ProxyScope::Services
7985            && self.normalized_services().is_empty()
7986        {
7987            anyhow::bail!(
7988                "proxy.scope='services' requires a non-empty proxy.services list when proxy is enabled"
7989            );
7990        }
7991
7992        Ok(())
7993    }
7994
7995    pub fn should_apply_to_service(&self, service_key: &str) -> bool {
7996        if !self.enabled {
7997            return false;
7998        }
7999
8000        match self.scope {
8001            ProxyScope::Environment => false,
8002            ProxyScope::Zeroclaw => true,
8003            ProxyScope::Services => {
8004                let service_key = service_key.trim().to_ascii_lowercase();
8005                if service_key.is_empty() {
8006                    return false;
8007                }
8008
8009                self.normalized_services()
8010                    .iter()
8011                    .any(|selector| service_selector_matches(selector, &service_key))
8012            }
8013        }
8014    }
8015
8016    pub fn apply_to_reqwest_builder(
8017        &self,
8018        mut builder: reqwest::ClientBuilder,
8019        service_key: &str,
8020    ) -> reqwest::ClientBuilder {
8021        if !self.should_apply_to_service(service_key) {
8022            return builder;
8023        }
8024
8025        let no_proxy = self.no_proxy_value();
8026
8027        if let Some(url) = normalize_proxy_url_option(self.all_proxy.as_deref()) {
8028            match reqwest::Proxy::all(&url) {
8029                Ok(proxy) => {
8030                    builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
8031                }
8032                Err(error) => {
8033                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid all_proxy URL: ");
8034                }
8035            }
8036        }
8037
8038        if let Some(url) = normalize_proxy_url_option(self.http_proxy.as_deref()) {
8039            match reqwest::Proxy::http(&url) {
8040                Ok(proxy) => {
8041                    builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
8042                }
8043                Err(error) => {
8044                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid http_proxy URL: ");
8045                }
8046            }
8047        }
8048
8049        if let Some(url) = normalize_proxy_url_option(self.https_proxy.as_deref()) {
8050            match reqwest::Proxy::https(&url) {
8051                Ok(proxy) => {
8052                    builder = builder.proxy(apply_no_proxy(proxy, no_proxy));
8053                }
8054                Err(error) => {
8055                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid https_proxy URL: ");
8056                }
8057            }
8058        }
8059
8060        builder
8061    }
8062
8063    pub fn apply_to_process_env(&self) {
8064        set_proxy_env_pair("HTTP_PROXY", self.http_proxy.as_deref());
8065        set_proxy_env_pair("HTTPS_PROXY", self.https_proxy.as_deref());
8066        set_proxy_env_pair("ALL_PROXY", self.all_proxy.as_deref());
8067
8068        let no_proxy_joined = {
8069            let list = self.normalized_no_proxy();
8070            (!list.is_empty()).then(|| list.join(","))
8071        };
8072        set_proxy_env_pair("NO_PROXY", no_proxy_joined.as_deref());
8073    }
8074
8075    pub fn clear_process_env() {
8076        clear_proxy_env_pair("HTTP_PROXY");
8077        clear_proxy_env_pair("HTTPS_PROXY");
8078        clear_proxy_env_pair("ALL_PROXY");
8079        clear_proxy_env_pair("NO_PROXY");
8080    }
8081
8082    fn no_proxy_value(&self) -> Option<reqwest::NoProxy> {
8083        let joined = {
8084            let list = self.normalized_no_proxy();
8085            (!list.is_empty()).then(|| list.join(","))
8086        };
8087        joined.as_deref().and_then(reqwest::NoProxy::from_string)
8088    }
8089}
8090
8091fn apply_no_proxy(proxy: reqwest::Proxy, no_proxy: Option<reqwest::NoProxy>) -> reqwest::Proxy {
8092    proxy.no_proxy(no_proxy)
8093}
8094
8095fn normalize_proxy_url_option(raw: Option<&str>) -> Option<String> {
8096    let value = raw?.trim();
8097    (!value.is_empty()).then(|| value.to_string())
8098}
8099
8100fn normalize_no_proxy_list(values: Vec<String>) -> Vec<String> {
8101    normalize_comma_values(values)
8102}
8103
8104fn normalize_service_list(values: Vec<String>) -> Vec<String> {
8105    let mut normalized = normalize_comma_values(values)
8106        .into_iter()
8107        .map(|value| value.to_ascii_lowercase())
8108        .collect::<Vec<_>>();
8109    normalized.sort_unstable();
8110    normalized.dedup();
8111    normalized
8112}
8113
8114fn normalize_comma_values(values: Vec<String>) -> Vec<String> {
8115    let mut output = Vec::new();
8116    for value in values {
8117        for part in value.split(',') {
8118            let normalized = part.trim();
8119            if normalized.is_empty() {
8120                continue;
8121            }
8122            output.push(normalized.to_string());
8123        }
8124    }
8125    output.sort_unstable();
8126    output.dedup();
8127    output
8128}
8129
8130fn is_supported_proxy_service_selector(selector: &str) -> bool {
8131    if SUPPORTED_PROXY_SERVICE_KEYS
8132        .iter()
8133        .any(|known| known.eq_ignore_ascii_case(selector))
8134    {
8135        return true;
8136    }
8137
8138    SUPPORTED_PROXY_SERVICE_SELECTORS
8139        .iter()
8140        .any(|known| known.eq_ignore_ascii_case(selector))
8141}
8142
8143fn service_selector_matches(selector: &str, service_key: &str) -> bool {
8144    if selector == service_key {
8145        return true;
8146    }
8147
8148    if let Some(prefix) = selector.strip_suffix(".*") {
8149        return service_key.starts_with(prefix)
8150            && service_key
8151                .strip_prefix(prefix)
8152                .is_some_and(|suffix| suffix.starts_with('.'));
8153    }
8154
8155    false
8156}
8157
8158const MCP_MAX_TOOL_TIMEOUT_SECS: u64 = 600;
8159
8160fn validate_mcp_config(config: &McpConfig) -> Result<()> {
8161    let mut seen_names = std::collections::HashSet::new();
8162    for (i, server) in config.servers.iter().enumerate() {
8163        let name = server.name.trim();
8164        if name.is_empty() {
8165            validation_bail!(
8166                RequiredFieldEmpty,
8167                format!("mcp.servers[{i}].name"),
8168                "mcp.servers[{i}].name must not be empty"
8169            );
8170        }
8171        if !seen_names.insert(name.to_ascii_lowercase()) {
8172            anyhow::bail!("mcp.servers contains duplicate name: {name}");
8173        }
8174
8175        if let Some(timeout) = server.tool_timeout_secs {
8176            if timeout == 0 {
8177                validation_bail!(
8178                    InvalidNumericRange,
8179                    format!("mcp.servers[{i}].tool_timeout_secs"),
8180                    "mcp.servers[{i}].tool_timeout_secs must be greater than 0"
8181                );
8182            }
8183            if timeout > MCP_MAX_TOOL_TIMEOUT_SECS {
8184                anyhow::bail!(
8185                    "mcp.servers[{i}].tool_timeout_secs exceeds max {MCP_MAX_TOOL_TIMEOUT_SECS}"
8186                );
8187            }
8188        }
8189
8190        match server.transport {
8191            McpTransport::Stdio => {
8192                if server.command.trim().is_empty() {
8193                    anyhow::bail!(
8194                        "mcp.servers[{i}] with transport=stdio requires non-empty command"
8195                    );
8196                }
8197            }
8198            McpTransport::Http | McpTransport::Sse => {
8199                let url = server
8200                    .url
8201                    .as_deref()
8202                    .map(str::trim)
8203                    .filter(|value| !value.is_empty())
8204                    .ok_or_else(|| {
8205                        let transport_str = match server.transport {
8206                            McpTransport::Http => "http",
8207                            McpTransport::Sse => "sse",
8208                            McpTransport::Stdio => "stdio",
8209                        };
8210                        ::zeroclaw_log::record!(
8211                            WARN,
8212                            ::zeroclaw_log::Event::new(
8213                                module_path!(),
8214                                ::zeroclaw_log::Action::Reject
8215                            )
8216                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
8217                            .with_attrs(::serde_json::json!({
8218                                "index": i,
8219                                "transport": transport_str,
8220                            })),
8221                            "mcp.servers entry rejected: transport requires url"
8222                        );
8223                        anyhow::Error::msg(format!(
8224                            "mcp.servers[{i}] with transport={transport_str} requires url"
8225                        ))
8226                    })?;
8227                let parsed = reqwest::Url::parse(url)
8228                    .with_context(|| format!("mcp.servers[{i}].url is not a valid URL"))?;
8229                if !matches!(parsed.scheme(), "http" | "https") {
8230                    anyhow::bail!("mcp.servers[{i}].url must use http/https");
8231                }
8232            }
8233        }
8234    }
8235    Ok(())
8236}
8237
8238fn validate_proxy_url(field: &str, url: &str) -> Result<()> {
8239    let parsed = reqwest::Url::parse(url)
8240        .with_context(|| format!("Invalid {field} URL: '{url}' is not a valid URL"))?;
8241
8242    match parsed.scheme() {
8243        "http" | "https" | "socks5" | "socks5h" | "socks" => {}
8244        scheme => {
8245            anyhow::bail!(
8246                "Invalid {field} URL scheme '{scheme}'. Allowed: http, https, socks5, socks5h, socks"
8247            );
8248        }
8249    }
8250
8251    if parsed.host_str().is_none() {
8252        anyhow::bail!("Invalid {field} URL: host is required");
8253    }
8254
8255    Ok(())
8256}
8257
8258fn set_proxy_env_pair(key: &str, value: Option<&str>) {
8259    let lowercase_key = key.to_ascii_lowercase();
8260    if let Some(value) = value.and_then(|candidate| normalize_proxy_url_option(Some(candidate))) {
8261        // SAFETY: called during single-threaded config init before async runtime starts.
8262        unsafe {
8263            std::env::set_var(key, &value);
8264            std::env::set_var(lowercase_key, value);
8265        }
8266    } else {
8267        // SAFETY: called during single-threaded config init before async runtime starts.
8268        unsafe {
8269            std::env::remove_var(key);
8270            std::env::remove_var(lowercase_key);
8271        }
8272    }
8273}
8274
8275fn clear_proxy_env_pair(key: &str) {
8276    // SAFETY: called during single-threaded config init before async runtime starts.
8277    unsafe {
8278        std::env::remove_var(key);
8279        std::env::remove_var(key.to_ascii_lowercase());
8280    }
8281}
8282
8283fn runtime_proxy_state() -> &'static RwLock<ProxyConfig> {
8284    RUNTIME_PROXY_CONFIG.get_or_init(|| RwLock::new(ProxyConfig::default()))
8285}
8286
8287fn runtime_proxy_client_cache() -> &'static RwLock<HashMap<String, reqwest::Client>> {
8288    RUNTIME_PROXY_CLIENT_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
8289}
8290
8291fn clear_runtime_proxy_client_cache() {
8292    match runtime_proxy_client_cache().write() {
8293        Ok(mut guard) => {
8294            guard.clear();
8295        }
8296        Err(poisoned) => {
8297            poisoned.into_inner().clear();
8298        }
8299    }
8300}
8301
8302fn runtime_proxy_cache_key(
8303    service_key: &str,
8304    timeout_secs: Option<u64>,
8305    connect_timeout_secs: Option<u64>,
8306) -> String {
8307    format!(
8308        "{}|timeout={}|connect_timeout={}",
8309        service_key.trim().to_ascii_lowercase(),
8310        timeout_secs
8311            .map(|value| value.to_string())
8312            .unwrap_or_else(|| "none".to_string()),
8313        connect_timeout_secs
8314            .map(|value| value.to_string())
8315            .unwrap_or_else(|| "none".to_string())
8316    )
8317}
8318
8319fn runtime_proxy_cached_client(cache_key: &str) -> Option<reqwest::Client> {
8320    match runtime_proxy_client_cache().read() {
8321        Ok(guard) => guard.get(cache_key).cloned(),
8322        Err(poisoned) => poisoned.into_inner().get(cache_key).cloned(),
8323    }
8324}
8325
8326fn set_runtime_proxy_cached_client(cache_key: String, client: reqwest::Client) {
8327    match runtime_proxy_client_cache().write() {
8328        Ok(mut guard) => {
8329            guard.insert(cache_key, client);
8330        }
8331        Err(poisoned) => {
8332            poisoned.into_inner().insert(cache_key, client);
8333        }
8334    }
8335}
8336
8337pub fn set_runtime_proxy_config(config: ProxyConfig) {
8338    match runtime_proxy_state().write() {
8339        Ok(mut guard) => {
8340            *guard = config;
8341        }
8342        Err(poisoned) => {
8343            *poisoned.into_inner() = config;
8344        }
8345    }
8346
8347    clear_runtime_proxy_client_cache();
8348}
8349
8350pub fn runtime_proxy_config() -> ProxyConfig {
8351    match runtime_proxy_state().read() {
8352        Ok(guard) => guard.clone(),
8353        Err(poisoned) => poisoned.into_inner().clone(),
8354    }
8355}
8356
8357pub fn apply_runtime_proxy_to_builder(
8358    builder: reqwest::ClientBuilder,
8359    service_key: &str,
8360) -> reqwest::ClientBuilder {
8361    runtime_proxy_config().apply_to_reqwest_builder(builder, service_key)
8362}
8363
8364pub fn build_runtime_proxy_client(service_key: &str) -> reqwest::Client {
8365    let cache_key = runtime_proxy_cache_key(service_key, None, None);
8366    if let Some(client) = runtime_proxy_cached_client(&cache_key) {
8367        return client;
8368    }
8369
8370    let builder = apply_runtime_proxy_to_builder(reqwest::Client::builder(), service_key);
8371    let client = builder.build().unwrap_or_else(|error| {
8372        ::zeroclaw_log::record!(
8373            WARN,
8374            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
8375                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
8376                .with_attrs(
8377                    ::serde_json::json!({"service_key": service_key, "error": format!("{}", error)})
8378                ),
8379            "Failed to build proxied client: "
8380        );
8381        reqwest::Client::new()
8382    });
8383    set_runtime_proxy_cached_client(cache_key, client.clone());
8384    client
8385}
8386
8387pub fn build_runtime_proxy_client_with_timeouts(
8388    service_key: &str,
8389    timeout_secs: u64,
8390    connect_timeout_secs: u64,
8391) -> reqwest::Client {
8392    let cache_key =
8393        runtime_proxy_cache_key(service_key, Some(timeout_secs), Some(connect_timeout_secs));
8394    if let Some(client) = runtime_proxy_cached_client(&cache_key) {
8395        return client;
8396    }
8397
8398    let builder = reqwest::Client::builder()
8399        .timeout(std::time::Duration::from_secs(timeout_secs))
8400        .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs));
8401    let builder = apply_runtime_proxy_to_builder(builder, service_key);
8402    let client = builder.build().unwrap_or_else(|error| {
8403        ::zeroclaw_log::record!(
8404            WARN,
8405            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
8406                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
8407                .with_attrs(
8408                    ::serde_json::json!({"service_key": service_key, "error": format!("{}", error)})
8409                ),
8410            "Failed to build proxied timeout client: "
8411        );
8412        reqwest::Client::new()
8413    });
8414    set_runtime_proxy_cached_client(cache_key, client.clone());
8415    client
8416}
8417
8418/// Build an HTTP client for a channel, using an explicit per-channel proxy URL
8419/// when configured.  Falls back to the global runtime proxy when `proxy_url` is
8420/// `None` or empty.
8421pub fn build_channel_proxy_client(service_key: &str, proxy_url: Option<&str>) -> reqwest::Client {
8422    match normalize_proxy_url_option(proxy_url) {
8423        Some(url) => build_explicit_proxy_client(service_key, &url, None, None),
8424        None => build_runtime_proxy_client(service_key),
8425    }
8426}
8427
8428/// Build an HTTP client for a channel with custom timeouts, using an explicit
8429/// per-channel proxy URL when configured.  Falls back to the global runtime
8430/// proxy when `proxy_url` is `None` or empty.
8431pub fn build_channel_proxy_client_with_timeouts(
8432    service_key: &str,
8433    proxy_url: Option<&str>,
8434    timeout_secs: u64,
8435    connect_timeout_secs: u64,
8436) -> reqwest::Client {
8437    match normalize_proxy_url_option(proxy_url) {
8438        Some(url) => build_explicit_proxy_client(
8439            service_key,
8440            &url,
8441            Some(timeout_secs),
8442            Some(connect_timeout_secs),
8443        ),
8444        None => build_runtime_proxy_client_with_timeouts(
8445            service_key,
8446            timeout_secs,
8447            connect_timeout_secs,
8448        ),
8449    }
8450}
8451
8452/// Apply an explicit proxy URL to a `reqwest::ClientBuilder`, returning the
8453/// modified builder.  Used by channels that specify a per-channel `proxy_url`.
8454pub fn apply_channel_proxy_to_builder(
8455    builder: reqwest::ClientBuilder,
8456    service_key: &str,
8457    proxy_url: Option<&str>,
8458) -> reqwest::ClientBuilder {
8459    match normalize_proxy_url_option(proxy_url) {
8460        Some(url) => apply_explicit_proxy_to_builder(builder, service_key, &url),
8461        None => apply_runtime_proxy_to_builder(builder, service_key),
8462    }
8463}
8464
8465/// Build a client with a single explicit proxy URL (http+https via `Proxy::all`).
8466fn build_explicit_proxy_client(
8467    service_key: &str,
8468    proxy_url: &str,
8469    timeout_secs: Option<u64>,
8470    connect_timeout_secs: Option<u64>,
8471) -> reqwest::Client {
8472    let cache_key = format!(
8473        "explicit|{}|{}|timeout={}|connect_timeout={}",
8474        service_key.trim().to_ascii_lowercase(),
8475        proxy_url,
8476        timeout_secs
8477            .map(|v| v.to_string())
8478            .unwrap_or_else(|| "none".to_string()),
8479        connect_timeout_secs
8480            .map(|v| v.to_string())
8481            .unwrap_or_else(|| "none".to_string()),
8482    );
8483    if let Some(client) = runtime_proxy_cached_client(&cache_key) {
8484        return client;
8485    }
8486
8487    let mut builder = reqwest::Client::builder();
8488    if let Some(t) = timeout_secs {
8489        builder = builder.timeout(std::time::Duration::from_secs(t));
8490    }
8491    if let Some(ct) = connect_timeout_secs {
8492        builder = builder.connect_timeout(std::time::Duration::from_secs(ct));
8493    }
8494    builder = apply_explicit_proxy_to_builder(builder, service_key, proxy_url);
8495    let client = builder.build().unwrap_or_else(|error| {
8496        ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"service_key": service_key, "proxy_url": proxy_url, "error": format!("{}", error)})), "Failed to build channel proxy client: ");
8497        reqwest::Client::new()
8498    });
8499    set_runtime_proxy_cached_client(cache_key, client.clone());
8500    client
8501}
8502
8503/// Apply a single explicit proxy URL to a builder via `Proxy::all`.
8504fn apply_explicit_proxy_to_builder(
8505    mut builder: reqwest::ClientBuilder,
8506    service_key: &str,
8507    proxy_url: &str,
8508) -> reqwest::ClientBuilder {
8509    match reqwest::Proxy::all(proxy_url) {
8510        Ok(proxy) => {
8511            builder = builder.proxy(proxy);
8512        }
8513        Err(error) => {
8514            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"proxy_url": proxy_url, "service_key": service_key, "error": format!("{}", error)})), "Ignoring invalid channel proxy_url: ");
8515        }
8516    }
8517    builder
8518}
8519
8520// ── Proxy-aware WebSocket connect ────────────────────────────────
8521//
8522// `tokio_tungstenite::connect_async` does not honour proxy settings.
8523// The helpers below resolve the effective proxy URL for a given service
8524// key and, when a proxy is active, establish a tunnelled TCP connection
8525// (HTTP CONNECT for http/https proxies, SOCKS5 for socks5/socks5h)
8526// before handing the stream to `tokio_tungstenite` for the WebSocket
8527// handshake.
8528
8529/// Combined async IO trait for boxed WebSocket transport streams.
8530trait AsyncReadWrite: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
8531impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send> AsyncReadWrite for T {}
8532
8533/// A boxed async IO stream used when a WebSocket connection is tunnelled
8534/// through a proxy.  The concrete type varies depending on the proxy
8535/// kind (HTTP CONNECT vs SOCKS5) and the target scheme (ws vs wss).
8536///
8537/// We wrap in a newtype so we can implement `AsyncRead` and `AsyncWrite`
8538/// via delegation, since Rust trait objects cannot combine multiple
8539/// non-auto traits.
8540pub struct BoxedIo(Box<dyn AsyncReadWrite>);
8541
8542impl tokio::io::AsyncRead for BoxedIo {
8543    fn poll_read(
8544        mut self: std::pin::Pin<&mut Self>,
8545        cx: &mut std::task::Context<'_>,
8546        buf: &mut tokio::io::ReadBuf<'_>,
8547    ) -> std::task::Poll<std::io::Result<()>> {
8548        std::pin::Pin::new(&mut *self.0).poll_read(cx, buf)
8549    }
8550}
8551
8552impl tokio::io::AsyncWrite for BoxedIo {
8553    fn poll_write(
8554        mut self: std::pin::Pin<&mut Self>,
8555        cx: &mut std::task::Context<'_>,
8556        buf: &[u8],
8557    ) -> std::task::Poll<std::io::Result<usize>> {
8558        std::pin::Pin::new(&mut *self.0).poll_write(cx, buf)
8559    }
8560
8561    fn poll_flush(
8562        mut self: std::pin::Pin<&mut Self>,
8563        cx: &mut std::task::Context<'_>,
8564    ) -> std::task::Poll<std::io::Result<()>> {
8565        std::pin::Pin::new(&mut *self.0).poll_flush(cx)
8566    }
8567
8568    fn poll_shutdown(
8569        mut self: std::pin::Pin<&mut Self>,
8570        cx: &mut std::task::Context<'_>,
8571    ) -> std::task::Poll<std::io::Result<()>> {
8572        std::pin::Pin::new(&mut *self.0).poll_shutdown(cx)
8573    }
8574}
8575
8576impl Unpin for BoxedIo {}
8577
8578/// Convenience alias for the WebSocket stream returned by the proxy-aware
8579/// connect helpers.
8580pub type ProxiedWsStream = tokio_tungstenite::WebSocketStream<BoxedIo>;
8581
8582/// Resolve the effective proxy URL for a WebSocket connection to the
8583/// given `ws_url`, taking into account the per-channel `proxy_url`
8584/// override, the runtime proxy config, scope and no_proxy list.
8585fn resolve_ws_proxy_url(
8586    service_key: &str,
8587    ws_url: &str,
8588    channel_proxy_url: Option<&str>,
8589) -> Option<String> {
8590    // 1. Explicit per-channel proxy always wins.
8591    if let Some(url) = normalize_proxy_url_option(channel_proxy_url) {
8592        return Some(url);
8593    }
8594
8595    // 2. Consult the runtime proxy config.
8596    let cfg = runtime_proxy_config();
8597    if !cfg.should_apply_to_service(service_key) {
8598        return None;
8599    }
8600
8601    // Check the no_proxy list against the WebSocket target host.
8602    if let Ok(parsed) = reqwest::Url::parse(ws_url)
8603        && let Some(host) = parsed.host_str()
8604    {
8605        let no_proxy_entries = cfg.normalized_no_proxy();
8606        if !no_proxy_entries.is_empty() {
8607            let host_lower = host.to_ascii_lowercase();
8608            let matches_no_proxy = no_proxy_entries.iter().any(|entry| {
8609                let entry = entry.trim().to_ascii_lowercase();
8610                if entry == "*" {
8611                    return true;
8612                }
8613                if host_lower == entry {
8614                    return true;
8615                }
8616                // Support ".example.com" matching "foo.example.com"
8617                if let Some(suffix) = entry.strip_prefix('.') {
8618                    return host_lower.ends_with(suffix) || host_lower == suffix;
8619                }
8620                // Support "example.com" also matching "foo.example.com"
8621                host_lower.ends_with(&format!(".{entry}"))
8622            });
8623            if matches_no_proxy {
8624                return None;
8625            }
8626        }
8627    }
8628
8629    // For wss:// prefer https_proxy, for ws:// prefer http_proxy, fall
8630    // back to all_proxy in both cases.
8631    let is_secure = ws_url.starts_with("wss://") || ws_url.starts_with("wss:");
8632    let preferred = if is_secure {
8633        normalize_proxy_url_option(cfg.https_proxy.as_deref())
8634    } else {
8635        normalize_proxy_url_option(cfg.http_proxy.as_deref())
8636    };
8637    preferred.or_else(|| normalize_proxy_url_option(cfg.all_proxy.as_deref()))
8638}
8639
8640/// Connect a WebSocket through the configured proxy (if any).
8641///
8642/// When no proxy applies, this is a thin wrapper around
8643/// `tokio_tungstenite::connect_async`.  When a proxy is active the
8644/// function tunnels the TCP connection through the proxy before
8645/// performing the WebSocket upgrade.
8646///
8647/// `service_key` is the proxy-service selector (e.g. `"channel.discord"`).
8648/// `channel_proxy_url` is the optional per-channel proxy override.
8649pub async fn ws_connect_with_proxy(
8650    ws_url: &str,
8651    service_key: &str,
8652    channel_proxy_url: Option<&str>,
8653) -> anyhow::Result<(
8654    ProxiedWsStream,
8655    tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
8656)> {
8657    let proxy_url = resolve_ws_proxy_url(service_key, ws_url, channel_proxy_url);
8658
8659    match proxy_url {
8660        None => {
8661            // No proxy — establish TCP+TLS manually, wrap in BoxedIo, then
8662            // perform the WebSocket handshake over the wrapped stream.
8663            //
8664            // Previous implementation used `connect_async` followed by
8665            // `into_inner()` + `from_raw_socket` to normalize the return
8666            // type.  That pattern discards data already buffered by the
8667            // tungstenite frame codec, causing channels (Slack Socket Mode,
8668            // Discord, etc.) to silently miss the first frames sent by the
8669            // server and all subsequent events.
8670            use tokio::net::TcpStream;
8671
8672            let target = reqwest::Url::parse(ws_url)
8673                .with_context(|| format!("Invalid WebSocket URL: {ws_url}"))?;
8674            let target_host = target
8675                .host_str()
8676                .ok_or_else(|| {
8677                    ::zeroclaw_log::record!(
8678                        WARN,
8679                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
8680                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
8681                            .with_attrs(::serde_json::json!({"ws_url": ws_url})),
8682                        "WebSocket URL has no host"
8683                    );
8684                    anyhow::Error::msg(format!("WebSocket URL has no host: {ws_url}"))
8685                })?
8686                .to_string();
8687            let target_port = target
8688                .port_or_known_default()
8689                .unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });
8690
8691            let tcp = TcpStream::connect(format!("{target_host}:{target_port}"))
8692                .await
8693                .with_context(|| format!("TCP connect to {target_host}:{target_port}"))?;
8694
8695            let is_secure = target.scheme() == "wss";
8696            let stream: BoxedIo = if is_secure {
8697                let mut root_store = rustls::RootCertStore::empty();
8698                root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
8699                let tls_config = std::sync::Arc::new(
8700                    rustls::ClientConfig::builder()
8701                        .with_root_certificates(root_store)
8702                        .with_no_client_auth(),
8703                );
8704                let connector = tokio_rustls::TlsConnector::from(tls_config);
8705                let server_name = rustls_pki_types::ServerName::try_from(target_host.clone())
8706                    .with_context(|| format!("Invalid TLS server name: {target_host}"))?;
8707                let tls_stream = connector
8708                    .connect(server_name, tcp)
8709                    .await
8710                    .with_context(|| format!("TLS handshake with {target_host}"))?;
8711                BoxedIo(Box::new(tls_stream))
8712            } else {
8713                BoxedIo(Box::new(tcp))
8714            };
8715
8716            let default_port = if is_secure { 443 } else { 80 };
8717            let host_header = if target_port == default_port {
8718                target_host.clone()
8719            } else {
8720                format!("{target_host}:{target_port}")
8721            };
8722
8723            let ws_request = tokio_tungstenite::tungstenite::http::Request::builder()
8724                .uri(ws_url)
8725                .header("Host", host_header)
8726                .header("Connection", "Upgrade")
8727                .header("Upgrade", "websocket")
8728                .header(
8729                    "Sec-WebSocket-Key",
8730                    tokio_tungstenite::tungstenite::handshake::client::generate_key(),
8731                )
8732                .header("Sec-WebSocket-Version", "13")
8733                .body(())
8734                .with_context(|| "Failed to build WebSocket upgrade request")?;
8735
8736            let (ws_stream, response) =
8737                tokio_tungstenite::client_async(ws_request, stream)
8738                    .await
8739                    .with_context(|| format!("WebSocket handshake failed for {ws_url}"))?;
8740
8741            Ok((ws_stream, response))
8742        }
8743        Some(proxy) => ws_connect_via_proxy(ws_url, &proxy).await,
8744    }
8745}
8746
8747/// Establish a WebSocket connection tunnelled through the given proxy URL.
8748async fn ws_connect_via_proxy(
8749    ws_url: &str,
8750    proxy_url: &str,
8751) -> anyhow::Result<(
8752    ProxiedWsStream,
8753    tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
8754)> {
8755    use tokio::io::{AsyncReadExt, AsyncWriteExt as _};
8756    use tokio::net::TcpStream;
8757
8758    let target =
8759        reqwest::Url::parse(ws_url).with_context(|| format!("Invalid WebSocket URL: {ws_url}"))?;
8760    let target_host = target
8761        .host_str()
8762        .ok_or_else(|| {
8763            ::zeroclaw_log::record!(
8764                WARN,
8765                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
8766                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
8767                    .with_attrs(::serde_json::json!({"ws_url": ws_url})),
8768                "WebSocket URL has no host"
8769            );
8770            anyhow::Error::msg(format!("WebSocket URL has no host: {ws_url}"))
8771        })?
8772        .to_string();
8773    let target_port = target
8774        .port_or_known_default()
8775        .unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });
8776
8777    let proxy = reqwest::Url::parse(proxy_url)
8778        .with_context(|| format!("Invalid proxy URL: {proxy_url}"))?;
8779
8780    let stream: BoxedIo = match proxy.scheme() {
8781        "socks5" | "socks5h" | "socks" => {
8782            let proxy_addr = format!(
8783                "{}:{}",
8784                proxy.host_str().unwrap_or("127.0.0.1"),
8785                proxy.port_or_known_default().unwrap_or(1080)
8786            );
8787            let target_addr = format!("{target_host}:{target_port}");
8788            let socks_stream = if proxy.username().is_empty() {
8789                tokio_socks::tcp::Socks5Stream::connect(proxy_addr.as_str(), target_addr.as_str())
8790                    .await
8791                    .with_context(|| format!("SOCKS5 connect to {target_addr} via {proxy_addr}"))?
8792            } else {
8793                let password = proxy.password().unwrap_or("");
8794                tokio_socks::tcp::Socks5Stream::connect_with_password(
8795                    proxy_addr.as_str(),
8796                    target_addr.as_str(),
8797                    proxy.username(),
8798                    password,
8799                )
8800                .await
8801                .with_context(|| format!("SOCKS5 auth connect to {target_addr} via {proxy_addr}"))?
8802            };
8803            let tcp: TcpStream = socks_stream.into_inner();
8804            BoxedIo(Box::new(tcp))
8805        }
8806        "http" | "https" => {
8807            let proxy_host = proxy.host_str().unwrap_or("127.0.0.1");
8808            let proxy_port = proxy.port_or_known_default().unwrap_or(8080);
8809            let proxy_addr = format!("{proxy_host}:{proxy_port}");
8810
8811            let mut tcp = TcpStream::connect(&proxy_addr)
8812                .await
8813                .with_context(|| format!("TCP connect to HTTP proxy {proxy_addr}"))?;
8814
8815            // Send HTTP CONNECT request.
8816            let connect_req = format!(
8817                "CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n\r\n"
8818            );
8819            tcp.write_all(connect_req.as_bytes()).await?;
8820
8821            // Read the response (we only need the status line).
8822            let mut buf = vec![0u8; 4096];
8823            let mut total = 0usize;
8824            loop {
8825                let n = tcp.read(&mut buf[total..]).await?;
8826                if n == 0 {
8827                    anyhow::bail!("HTTP CONNECT proxy closed connection before response");
8828                }
8829                total += n;
8830                // Look for end of HTTP headers.
8831                if let Some(pos) = find_header_end(&buf[..total]) {
8832                    let status_line = std::str::from_utf8(&buf[..pos])
8833                        .unwrap_or("")
8834                        .lines()
8835                        .next()
8836                        .unwrap_or("");
8837                    if !status_line.contains("200") {
8838                        anyhow::bail!(
8839                            "HTTP CONNECT proxy returned non-200 response: {status_line}"
8840                        );
8841                    }
8842                    break;
8843                }
8844                if total >= buf.len() {
8845                    anyhow::bail!("HTTP CONNECT proxy response too large");
8846                }
8847            }
8848
8849            BoxedIo(Box::new(tcp))
8850        }
8851        scheme => {
8852            anyhow::bail!("Unsupported proxy scheme '{scheme}' for WebSocket connections");
8853        }
8854    };
8855
8856    // If the target is wss://, wrap in TLS.
8857    let is_secure = target.scheme() == "wss";
8858    let stream: BoxedIo = if is_secure {
8859        let mut root_store = rustls::RootCertStore::empty();
8860        root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
8861        let tls_config = std::sync::Arc::new(
8862            rustls::ClientConfig::builder()
8863                .with_root_certificates(root_store)
8864                .with_no_client_auth(),
8865        );
8866        let connector = tokio_rustls::TlsConnector::from(tls_config);
8867        let server_name = rustls_pki_types::ServerName::try_from(target_host.clone())
8868            .with_context(|| format!("Invalid TLS server name: {target_host}"))?;
8869
8870        // `stream` is `BoxedIo` — we need a concrete `AsyncRead + AsyncWrite`
8871        // for `TlsConnector::connect`.  Since `BoxedIo` already satisfies
8872        // those bounds we can pass it directly.
8873        let tls_stream = connector
8874            .connect(server_name, stream)
8875            .await
8876            .with_context(|| format!("TLS handshake with {target_host}"))?;
8877        BoxedIo(Box::new(tls_stream))
8878    } else {
8879        stream
8880    };
8881
8882    // Perform the WebSocket client handshake over the tunnelled stream.
8883    let ws_request = tokio_tungstenite::tungstenite::http::Request::builder()
8884        .uri(ws_url)
8885        .header("Host", format!("{target_host}:{target_port}"))
8886        .header("Connection", "Upgrade")
8887        .header("Upgrade", "websocket")
8888        .header(
8889            "Sec-WebSocket-Key",
8890            tokio_tungstenite::tungstenite::handshake::client::generate_key(),
8891        )
8892        .header("Sec-WebSocket-Version", "13")
8893        .body(())
8894        .with_context(|| "Failed to build WebSocket upgrade request")?;
8895
8896    let (ws_stream, response) = tokio_tungstenite::client_async(ws_request, stream)
8897        .await
8898        .with_context(|| format!("WebSocket handshake failed for {ws_url}"))?;
8899
8900    Ok((ws_stream, response))
8901}
8902
8903/// Find the `\r\n\r\n` boundary marking the end of HTTP headers.
8904fn find_header_end(buf: &[u8]) -> Option<usize> {
8905    buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
8906}
8907
8908// ── Memory ───────────────────────────────────────────────────
8909
8910/// Persistent storage configuration (`[storage]` section).
8911///
8912/// Storage is a two-tier alias-keyed map: `[storage.<backend>.<alias>]`,
8913/// parallel to `[providers.models.<type>.<alias>]`. Each backend has its own typed
8914/// config struct. `MemoryConfig.backend` carries a dotted reference (`"sqlite.default"`,
8915/// `"postgres.work"`) that resolves to one of these entries via
8916/// [`Config::resolve_active_storage`].
8917#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
8918#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8919#[prefix = "storage"]
8920pub struct StorageConfig {
8921    /// SQLite storage instances (`[storage.sqlite.<alias>]`).
8922    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8923    #[nested]
8924    pub sqlite: HashMap<String, SqliteStorageConfig>,
8925    /// PostgreSQL storage instances (`[storage.postgres.<alias>]`).
8926    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8927    #[nested]
8928    pub postgres: HashMap<String, PostgresStorageConfig>,
8929    /// Qdrant storage instances (`[storage.qdrant.<alias>]`).
8930    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8931    #[nested]
8932    pub qdrant: HashMap<String, QdrantStorageConfig>,
8933    /// Markdown storage instances (`[storage.markdown.<alias>]`).
8934    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8935    #[nested]
8936    pub markdown: HashMap<String, MarkdownStorageConfig>,
8937    /// Lucid CLI sync instances (`[storage.lucid.<alias>]`).
8938    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
8939    #[nested]
8940    pub lucid: HashMap<String, LucidStorageConfig>,
8941}
8942
8943/// SQLite storage backend (`[storage.sqlite.<alias>]`).
8944#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
8945#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8946#[prefix = "storage_sqlite"]
8947#[serde(default)]
8948pub struct SqliteStorageConfig {
8949    /// Optional override for the SQLite database path.
8950    /// When unset, defaults to `<workspace_dir>/brain.db`.
8951    pub path: Option<String>,
8952    /// Maximum seconds to wait when opening the DB if it's locked.
8953    /// `None` waits indefinitely. Recommended max: 300.
8954    pub open_timeout_secs: Option<u64>,
8955}
8956
8957/// PostgreSQL storage backend (`[storage.postgres.<alias>]`).
8958///
8959/// Holds connection parameters AND pgvector settings on one alias-keyed
8960/// entry; previously these lived in two separate sections.
8961#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
8962#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
8963#[prefix = "storage_postgres"]
8964#[serde(default)]
8965pub struct PostgresStorageConfig {
8966    /// Connection URL (e.g. `"postgres://user:pass@host/db"`).
8967    /// Accepts legacy aliases: dbURL, database_url, databaseUrl.
8968    #[serde(alias = "dbURL", alias = "database_url", alias = "databaseUrl")]
8969    #[secret]
8970    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
8971    pub db_url: Option<String>,
8972    /// Database schema for the memory table.
8973    pub schema: String,
8974    /// Table name for memory entries.
8975    pub table: String,
8976    /// Optional connection timeout in seconds.
8977    pub connect_timeout_secs: Option<u64>,
8978    /// Enable pgvector extension for hybrid vector+keyword recall.
8979    pub vector_enabled: bool,
8980    /// Vector dimensions for pgvector embeddings.
8981    pub vector_dimensions: usize,
8982}
8983
8984impl Default for PostgresStorageConfig {
8985    fn default() -> Self {
8986        Self {
8987            db_url: None,
8988            schema: default_storage_schema(),
8989            table: default_storage_table(),
8990            connect_timeout_secs: None,
8991            vector_enabled: false,
8992            vector_dimensions: default_pgvector_dimensions(),
8993        }
8994    }
8995}
8996
8997/// Qdrant vector database backend (`[storage.qdrant.<alias>]`).
8998///
8999/// URL, collection, and API key all fall back to environment variables
9000/// (`QDRANT_URL`, `QDRANT_COLLECTION`, `QDRANT_API_KEY`) when unset.
9001#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9002#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9003#[prefix = "storage_qdrant"]
9004#[serde(default)]
9005pub struct QdrantStorageConfig {
9006    /// Qdrant server URL (e.g. `"http://localhost:6333"`).
9007    /// Falls back to `QDRANT_URL` env var if unset.
9008    pub url: Option<String>,
9009    /// Collection name for storing memories.
9010    /// Falls back to `QDRANT_COLLECTION` env var, or `"zeroclaw_memories"`.
9011    pub collection: String,
9012    /// API key for Qdrant Cloud or secured instances.
9013    /// Falls back to `QDRANT_API_KEY` env var if unset.
9014    #[secret]
9015    #[credential_class = "encrypted_secret"]
9016    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
9017    pub api_key: Option<String>,
9018}
9019
9020impl Default for QdrantStorageConfig {
9021    fn default() -> Self {
9022        Self {
9023            url: None,
9024            collection: default_qdrant_collection(),
9025            api_key: None,
9026        }
9027    }
9028}
9029
9030/// Markdown directory storage (`[storage.markdown.<alias>]`).
9031#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9032#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9033#[prefix = "storage_markdown"]
9034#[serde(default)]
9035pub struct MarkdownStorageConfig {
9036    /// Optional override for the markdown root directory.
9037    /// When unset, defaults to `<workspace_dir>/memory/`.
9038    pub directory: Option<String>,
9039}
9040
9041/// Lucid CLI sync backend (`[storage.lucid.<alias>]`).
9042#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9043#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9044#[prefix = "storage_lucid"]
9045#[serde(default)]
9046pub struct LucidStorageConfig {
9047    /// Optional path to the lucid-memory binary.
9048    pub binary_path: Option<String>,
9049}
9050
9051fn default_storage_schema() -> String {
9052    "public".into()
9053}
9054
9055fn default_storage_table() -> String {
9056    "memories".into()
9057}
9058
9059fn default_qdrant_collection() -> String {
9060    "zeroclaw_memories".into()
9061}
9062
9063/// Search strategy for memory recall.
9064#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
9065#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9066#[serde(rename_all = "snake_case")]
9067pub enum SearchMode {
9068    /// Pure keyword search (FTS5 BM25)
9069    Bm25,
9070    /// Pure vector/semantic search
9071    Embedding,
9072    /// Weighted combination of keyword + vector (default)
9073    #[default]
9074    Hybrid,
9075}
9076
9077/// Memory backend configuration (`[memory]` section).
9078///
9079/// Controls conversation memory storage, embeddings, hybrid search, response
9080/// caching, and memory snapshot/hydration. Backend-specific connection settings
9081/// live under `[storage.<backend>.<alias>]`; this section selects which storage
9082/// instance to use via the `backend` dotted reference.
9083#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9084#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9085#[prefix = "memory"]
9086#[allow(clippy::struct_excessive_bools)]
9087pub struct MemoryConfig {
9088    /// Dotted reference to the active storage instance: `<backend>.<alias>`
9089    /// (e.g. `"sqlite.default"`, `"postgres.work"`). Resolves through
9090    /// `Config.storage.<backend>.<alias>` at runtime. Bare backend names
9091    /// (`"sqlite"`) are treated as `"<backend>.default"`. Set to `"none"` to
9092    /// disable persistence entirely.
9093    pub backend: String,
9094    /// 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.
9095    #[serde(default = "default_auto_save")]
9096    pub auto_save: bool,
9097    /// Run the periodic hygiene pass that archives stale daily/session files and enforces retention windows. Leave on unless you want to manage cleanup yourself.
9098    #[serde(default = "default_hygiene_enabled")]
9099    pub hygiene_enabled: bool,
9100    /// Move daily/session files to the archive directory after this many days. Keeps the hot working set small without deleting history.
9101    #[serde(default = "default_archive_after_days")]
9102    pub archive_after_days: u32,
9103    /// Delete archived files permanently after this many days. Set high if you need long-term history; set low for privacy / disk-space reasons.
9104    #[serde(default = "default_purge_after_days")]
9105    pub purge_after_days: u32,
9106    /// 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.
9107    #[serde(default = "default_conversation_retention_days")]
9108    pub conversation_retention_days: u32,
9109    /// 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.).
9110    #[serde(default = "default_embedding_provider")]
9111    pub embedding_provider: String,
9112    /// 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.
9113    #[serde(default = "default_embedding_model")]
9114    pub embedding_model: String,
9115    /// 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.
9116    #[serde(default = "default_embedding_dims")]
9117    pub embedding_dimensions: usize,
9118    /// 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.
9119    #[serde(default = "default_vector_weight")]
9120    pub vector_weight: f64,
9121    /// 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.
9122    #[serde(default = "default_keyword_weight")]
9123    pub keyword_weight: f64,
9124    /// 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).
9125    #[serde(default)]
9126    pub search_mode: SearchMode,
9127    /// Minimum hybrid score (0.0–1.0) for a memory to be included in context.
9128    /// Memories scoring below this threshold are dropped to prevent irrelevant
9129    /// context from bleeding into conversations. Default: 0.4
9130    #[serde(default = "default_min_relevance_score")]
9131    pub min_relevance_score: f64,
9132    /// Max embedding cache entries before LRU eviction
9133    #[serde(default = "default_cache_size")]
9134    pub embedding_cache_size: usize,
9135    /// Max tokens per chunk for document splitting
9136    #[serde(default = "default_chunk_size")]
9137    pub chunk_max_tokens: usize,
9138
9139    // ── Response Cache (saves tokens on repeated prompts) ──────
9140    /// Enable LLM response caching to avoid paying for duplicate prompts
9141    #[serde(default)]
9142    pub response_cache_enabled: bool,
9143    /// TTL in minutes for cached responses (default: 60)
9144    #[serde(default = "default_response_cache_ttl")]
9145    pub response_cache_ttl_minutes: u32,
9146    /// Max number of cached responses before LRU eviction (default: 5000)
9147    #[serde(default = "default_response_cache_max")]
9148    pub response_cache_max_entries: usize,
9149    /// Max in-memory hot cache entries for the two-tier response cache (default: 256)
9150    #[serde(default = "default_response_cache_hot_entries")]
9151    pub response_cache_hot_entries: usize,
9152
9153    // ── Memory Snapshot (soul backup to Markdown) ─────────────
9154    /// Enable periodic export of core memories to MEMORY_SNAPSHOT.md
9155    #[serde(default)]
9156    pub snapshot_enabled: bool,
9157    /// Run snapshot during hygiene passes (heartbeat-driven)
9158    #[serde(default)]
9159    pub snapshot_on_hygiene: bool,
9160    /// Auto-hydrate from MEMORY_SNAPSHOT.md when brain.db is missing
9161    #[serde(default = "default_true")]
9162    pub auto_hydrate: bool,
9163
9164    // ── Retrieval Pipeline ─────────────────────────────────────
9165    /// Retrieval stages to execute in order. Valid: "cache", "fts", "vector".
9166    #[serde(default = "default_retrieval_stages")]
9167    pub retrieval_stages: Vec<String>,
9168    /// Enable LLM reranking when candidate count exceeds threshold.
9169    #[serde(default)]
9170    pub rerank_enabled: bool,
9171    /// Minimum candidate count to trigger reranking.
9172    #[serde(default = "default_rerank_threshold")]
9173    pub rerank_threshold: usize,
9174    /// FTS score above which to early-return without vector search (0.0–1.0).
9175    #[serde(default = "default_fts_early_return_score")]
9176    pub fts_early_return_score: f64,
9177
9178    // ── Namespace Isolation ─────────────────────────────────────
9179    /// Default namespace for memory entries.
9180    #[serde(default = "default_namespace")]
9181    pub default_namespace: String,
9182
9183    // ── Conflict Resolution ─────────────────────────────────────
9184    /// Cosine similarity threshold for conflict detection (0.0–1.0).
9185    #[serde(default = "default_conflict_threshold")]
9186    pub conflict_threshold: f64,
9187
9188    // ── Audit Trail ─────────────────────────────────────────────
9189    /// Enable audit logging of memory operations.
9190    #[serde(default)]
9191    pub audit_enabled: bool,
9192    /// Retention period for audit entries in days (default: 30).
9193    #[serde(default = "default_audit_retention_days")]
9194    pub audit_retention_days: u32,
9195
9196    // ── Policy Engine ───────────────────────────────────────────
9197    /// Memory policy configuration.
9198    #[serde(default)]
9199    #[nested]
9200    pub policy: MemoryPolicyConfig,
9201    // Backend-specific config fields (sqlite_open_timeout_secs, qdrant.*,
9202    // postgres.*) live on `[storage.<backend>.<alias>]`. The `backend` field
9203    // carries a dotted alias reference and the runtime looks up the typed
9204    // config via `Config::resolve_active_storage`.
9205}
9206
9207/// Memory policy configuration (`[memory.policy]` section).
9208#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9209#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9210#[prefix = "memory.policy"]
9211pub struct MemoryPolicyConfig {
9212    /// Maximum entries per namespace (0 = unlimited).
9213    #[serde(default)]
9214    pub max_entries_per_namespace: usize,
9215    /// Maximum entries per category (0 = unlimited).
9216    #[serde(default)]
9217    pub max_entries_per_category: usize,
9218    /// Retention days by category (overrides global). Keys: "core", "daily", "conversation".
9219    #[serde(default)]
9220    pub retention_days_by_category: std::collections::HashMap<String, u32>,
9221    /// Namespaces that are read-only (writes are rejected).
9222    #[serde(default)]
9223    pub read_only_namespaces: Vec<String>,
9224}
9225
9226fn default_retrieval_stages() -> Vec<String> {
9227    vec!["cache".into(), "fts".into(), "vector".into()]
9228}
9229fn default_rerank_threshold() -> usize {
9230    5
9231}
9232fn default_fts_early_return_score() -> f64 {
9233    0.85
9234}
9235fn default_namespace() -> String {
9236    "default".into()
9237}
9238fn default_conflict_threshold() -> f64 {
9239    0.85
9240}
9241fn default_audit_retention_days() -> u32 {
9242    30
9243}
9244
9245fn default_pgvector_dimensions() -> usize {
9246    1536
9247}
9248
9249fn default_embedding_provider() -> String {
9250    "none".into()
9251}
9252fn default_auto_save() -> bool {
9253    true
9254}
9255fn default_hygiene_enabled() -> bool {
9256    true
9257}
9258fn default_archive_after_days() -> u32 {
9259    7
9260}
9261fn default_purge_after_days() -> u32 {
9262    30
9263}
9264fn default_conversation_retention_days() -> u32 {
9265    30
9266}
9267fn default_embedding_model() -> String {
9268    "text-embedding-3-small".into()
9269}
9270fn default_embedding_dims() -> usize {
9271    1536
9272}
9273fn default_vector_weight() -> f64 {
9274    0.7
9275}
9276fn default_keyword_weight() -> f64 {
9277    0.3
9278}
9279fn default_min_relevance_score() -> f64 {
9280    0.4
9281}
9282fn default_cache_size() -> usize {
9283    10_000
9284}
9285fn default_chunk_size() -> usize {
9286    512
9287}
9288fn default_response_cache_ttl() -> u32 {
9289    60
9290}
9291fn default_response_cache_max() -> usize {
9292    5_000
9293}
9294
9295fn default_response_cache_hot_entries() -> usize {
9296    256
9297}
9298
9299impl Default for MemoryConfig {
9300    fn default() -> Self {
9301        Self {
9302            backend: "sqlite".into(),
9303            auto_save: true,
9304            hygiene_enabled: default_hygiene_enabled(),
9305            archive_after_days: default_archive_after_days(),
9306            purge_after_days: default_purge_after_days(),
9307            conversation_retention_days: default_conversation_retention_days(),
9308            embedding_provider: default_embedding_provider(),
9309            embedding_model: default_embedding_model(),
9310            embedding_dimensions: default_embedding_dims(),
9311            vector_weight: default_vector_weight(),
9312            keyword_weight: default_keyword_weight(),
9313            search_mode: SearchMode::default(),
9314            min_relevance_score: default_min_relevance_score(),
9315            embedding_cache_size: default_cache_size(),
9316            chunk_max_tokens: default_chunk_size(),
9317            response_cache_enabled: false,
9318            response_cache_ttl_minutes: default_response_cache_ttl(),
9319            response_cache_max_entries: default_response_cache_max(),
9320            response_cache_hot_entries: default_response_cache_hot_entries(),
9321            snapshot_enabled: false,
9322            snapshot_on_hygiene: false,
9323            auto_hydrate: true,
9324            retrieval_stages: default_retrieval_stages(),
9325            rerank_enabled: false,
9326            rerank_threshold: default_rerank_threshold(),
9327            fts_early_return_score: default_fts_early_return_score(),
9328            default_namespace: default_namespace(),
9329            conflict_threshold: default_conflict_threshold(),
9330            audit_enabled: false,
9331            audit_retention_days: default_audit_retention_days(),
9332            policy: MemoryPolicyConfig::default(),
9333        }
9334    }
9335}
9336
9337// ── Observability ─────────────────────────────────────────────────
9338
9339/// Observability backend configuration (`[observability]` section).
9340#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9341#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9342#[prefix = "observability"]
9343pub struct ObservabilityConfig {
9344    /// "none" | "log" | "verbose" | "prometheus" | "otel"
9345    pub backend: String,
9346
9347    /// OTLP endpoint (e.g. `"http://localhost:4318"`). Only used when backend = `"otel"`.
9348    #[serde(default)]
9349    pub otel_endpoint: Option<String>,
9350
9351    /// Service name reported to the OTel collector. Defaults to "zeroclaw".
9352    #[serde(default)]
9353    pub otel_service_name: Option<String>,
9354
9355    /// Optional HTTP headers sent with every OTLP export request (e.g. authorization).
9356    /// Specified as key-value pairs in TOML:
9357    /// ```toml
9358    /// [observability.otel_headers]
9359    /// Authorization = "Bearer sk-..."
9360    /// ```
9361    #[serde(default)]
9362    #[secret]
9363    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
9364    pub otel_headers: Option<std::collections::HashMap<String, String>>,
9365
9366    /// Log persistence mode: "none" | "rolling" | "full".
9367    /// Controls whether every event passing through `zeroclaw_log::record!`
9368    /// is appended to the on-disk JSONL log.
9369    #[serde(default = "default_log_persistence", alias = "runtime_trace_mode")]
9370    pub log_persistence: String,
9371
9372    /// Log persistence file path. Relative paths resolve under workspace_dir.
9373    #[serde(default = "default_log_persistence_path", alias = "runtime_trace_path")]
9374    pub log_persistence_path: String,
9375
9376    /// Maximum entries retained when `log_persistence = "rolling"`.
9377    #[serde(
9378        default = "default_log_persistence_max_entries",
9379        alias = "runtime_trace_max_entries"
9380    )]
9381    pub log_persistence_max_entries: usize,
9382
9383    /// Tool I/O capture policy: "off" | "redacted" | "full".
9384    /// - `off`: only tool name + outcome + duration land in the log.
9385    /// - `redacted` (default): tool input + output are leak-scanned and
9386    ///   truncated at `log_tool_io_truncate_bytes` before persisting.
9387    /// - `full`: full input + output, still leak-scanned. For operators
9388    ///   who need replay fidelity and accept the disk cost.
9389    #[serde(default = "default_log_tool_io")]
9390    pub log_tool_io: String,
9391
9392    /// Truncate the captured tool input and output at this many bytes when
9393    /// `log_tool_io = "redacted"`. Truncated events carry an explicit
9394    /// `tool_output_truncated: true` flag plus `tool_output_original_bytes`.
9395    #[serde(default = "default_log_tool_io_truncate_bytes")]
9396    pub log_tool_io_truncate_bytes: usize,
9397
9398    /// Tool names whose I/O is never logged beyond name + outcome + duration
9399    /// regardless of `log_tool_io`. Use for tools whose I/O is intrinsically
9400    /// sensitive (e.g. memory recall against personal namespaces, agent
9401    /// secret reads). Empty by default.
9402    #[serde(default)]
9403    pub log_tool_io_denylist: Vec<String>,
9404}
9405
9406impl Default for ObservabilityConfig {
9407    fn default() -> Self {
9408        Self {
9409            backend: "none".into(),
9410            otel_endpoint: None,
9411            otel_service_name: None,
9412            otel_headers: None,
9413            log_persistence: default_log_persistence(),
9414            log_persistence_path: default_log_persistence_path(),
9415            log_persistence_max_entries: default_log_persistence_max_entries(),
9416            log_tool_io: default_log_tool_io(),
9417            log_tool_io_truncate_bytes: default_log_tool_io_truncate_bytes(),
9418            log_tool_io_denylist: Vec::new(),
9419        }
9420    }
9421}
9422
9423fn default_log_persistence() -> String {
9424    "rolling".to_string()
9425}
9426
9427fn default_log_persistence_path() -> String {
9428    "state/runtime-trace.jsonl".to_string()
9429}
9430
9431fn default_log_persistence_max_entries() -> usize {
9432    200
9433}
9434
9435fn default_log_tool_io() -> String {
9436    "redacted".to_string()
9437}
9438
9439fn default_log_tool_io_truncate_bytes() -> usize {
9440    40960
9441}
9442
9443// ── Hooks ────────────────────────────────────────────────────────
9444
9445#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9446#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9447#[prefix = "hooks"]
9448pub struct HooksConfig {
9449    /// Enable lifecycle hook execution.
9450    ///
9451    /// Hooks run in-process with the same privileges as the main runtime.
9452    /// Keep enabled hook handlers narrowly scoped and auditable.
9453    pub enabled: bool,
9454    #[serde(default)]
9455    #[nested]
9456    pub builtin: BuiltinHooksConfig,
9457}
9458
9459impl Default for HooksConfig {
9460    fn default() -> Self {
9461        Self {
9462            enabled: true,
9463            builtin: BuiltinHooksConfig::default(),
9464        }
9465    }
9466}
9467
9468#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
9469#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9470#[prefix = "hooks.builtin"]
9471pub struct BuiltinHooksConfig {
9472    /// Enable the command-logger hook (logs tool calls for auditing).
9473    pub command_logger: bool,
9474    /// Configuration for the webhook-audit hook.
9475    ///
9476    /// When enabled, POSTs a JSON payload to `url` for every tool invocation
9477    /// that matches one of `tool_patterns`.
9478    #[serde(default)]
9479    #[nested]
9480    pub webhook_audit: WebhookAuditConfig,
9481}
9482
9483/// Configuration for the webhook-audit builtin hook.
9484///
9485/// Sends an HTTP POST with a JSON body to an external endpoint each time
9486/// a tool call matches one of the configured patterns. Useful for
9487/// centralised audit logging, SIEM ingestion, or compliance pipelines.
9488#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9489#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9490#[prefix = "hooks.builtin.webhook_audit"]
9491pub struct WebhookAuditConfig {
9492    /// Enable the webhook-audit hook. Default: `false`.
9493    #[serde(default)]
9494    pub enabled: bool,
9495    /// Target URL that will receive the audit POST requests.
9496    #[serde(default)]
9497    pub url: String,
9498    /// Glob patterns for tool names to audit (e.g. `["Bash", "Write"]`).
9499    /// An empty list means **no** tools are audited.
9500    #[serde(default)]
9501    pub tool_patterns: Vec<String>,
9502    /// Include tool call arguments in the audit payload. Default: `false`.
9503    ///
9504    /// Be mindful of sensitive data — arguments may contain secrets or PII.
9505    #[serde(default)]
9506    pub include_args: bool,
9507    /// Maximum size (in bytes) of serialised arguments included in a single
9508    /// audit payload. Arguments exceeding this limit are truncated.
9509    /// Default: `4096`.
9510    #[serde(default = "default_max_args_bytes")]
9511    pub max_args_bytes: u64,
9512}
9513
9514fn default_max_args_bytes() -> u64 {
9515    4096
9516}
9517
9518impl Default for WebhookAuditConfig {
9519    fn default() -> Self {
9520        Self {
9521            enabled: false,
9522            url: String::new(),
9523            tool_patterns: Vec::new(),
9524            include_args: false,
9525            max_args_bytes: default_max_args_bytes(),
9526        }
9527    }
9528}
9529
9530// ── Autonomy / Security ──────────────────────────────────────────
9531//
9532// All policy fields live on per-agent `[risk_profiles.<alias>]` entries
9533// (see `RiskProfileConfig` below). `Config::active_risk_profile(agent_alias)`
9534// resolves the active profile for any callsite (agent-driven or non-agent
9535// contexts). Configs from older schema versions are folded into
9536// `risk_profiles.default` by the migration in `schema/v2.rs`.
9537
9538fn default_auto_approve() -> Vec<String> {
9539    vec![
9540        "file_read".into(),
9541        "memory_recall".into(),
9542        "web_search_tool".into(),
9543        "web_fetch".into(),
9544        "calculator".into(),
9545        "glob_search".into(),
9546        "content_search".into(),
9547        "image_info".into(),
9548        "weather".into(),
9549        "tool_search".into(),
9550        "browser".into(),
9551        "browser_open".into(),
9552    ]
9553}
9554
9555fn default_always_ask() -> Vec<String> {
9556    vec![]
9557}
9558
9559impl RiskProfileConfig {
9560    /// Merge the built-in default `auto_approve` entries into the current
9561    /// list, preserving any user-supplied additions.
9562    pub fn ensure_default_auto_approve(&mut self) {
9563        let defaults = default_auto_approve();
9564        for entry in defaults {
9565            if !self.auto_approve.iter().any(|existing| existing == &entry) {
9566                self.auto_approve.push(entry);
9567            }
9568        }
9569    }
9570
9571    /// Synthesize a [`SandboxConfig`] from this profile's flattened sandbox
9572    /// fields. Sandbox config is stored flat on the profile; callsites that
9573    /// still want a `SandboxConfig` instance (sandbox detection in
9574    /// `zeroclaw-runtime::security::detect`) can call this helper.
9575    #[must_use]
9576    pub fn sandbox_config(&self) -> SandboxConfig {
9577        let backend = self
9578            .sandbox_backend
9579            .as_deref()
9580            .map(str::trim)
9581            .filter(|s| !s.is_empty())
9582            .map(parse_sandbox_backend)
9583            .unwrap_or_default();
9584        SandboxConfig {
9585            enabled: self.sandbox_enabled,
9586            backend,
9587            firejail_args: self.firejail_args.clone(),
9588        }
9589    }
9590}
9591
9592fn parse_sandbox_backend(name: &str) -> SandboxBackend {
9593    match name.to_ascii_lowercase().as_str() {
9594        "auto" => SandboxBackend::Auto,
9595        "landlock" => SandboxBackend::Landlock,
9596        "firejail" => SandboxBackend::Firejail,
9597        "bubblewrap" => SandboxBackend::Bubblewrap,
9598        "docker" => SandboxBackend::Docker,
9599        "sandbox-exec" | "sandboxexec" | "seatbelt" => SandboxBackend::SandboxExec,
9600        "none" => SandboxBackend::None,
9601        _ => SandboxBackend::default(),
9602    }
9603}
9604
9605fn is_valid_env_var_name(name: &str) -> bool {
9606    let mut chars = name.chars();
9607    match chars.next() {
9608        Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
9609        _ => return false,
9610    }
9611    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
9612}
9613
9614// ── Profiles & Bundles ───────────────────────────────────────────
9615
9616/// Named risk/autonomy profile (`[risk_profiles.<alias>]`).
9617///
9618/// Unified policy surface. Agents reference a profile by alias and the
9619/// runtime resolves through it for shell command allowlists, approval gates,
9620/// sandbox/resource limits, and delegation guardrails. The conventional
9621/// `risk_profiles["default"]` is the resolution target for non-agent
9622/// contexts (orchestrator init, cron worker startup); the `Default` impl
9623/// below mirrors the legacy safety-first defaults so a fresh install
9624/// behaves the same as a config from before the per-profile split.
9625#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9626#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9627#[prefix = "risk_profile"]
9628#[serde(default)]
9629pub struct RiskProfileConfig {
9630    /// Autonomy level applied to this profile. Default: `supervised`.
9631    pub level: AutonomyLevel,
9632    /// Restrict filesystem access to workspace-relative paths. Default: `false`.
9633    pub workspace_only: bool,
9634    /// Allowlist of executable names for shell execution.
9635    pub allowed_commands: Vec<String>,
9636    /// Explicit path denylist.
9637    pub forbidden_paths: Vec<String>,
9638    /// Require approval for medium-risk operations.
9639    pub require_approval_for_medium_risk: bool,
9640    /// Block high-risk commands even when allowlisted.
9641    pub block_high_risk_commands: bool,
9642    /// Environment variable names passed through to shell subprocesses.
9643    #[credential_class = "legacy_env_path"]
9644    pub shell_env_passthrough: Vec<String>,
9645    /// Tools that never require approval in this profile.
9646    pub auto_approve: Vec<String>,
9647    /// Tools that always require approval in this profile.
9648    pub always_ask: Vec<String>,
9649    /// Extra directory roots the agent may access.
9650    #[serde(alias = "allowed_path", alias = "allowed_paths")]
9651    pub allowed_roots: Vec<String>,
9652    /// Whether and to which agents this profile may delegate. Defaults to
9653    /// `Forbidden`. Delegation requires caller and target to share a risk
9654    /// profile; the allow-list names the reachable same-profile agents.
9655    #[serde(default)]
9656    #[nested]
9657    pub delegation_policy: DelegationPolicy,
9658    /// Tools the agent may call in agentic mode. Empty = inherit / no
9659    /// authorization constraint. Authorization decision: which tools is
9660    /// the agent permitted to invoke at all. See `excluded_tools` for
9661    /// the inverse denylist scoped to non-CLI channels.
9662    pub allowed_tools: Vec<String>,
9663    /// Tools excluded from non-CLI channels under this profile.
9664    pub excluded_tools: Vec<String>,
9665    // ── Sandbox (from security.sandbox) ─────────────────────────────
9666    /// Whether the sandbox is enabled for this profile. `None` inherits global.
9667    pub sandbox_enabled: Option<bool>,
9668    /// Sandbox backend identifier (e.g. `"firejail"`, `"landlock"`). `None` inherits.
9669    pub sandbox_backend: Option<String>,
9670    /// Extra arguments forwarded to firejail when sandbox_backend = "firejail".
9671    pub firejail_args: Vec<String>,
9672}
9673
9674impl Default for RiskProfileConfig {
9675    fn default() -> Self {
9676        Self {
9677            level: AutonomyLevel::Supervised,
9678            workspace_only: true,
9679            allowed_commands: crate::policy::default_allowed_commands(),
9680            forbidden_paths: crate::policy::default_forbidden_paths(),
9681            require_approval_for_medium_risk: true,
9682            block_high_risk_commands: true,
9683            shell_env_passthrough: vec![],
9684            auto_approve: default_auto_approve(),
9685            always_ask: default_always_ask(),
9686            allowed_roots: Vec::new(),
9687            delegation_policy: DelegationPolicy::default(),
9688            allowed_tools: Vec::new(),
9689            excluded_tools: Vec::new(),
9690            sandbox_enabled: None,
9691            sandbox_backend: None,
9692            firejail_args: Vec::new(),
9693        }
9694    }
9695}
9696
9697/// Named runtime/LLM execution profile (`[runtime_profiles.<alias>]`).
9698///
9699/// Reusable operational tuning: agentic mode, iteration caps, context
9700/// budget, parallel dispatch, resource ceilings, recursion depth, and
9701/// the budget knobs that `SecurityPolicy` enforces with subagent
9702/// parent-subset discipline. Anything authorization-shaped (allowed
9703/// commands/tools/paths, approval gates, sandbox) lives on
9704/// `[risk_profiles.<alias>]`. Anything model-provider shaped (model,
9705/// temperature, max_tokens, timeout_secs) lives on
9706/// `[providers.models.<type>.<alias>]`.
9707#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9708#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9709#[prefix = "runtime_profile"]
9710#[serde(default)]
9711pub struct RuntimeProfileConfig {
9712    /// Enable agentic (multi-turn tool-call loop) mode.
9713    pub agentic: bool,
9714    /// Maximum tool-call iterations in agentic mode. `0` inherits the global default.
9715    pub max_tool_iterations: usize,
9716    // ── Budget caps (enforced with subagent parent-subset discipline) ──
9717    /// Maximum actions allowed per hour. `0` is a hard zero budget — the
9718    /// per-sender rate tracker treats a max of 0 as always exhausted
9719    /// (`PerSenderTracker::is_exhausted`), blocking every action. For an
9720    /// effectively-unlimited budget use a high value (e.g. `u32::MAX`), not 0.
9721    /// `SecurityPolicy::ensure_no_escalation_beyond` rejects subagents
9722    /// that try to raise this above the parent's value.
9723    pub max_actions_per_hour: u32,
9724    /// Maximum cost per day in cents. `0` inherits the global limit.
9725    /// Parent-subset enforced for subagents.
9726    pub max_cost_per_day_cents: u32,
9727    /// Shell subprocess timeout in seconds. `0` inherits the global timeout.
9728    /// Parent-subset enforced for subagents.
9729    pub shell_timeout_secs: u64,
9730    // ── Delegation tuning ──
9731    /// Maximum delegation recursion depth. `0` inherits the default.
9732    pub max_delegation_depth: u32,
9733    /// Delegate call timeout in seconds. `None` inherits global delegate timeout.
9734    pub delegation_timeout_secs: Option<u64>,
9735    /// Agentic delegate run timeout in seconds. `None` inherits global.
9736    pub agentic_timeout_secs: Option<u64>,
9737    // ── Per-agent runtime tunables (also live on AliasedAgentConfig) ─
9738    /// Maximum conversation history messages retained per session. `None` inherits.
9739    pub max_history_messages: Option<usize>,
9740    /// Maximum estimated tokens for context before compaction. `None` inherits.
9741    pub max_context_tokens: Option<usize>,
9742    /// Use compact bootstrap (6000 chars / 2 RAG chunks). `None` inherits.
9743    pub compact_context: Option<bool>,
9744    /// Enable parallel tool execution per iteration. `None` inherits.
9745    pub parallel_tools: Option<bool>,
9746    /// Tool dispatch strategy (e.g. `"auto"`). `None` inherits.
9747    pub tool_dispatcher: Option<String>,
9748    /// Tools exempt from within-turn dedup check.
9749    pub tool_call_dedup_exempt: Vec<String>,
9750    /// Maximum characters for the assembled system prompt. `None` inherits.
9751    pub max_system_prompt_chars: Option<usize>,
9752    /// Enable context-aware tool filtering per iteration. `None` inherits.
9753    pub context_aware_tools: Option<bool>,
9754    /// Maximum characters for a single tool result. `None` inherits.
9755    pub max_tool_result_chars: Option<usize>,
9756    /// Number of recent turns whose full tool context is preserved. `None` inherits.
9757    pub keep_tool_context_turns: Option<usize>,
9758    /// Maximum memory entries injected per turn. `None` inherits global default (5).
9759    /// Set to `0` for unlimited.
9760    pub memory_recall_limit: Option<usize>,
9761    pub strict_tool_parsing: bool,
9762    #[nested]
9763    pub thinking: crate::scattered_types::ThinkingConfig,
9764    #[nested]
9765    pub history_pruning: crate::scattered_types::HistoryPrunerConfig,
9766    #[nested]
9767    pub eval: crate::scattered_types::EvalConfig,
9768    #[nested]
9769    pub auto_classify: Option<crate::scattered_types::AutoClassifyConfig>,
9770    #[nested]
9771    pub context_compression: crate::scattered_types::ContextCompressionConfig,
9772    #[nested]
9773    pub tool_receipts: ToolReceiptsConfig,
9774    pub tool_filter_groups: Vec<ToolFilterGroup>,
9775}
9776
9777impl Default for RuntimeProfileConfig {
9778    fn default() -> Self {
9779        Self {
9780            agentic: false,
9781            max_tool_iterations: 0,
9782            max_actions_per_hour: 20,
9783            max_cost_per_day_cents: 500,
9784            shell_timeout_secs: 60,
9785            max_delegation_depth: 0,
9786            delegation_timeout_secs: None,
9787            agentic_timeout_secs: None,
9788            max_history_messages: None,
9789            max_context_tokens: None,
9790            compact_context: None,
9791            parallel_tools: None,
9792            tool_dispatcher: None,
9793            tool_call_dedup_exempt: Vec::new(),
9794            max_system_prompt_chars: None,
9795            context_aware_tools: None,
9796            max_tool_result_chars: None,
9797            keep_tool_context_turns: None,
9798            memory_recall_limit: None,
9799            strict_tool_parsing: false,
9800            thinking: crate::scattered_types::ThinkingConfig::default(),
9801            history_pruning: crate::scattered_types::HistoryPrunerConfig::default(),
9802            eval: crate::scattered_types::EvalConfig::default(),
9803            auto_classify: None,
9804            context_compression: crate::scattered_types::ContextCompressionConfig::default(),
9805            tool_receipts: ToolReceiptsConfig::default(),
9806            tool_filter_groups: Vec::new(),
9807        }
9808    }
9809}
9810
9811/// Named skill bundle (`[skill_bundles.<alias>]`).
9812///
9813/// A reusable group of skills that can be attached to an agent or channel
9814/// by alias, controlling which skills are loaded and from where.
9815#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9816#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9817#[prefix = "skill_bundle"]
9818#[serde(default)]
9819pub struct SkillBundleConfig {
9820    /// Directory path (relative to workspace root) to load skills from.
9821    pub directory: Option<String>,
9822    /// Skill names to include. Empty means include all skills in `directory`.
9823    pub include: Vec<String>,
9824    /// Skill names to exclude from this bundle.
9825    pub exclude: Vec<String>,
9826}
9827
9828/// Named knowledge bundle (`[knowledge_bundles.<alias>]`).
9829///
9830/// A reusable set of knowledge sources (documents, URLs, or RAG corpus paths)
9831/// that can be attached to an agent by alias.
9832#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9833#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9834#[prefix = "knowledge_bundle"]
9835#[serde(default)]
9836pub struct KnowledgeBundleConfig {
9837    /// Paths or URLs to include in this knowledge bundle.
9838    pub sources: Vec<String>,
9839    /// Tags for filtering or categorising sources within the bundle.
9840    pub tags: Vec<String>,
9841}
9842
9843/// Named MCP server bundle (`[mcp_bundles.<alias>]`).
9844///
9845/// A reusable group of MCP servers that can be activated together by alias.
9846#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
9847#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9848#[prefix = "mcp_bundle"]
9849#[serde(default)]
9850pub struct McpBundleConfig {
9851    /// MCP server IDs to include in this bundle.
9852    pub servers: Vec<String>,
9853    /// MCP server IDs to exclude from this bundle.
9854    pub exclude: Vec<String>,
9855}
9856
9857// ── Runtime ──────────────────────────────────────────────────────
9858
9859/// Runtime adapter configuration (`[runtime]` section).
9860#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9861#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9862#[prefix = "runtime"]
9863pub struct RuntimeConfig {
9864    /// Runtime kind (`native` | `docker`).
9865    #[serde(default = "default_runtime_kind")]
9866    pub kind: String,
9867
9868    /// Docker runtime settings (used when `kind = "docker"`).
9869    #[serde(default)]
9870    #[nested]
9871    pub docker: DockerRuntimeConfig,
9872
9873    /// Global reasoning override for model_providers that expose explicit controls.
9874    /// - `None`: model_provider default behavior
9875    /// - `Some(true)`: request reasoning/thinking when supported
9876    /// - `Some(false)`: disable reasoning/thinking when supported
9877    #[serde(default)]
9878    pub reasoning_enabled: Option<bool>,
9879    /// Optional reasoning effort for model_providers that expose a level control.
9880    #[serde(default, deserialize_with = "deserialize_reasoning_effort_opt")]
9881    pub reasoning_effort: Option<String>,
9882}
9883
9884/// Docker runtime configuration (`[runtime.docker]` section).
9885#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9886#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9887#[prefix = "runtime.docker"]
9888pub struct DockerRuntimeConfig {
9889    /// Runtime image used to execute shell commands.
9890    #[serde(default = "default_docker_image")]
9891    pub image: String,
9892
9893    /// Docker network mode (`none`, `bridge`, etc.).
9894    #[serde(default = "default_docker_network")]
9895    pub network: String,
9896
9897    /// Optional memory limit in MB (`None` = no explicit limit).
9898    #[serde(default = "default_docker_memory_limit_mb")]
9899    pub memory_limit_mb: Option<u64>,
9900
9901    /// Optional CPU limit (`None` = no explicit limit).
9902    #[serde(default = "default_docker_cpu_limit")]
9903    pub cpu_limit: Option<f64>,
9904
9905    /// Mount root filesystem as read-only.
9906    #[serde(default = "default_true")]
9907    pub read_only_rootfs: bool,
9908
9909    /// Mount configured workspace into `/workspace`.
9910    #[serde(default = "default_true")]
9911    pub mount_workspace: bool,
9912
9913    /// Optional workspace root allowlist for Docker mount validation.
9914    #[serde(default)]
9915    pub allowed_workspace_roots: Vec<String>,
9916}
9917
9918fn default_runtime_kind() -> String {
9919    "native".into()
9920}
9921
9922fn default_docker_image() -> String {
9923    "alpine:3.20".into()
9924}
9925
9926fn default_docker_network() -> String {
9927    "none".into()
9928}
9929
9930fn default_docker_memory_limit_mb() -> Option<u64> {
9931    Some(512)
9932}
9933
9934fn default_docker_cpu_limit() -> Option<f64> {
9935    Some(1.0)
9936}
9937
9938impl Default for DockerRuntimeConfig {
9939    fn default() -> Self {
9940        Self {
9941            image: default_docker_image(),
9942            network: default_docker_network(),
9943            memory_limit_mb: default_docker_memory_limit_mb(),
9944            cpu_limit: default_docker_cpu_limit(),
9945            read_only_rootfs: true,
9946            mount_workspace: true,
9947            allowed_workspace_roots: Vec::new(),
9948        }
9949    }
9950}
9951
9952impl Default for RuntimeConfig {
9953    fn default() -> Self {
9954        Self {
9955            kind: default_runtime_kind(),
9956            docker: DockerRuntimeConfig::default(),
9957            reasoning_enabled: None,
9958            reasoning_effort: None,
9959        }
9960    }
9961}
9962
9963// ── Reliability / supervision ────────────────────────────────────
9964
9965/// Reliability and supervision configuration (`[reliability]` section).
9966///
9967/// Controls model_provider retries, API key rotation, and channel restart backoff.
9968#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
9969#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
9970#[prefix = "reliability"]
9971pub struct ReliabilityConfig {
9972    /// Retries per model_provider before bailing.
9973    #[serde(default = "default_provider_retries")]
9974    pub provider_retries: u32,
9975    /// Base backoff (ms) for model_provider retry delay.
9976    #[serde(default = "default_provider_backoff_ms")]
9977    pub provider_backoff_ms: u64,
9978    /// Additional API keys for round-robin rotation on rate-limit (429) errors.
9979    /// The primary `api_key` is always tried first; these are extras.
9980    #[serde(default)]
9981    #[secret]
9982    #[credential_class = "encrypted_secret"]
9983    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
9984    pub api_keys: Vec<String>,
9985    /// Initial backoff for channel/daemon restarts.
9986    #[serde(default = "default_channel_backoff_secs")]
9987    pub channel_initial_backoff_secs: u64,
9988    /// Max backoff for channel/daemon restarts.
9989    #[serde(default = "default_channel_backoff_max_secs")]
9990    pub channel_max_backoff_secs: u64,
9991    /// Scheduler polling cadence in seconds.
9992    #[serde(default = "default_scheduler_poll_secs")]
9993    pub scheduler_poll_secs: u64,
9994    /// Max retries for cron job execution attempts.
9995    #[serde(default = "default_scheduler_retries")]
9996    pub scheduler_retries: u32,
9997}
9998
9999fn default_provider_retries() -> u32 {
10000    2
10001}
10002
10003fn default_provider_backoff_ms() -> u64 {
10004    500
10005}
10006
10007fn default_channel_backoff_secs() -> u64 {
10008    2
10009}
10010
10011fn default_channel_backoff_max_secs() -> u64 {
10012    60
10013}
10014
10015fn default_scheduler_poll_secs() -> u64 {
10016    15
10017}
10018
10019fn default_scheduler_retries() -> u32 {
10020    2
10021}
10022
10023impl Default for ReliabilityConfig {
10024    fn default() -> Self {
10025        Self {
10026            provider_retries: default_provider_retries(),
10027            provider_backoff_ms: default_provider_backoff_ms(),
10028            api_keys: Vec::new(),
10029            channel_initial_backoff_secs: default_channel_backoff_secs(),
10030            channel_max_backoff_secs: default_channel_backoff_max_secs(),
10031            scheduler_poll_secs: default_scheduler_poll_secs(),
10032            scheduler_retries: default_scheduler_retries(),
10033        }
10034    }
10035}
10036
10037// ── Scheduler ────────────────────────────────────────────────────
10038
10039/// Scheduler configuration for periodic task execution (`[scheduler]` section).
10040///
10041/// Owns the cron-runtime knobs: per-job declarations live on
10042/// `Config.cron: HashMap<String, CronJobDecl>` (alias-keyed), while the
10043/// scheduler loop's runtime behavior (`enabled`, polling cap, catch-up) lives here.
10044#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10045#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10046#[prefix = "scheduler"]
10047pub struct SchedulerConfig {
10048    /// Enable the built-in scheduler loop. When false, no cron jobs run.
10049    #[serde(default = "default_scheduler_enabled")]
10050    pub enabled: bool,
10051    /// Maximum number of persisted scheduled tasks per polling cycle.
10052    #[serde(default = "default_scheduler_max_tasks")]
10053    pub max_tasks: usize,
10054    /// Maximum tasks executed in parallel within a single polling cycle.
10055    #[serde(default = "default_scheduler_max_concurrent")]
10056    pub max_concurrent: usize,
10057    /// Run all overdue jobs at scheduler startup. Default: `true`.
10058    ///
10059    /// When the daemon restarts late, jobs whose `next_run` is in the past
10060    /// fire once before normal polling resumes. Disable to wait for the
10061    /// next scheduled occurrence instead.
10062    #[serde(default = "default_true")]
10063    pub catch_up_on_startup: bool,
10064    /// Maximum number of historical cron run records to retain. Default: `50`.
10065    #[serde(default = "default_max_run_history")]
10066    pub max_run_history: u32,
10067}
10068
10069fn default_scheduler_enabled() -> bool {
10070    true
10071}
10072
10073fn default_scheduler_max_tasks() -> usize {
10074    64
10075}
10076
10077fn default_scheduler_max_concurrent() -> usize {
10078    4
10079}
10080
10081impl Default for SchedulerConfig {
10082    fn default() -> Self {
10083        Self {
10084            enabled: default_scheduler_enabled(),
10085            max_tasks: default_scheduler_max_tasks(),
10086            max_concurrent: default_scheduler_max_concurrent(),
10087            catch_up_on_startup: true,
10088            max_run_history: default_max_run_history(),
10089        }
10090    }
10091}
10092
10093// ── Model routing ────────────────────────────────────────────────
10094
10095/// Route a task hint to a specific model_provider + model.
10096///
10097/// ```toml
10098/// [[model_routes]]
10099/// hint = "reasoning"
10100/// model_provider = "openrouter.default"
10101/// model = "anthropic/claude-opus-4-20250514"
10102///
10103/// [[model_routes]]
10104/// hint = "fast"
10105/// model_provider = "groq.low-latency"
10106/// model = "llama-3.3-70b-versatile"
10107/// ```
10108///
10109/// Usage: pass `hint:reasoning` as the model parameter to route the request.
10110#[derive(Debug, Clone, Serialize, Deserialize)]
10111#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10112pub struct ModelRouteConfig {
10113    /// Task hint name (e.g. "reasoning", "fast", "code", "summarize")
10114    pub hint: String,
10115    /// Dotted provider profile ref to route to (must resolve to providers.models.<type>.<alias>)
10116    pub model_provider: String,
10117    /// Provider-local model identifier to use with that provider profile
10118    pub model: String,
10119    /// Optional API key override for this route's model provider
10120    #[serde(default)]
10121    pub api_key: Option<String>,
10122}
10123
10124// ── Embedding routing ───────────────────────────────────────────
10125
10126/// Route an embedding hint to a specific model_provider + model.
10127///
10128/// ```toml
10129/// [[embedding_routes]]
10130/// hint = "semantic"
10131/// model_provider = "openai.embeddings"
10132/// model = "text-embedding-3-small"
10133/// dimensions = 1536
10134///
10135/// [memory]
10136/// embedding_model = "hint:semantic"
10137/// ```
10138#[derive(Debug, Clone, Serialize, Deserialize)]
10139#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10140pub struct EmbeddingRouteConfig {
10141    /// Route hint name (e.g. "semantic", "archive", "faq")
10142    pub hint: String,
10143    /// Dotted embedding-capable provider profile ref
10144    pub model_provider: String,
10145    /// Provider-local embedding model identifier to use with that provider profile
10146    pub model: String,
10147    /// Optional embedding dimension override for this route
10148    #[serde(default)]
10149    pub dimensions: Option<usize>,
10150    /// Optional API key override for this route's model_provider
10151    #[serde(default)]
10152    pub api_key: Option<String>,
10153}
10154
10155// ── Query Classification ─────────────────────────────────────────
10156
10157/// Automatic query classification — classifies user messages by keyword/pattern
10158/// and routes to the appropriate model hint. Disabled by default.
10159#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
10160#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10161#[prefix = "query_classification"]
10162pub struct QueryClassificationConfig {
10163    /// Enable automatic query classification. Default: `false`.
10164    #[serde(default)]
10165    pub enabled: bool,
10166    /// Classification rules evaluated in priority order.
10167    #[serde(default)]
10168    pub rules: Vec<ClassificationRule>,
10169}
10170
10171/// A single classification rule mapping message patterns to a model hint.
10172#[derive(Debug, Clone, Serialize, Deserialize, Default)]
10173#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10174pub struct ClassificationRule {
10175    /// Must match a `[[model_routes]]` hint value.
10176    pub hint: String,
10177    /// Case-insensitive substring matches.
10178    #[serde(default)]
10179    pub keywords: Vec<String>,
10180    /// Case-sensitive literal matches (for "```", "fn ", etc.).
10181    #[serde(default)]
10182    pub patterns: Vec<String>,
10183    /// Only match if message length >= N chars.
10184    #[serde(default)]
10185    pub min_length: Option<usize>,
10186    /// Only match if message length <= N chars.
10187    #[serde(default)]
10188    pub max_length: Option<usize>,
10189    /// Higher priority rules are checked first.
10190    #[serde(default)]
10191    pub priority: i32,
10192}
10193
10194// ── Heartbeat ────────────────────────────────────────────────────
10195
10196/// Heartbeat configuration for periodic health pings (`[heartbeat]` section).
10197#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10198#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10199#[prefix = "heartbeat"]
10200#[allow(clippy::struct_excessive_bools)]
10201pub struct HeartbeatConfig {
10202    /// Enable periodic heartbeat pings. Default: `false`. When enabled,
10203    /// `agent` must name a configured agent — there is no default agent
10204    /// for heartbeat to fall through to.
10205    #[serde(default)]
10206    pub enabled: bool,
10207    /// Configured agent alias the heartbeat worker runs as. Required
10208    /// when `enabled = true`; refers to a `[agents.<alias>]` entry.
10209    #[serde(default)]
10210    pub agent: String,
10211    /// Interval in minutes between heartbeat pings. Minimum: `1`. Default: `30`.
10212    #[serde(default = "default_heartbeat_interval")]
10213    pub interval_minutes: u32,
10214    /// Enable two-phase heartbeat: Phase 1 asks LLM whether to run, Phase 2
10215    /// executes only when the LLM decides there is work to do. Saves API cost
10216    /// during quiet periods. Default: `true`.
10217    #[serde(default = "default_two_phase")]
10218    pub two_phase: bool,
10219    /// Optional fallback task text when `HEARTBEAT.md` has no task entries.
10220    #[serde(default)]
10221    pub message: Option<String>,
10222    /// Optional delivery channel for heartbeat output (for example: `telegram`).
10223    /// When omitted, auto-selects the first configured channel.
10224    #[serde(default, alias = "channel")]
10225    pub target: Option<String>,
10226    /// Optional delivery recipient/chat identifier (required when `target` is
10227    /// explicitly set).
10228    #[serde(default, alias = "recipient")]
10229    pub to: Option<String>,
10230    /// Enable adaptive intervals that back off on failures and speed up for
10231    /// high-priority tasks. Default: `false`.
10232    #[serde(default)]
10233    pub adaptive: bool,
10234    /// Minimum interval in minutes when adaptive mode is enabled. Default: `5`.
10235    #[serde(default = "default_heartbeat_min_interval")]
10236    pub min_interval_minutes: u32,
10237    /// Maximum interval in minutes when adaptive mode backs off. Default: `120`.
10238    #[serde(default = "default_heartbeat_max_interval")]
10239    pub max_interval_minutes: u32,
10240    /// Dead-man's switch timeout in minutes. If the heartbeat has not ticked
10241    /// within this window, an alert is sent. `0` disables. Default: `0`.
10242    #[serde(default)]
10243    pub deadman_timeout_minutes: u32,
10244    /// Channel for dead-man's switch alerts (e.g. `telegram`). Falls back to
10245    /// the heartbeat delivery channel.
10246    #[serde(default)]
10247    pub deadman_channel: Option<String>,
10248    /// Recipient for dead-man's switch alerts. Falls back to `to`.
10249    #[serde(default)]
10250    pub deadman_to: Option<String>,
10251    /// Maximum number of heartbeat run history records to retain. Default: `100`.
10252    #[serde(default = "default_heartbeat_max_run_history")]
10253    pub max_run_history: u32,
10254    /// Load the channel session history before each heartbeat task execution so
10255    /// the LLM has conversational context. Default: `false`.
10256    ///
10257    /// When `true`, the session file for the configured `target`/`to` is passed
10258    /// to the agent as `session_state_file`, giving it access to the recent
10259    /// conversation history — just as if the user had sent a message.
10260    #[serde(default)]
10261    pub load_session_context: bool,
10262    /// Maximum wall-clock seconds allowed for a single agent invocation
10263    /// (Phase 1 decision or Phase 2 task execution). `0` disables.
10264    /// Default: `600` (10 minutes).
10265    #[serde(default = "default_heartbeat_task_timeout")]
10266    pub task_timeout_secs: u64,
10267}
10268
10269fn default_heartbeat_interval() -> u32 {
10270    30
10271}
10272
10273fn default_two_phase() -> bool {
10274    true
10275}
10276
10277fn default_heartbeat_min_interval() -> u32 {
10278    5
10279}
10280
10281fn default_heartbeat_max_interval() -> u32 {
10282    120
10283}
10284
10285fn default_heartbeat_max_run_history() -> u32 {
10286    100
10287}
10288
10289fn default_heartbeat_task_timeout() -> u64 {
10290    600
10291}
10292
10293impl Default for HeartbeatConfig {
10294    fn default() -> Self {
10295        Self {
10296            enabled: false,
10297            agent: String::new(),
10298            interval_minutes: default_heartbeat_interval(),
10299            two_phase: true,
10300            message: None,
10301            target: None,
10302            to: None,
10303            adaptive: false,
10304            min_interval_minutes: default_heartbeat_min_interval(),
10305            max_interval_minutes: default_heartbeat_max_interval(),
10306            deadman_timeout_minutes: 0,
10307            deadman_channel: None,
10308            deadman_to: None,
10309            max_run_history: default_heartbeat_max_run_history(),
10310            load_session_context: false,
10311            task_timeout_secs: default_heartbeat_task_timeout(),
10312        }
10313    }
10314}
10315
10316// ── Cron ────────────────────────────────────────────────────────
10317
10318/// A declarative cron job definition (`[cron.<alias>]`).
10319///
10320/// Stored alias-keyed on `Config.cron`. The map key serves as the stable job id.
10321/// Synced into the database at scheduler startup with `source = "declarative"`,
10322/// distinguishing them from jobs created imperatively via CLI or API.
10323/// Declarative config takes precedence on each sync: if the config changes,
10324/// the DB is updated to match. Imperative jobs are never deleted by sync.
10325#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10326#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10327#[prefix = "cron"]
10328pub struct CronJobDecl {
10329    /// Human-readable name.
10330    #[serde(default)]
10331    pub name: Option<String>,
10332    /// Job type: `"shell"` (default) or `"agent"`.
10333    #[serde(default = "default_job_type_decl")]
10334    pub job_type: String,
10335    /// Schedule for the job.
10336    #[serde(default)]
10337    pub schedule: CronScheduleDecl,
10338    /// Shell command to run (required when `job_type = "shell"`).
10339    #[serde(default)]
10340    pub command: Option<String>,
10341    /// Agent prompt (required when `job_type = "agent"`).
10342    #[serde(default)]
10343    pub prompt: Option<String>,
10344    /// Whether the job is enabled. Default: `true`.
10345    #[serde(default = "default_true")]
10346    pub enabled: bool,
10347    /// Model override for agent jobs.
10348    #[serde(default)]
10349    pub model: Option<String>,
10350    /// Optional allowlist of tool names for agent jobs. When omitted, scheduler
10351    /// defaults may still exclude scheduler mutation tools for cron agent jobs.
10352    #[serde(default)]
10353    pub allowed_tools: Option<Vec<String>>,
10354    /// Whether to recall and inject memory context before this agent job runs.
10355    /// Defaults to `true`; set to `false` for stateless digest jobs.
10356    #[serde(default = "default_true")]
10357    pub uses_memory: bool,
10358    /// Session target: `"isolated"` (default) or `"main"`.
10359    #[serde(default)]
10360    pub session_target: Option<String>,
10361    /// Delivery configuration.
10362    #[serde(default)]
10363    #[nested]
10364    pub delivery: Option<DeliveryConfigDecl>,
10365}
10366
10367impl Default for CronJobDecl {
10368    fn default() -> Self {
10369        Self {
10370            name: None,
10371            job_type: default_job_type_decl(),
10372            schedule: CronScheduleDecl::default(),
10373            command: None,
10374            prompt: None,
10375            enabled: true,
10376            model: None,
10377            allowed_tools: None,
10378            uses_memory: true,
10379            session_target: None,
10380            delivery: None,
10381        }
10382    }
10383}
10384
10385/// Schedule variant for declarative cron jobs.
10386#[derive(Debug, Clone, Serialize, Deserialize)]
10387#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10388#[serde(tag = "kind", rename_all = "lowercase")]
10389pub enum CronScheduleDecl {
10390    /// Classic cron expression.
10391    Cron {
10392        expr: String,
10393        #[serde(default)]
10394        tz: Option<String>,
10395    },
10396    /// Interval in milliseconds.
10397    Every { every_ms: u64 },
10398    /// One-shot at an RFC 3339 timestamp.
10399    At { at: String },
10400}
10401
10402impl Default for CronScheduleDecl {
10403    fn default() -> Self {
10404        // Empty cron expression — `validate_decl` rejects it. Used only as
10405        // a placeholder when a fresh map entry is auto-created via the
10406        // schema's `create_map_key` path; the user fills it in immediately.
10407        Self::Cron {
10408            expr: String::new(),
10409            tz: None,
10410        }
10411    }
10412}
10413
10414/// Delivery configuration for declarative cron jobs.
10415#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10416#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10417#[prefix = "cron_delivery"]
10418pub struct DeliveryConfigDecl {
10419    /// Delivery mode: `"none"` or `"announce"`.
10420    #[serde(default = "default_delivery_mode")]
10421    pub mode: String,
10422    /// Channel name (e.g. `"telegram"`, `"discord"`).
10423    #[serde(default)]
10424    pub channel: Option<String>,
10425    /// Target/recipient identifier.
10426    #[serde(default)]
10427    pub to: Option<String>,
10428    /// Optional thread/conversation identifier carried into the outbound send.
10429    /// Required by channels that route on a separate `thread_id` field (e.g.
10430    /// webhook callbacks bridging into agent-chat platforms).
10431    #[serde(default, skip_serializing_if = "Option::is_none")]
10432    pub thread_id: Option<String>,
10433    /// Best-effort delivery. Default: `true`.
10434    #[serde(default = "default_true")]
10435    pub best_effort: bool,
10436}
10437
10438impl Default for DeliveryConfigDecl {
10439    fn default() -> Self {
10440        Self {
10441            mode: default_delivery_mode(),
10442            channel: None,
10443            to: None,
10444            thread_id: None,
10445            best_effort: true,
10446        }
10447    }
10448}
10449
10450fn default_job_type_decl() -> String {
10451    "shell".to_string()
10452}
10453
10454fn default_delivery_mode() -> String {
10455    "none".to_string()
10456}
10457
10458fn default_max_run_history() -> u32 {
10459    50
10460}
10461
10462// ── ACP ──────────────────────────────────────────────────────────
10463
10464/// ACP (Agent Client Protocol) server configuration (`[acp]` section).
10465#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10466#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10467#[prefix = "acp"]
10468pub struct AcpConfig {
10469    /// Agent alias to use when `session/new` omits `agentAlias` and more than
10470    /// one agent is configured. When exactly one agent exists it is
10471    /// auto-selected regardless of this field.
10472    #[serde(default, skip_serializing_if = "Option::is_none")]
10473    pub default_agent: Option<String>,
10474    /// Maximum number of concurrent ACP sessions. Default: `10`.
10475    #[serde(default = "default_acp_max_sessions")]
10476    pub max_sessions: usize,
10477    /// Idle session timeout in seconds. Sessions with no activity for this
10478    /// duration are eligible for eviction. Default: `3600` (1 hour).
10479    #[serde(default = "default_acp_session_timeout_secs")]
10480    pub session_timeout_secs: u64,
10481}
10482
10483fn default_acp_max_sessions() -> usize {
10484    10
10485}
10486
10487fn default_acp_session_timeout_secs() -> u64 {
10488    3600
10489}
10490
10491impl Default for AcpConfig {
10492    fn default() -> Self {
10493        Self {
10494            default_agent: None,
10495            max_sessions: default_acp_max_sessions(),
10496            session_timeout_secs: default_acp_session_timeout_secs(),
10497        }
10498    }
10499}
10500
10501// ── Tunnel ──────────────────────────────────────────────────────
10502
10503/// Tunnel configuration for exposing the gateway publicly (`[tunnel]` section).
10504///
10505/// Supported model_providers: `"none"` (default), `"cloudflare"`, `"tailscale"`, `"ngrok"`, `"openvpn"`, `"pinggy"`, `"custom"`.
10506#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10507#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10508#[prefix = "tunnel"]
10509pub struct TunnelConfig {
10510    /// 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]`.
10511    pub tunnel_provider: String,
10512
10513    /// Cloudflare Tunnel configuration (used when `tunnel_provider = "cloudflare"`).
10514    #[serde(default)]
10515    #[nested]
10516    pub cloudflare: Option<CloudflareTunnelConfig>,
10517
10518    /// Tailscale Funnel/Serve configuration (used when `tunnel_provider = "tailscale"`).
10519    #[serde(default)]
10520    #[nested]
10521    pub tailscale: Option<TailscaleTunnelConfig>,
10522
10523    /// ngrok tunnel configuration (used when `tunnel_provider = "ngrok"`).
10524    #[serde(default)]
10525    #[nested]
10526    pub ngrok: Option<NgrokTunnelConfig>,
10527
10528    /// OpenVPN tunnel configuration (used when `tunnel_provider = "openvpn"`).
10529    #[serde(default)]
10530    #[nested]
10531    pub openvpn: Option<OpenVpnTunnelConfig>,
10532
10533    /// Custom tunnel command configuration (used when `tunnel_provider = "custom"`).
10534    #[serde(default)]
10535    #[nested]
10536    pub custom: Option<CustomTunnelConfig>,
10537
10538    /// Pinggy tunnel configuration (used when `tunnel_provider = "pinggy"`).
10539    #[serde(default)]
10540    #[nested]
10541    pub pinggy: Option<PinggyTunnelConfig>,
10542}
10543
10544impl Default for TunnelConfig {
10545    fn default() -> Self {
10546        Self {
10547            tunnel_provider: "none".into(),
10548            cloudflare: None,
10549            tailscale: None,
10550            ngrok: None,
10551            openvpn: None,
10552            custom: None,
10553            pinggy: None,
10554        }
10555    }
10556}
10557
10558#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10559#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10560#[prefix = "tunnel.cloudflare"]
10561pub struct CloudflareTunnelConfig {
10562    /// Cloudflare Tunnel token (from Zero Trust dashboard)
10563    #[serde(default)]
10564    #[secret]
10565    #[credential_class = "encrypted_secret"]
10566    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10567    pub token: String,
10568}
10569
10570#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10571#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10572#[prefix = "tunnel.tailscale"]
10573pub struct TailscaleTunnelConfig {
10574    /// Use Tailscale Funnel (public internet) vs Serve (tailnet only)
10575    #[serde(default)]
10576    pub funnel: bool,
10577    /// Optional hostname override
10578    #[serde(default)]
10579    pub hostname: Option<String>,
10580}
10581
10582#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10583#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10584#[prefix = "tunnel.ngrok"]
10585pub struct NgrokTunnelConfig {
10586    /// ngrok auth token
10587    #[serde(default)]
10588    #[secret]
10589    #[credential_class = "encrypted_secret"]
10590    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10591    pub auth_token: String,
10592    /// Optional custom domain
10593    #[serde(default)]
10594    pub domain: Option<String>,
10595}
10596
10597/// OpenVPN tunnel configuration (`[tunnel.openvpn]`).
10598///
10599/// Required when `tunnel.tunnel_provider = "openvpn"`. Omitting this section entirely
10600/// preserves previous behavior. Setting `tunnel.tunnel_provider = "none"` (or removing
10601/// the `[tunnel.openvpn]` block) cleanly reverts to no-tunnel mode.
10602///
10603/// Defaults: `connect_timeout_secs = 30`.
10604#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10605#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10606#[prefix = "tunnel.openvpn"]
10607pub struct OpenVpnTunnelConfig {
10608    /// Path to `.ovpn` configuration file (must not be empty).
10609    pub config_file: String,
10610    /// Optional path to auth credentials file (`--auth-user-pass`).
10611    #[serde(default)]
10612    #[credential_class = "path_only_reference"]
10613    pub auth_file: Option<String>,
10614    /// Advertised address once VPN is connected (e.g., `"10.8.0.2:42617"`).
10615    /// When omitted the tunnel falls back to `http://{local_host}:{local_port}`.
10616    #[serde(default)]
10617    pub advertise_address: Option<String>,
10618    /// Connection timeout in seconds (default: 30, must be > 0).
10619    #[serde(default = "default_openvpn_timeout")]
10620    pub connect_timeout_secs: u64,
10621    /// Extra openvpn CLI arguments forwarded verbatim.
10622    #[serde(default)]
10623    pub extra_args: Vec<String>,
10624}
10625
10626fn default_openvpn_timeout() -> u64 {
10627    30
10628}
10629
10630impl Default for OpenVpnTunnelConfig {
10631    fn default() -> Self {
10632        Self {
10633            config_file: String::new(),
10634            auth_file: None,
10635            advertise_address: None,
10636            connect_timeout_secs: default_openvpn_timeout(),
10637            extra_args: Vec::new(),
10638        }
10639    }
10640}
10641
10642#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10643#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10644#[prefix = "tunnel.pinggy"]
10645pub struct PinggyTunnelConfig {
10646    /// Pinggy access token (optional — free tier works without one).
10647    #[serde(default)]
10648    #[secret]
10649    #[credential_class = "encrypted_secret"]
10650    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
10651    pub token: Option<String>,
10652    /// Server region: `"us"` (USA), `"eu"` (Europe), `"ap"` (Asia), `"br"` (South America), `"au"` (Australia), or omit for auto.
10653    #[serde(default)]
10654    pub region: Option<String>,
10655}
10656
10657#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
10658#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10659#[prefix = "tunnel.custom"]
10660pub struct CustomTunnelConfig {
10661    /// Command template to start the tunnel. Use {port} and {host} placeholders.
10662    /// Example: "bore local {port} --to bore.pub"
10663    #[serde(default)]
10664    pub start_command: String,
10665    /// Optional URL to check tunnel health
10666    #[serde(default)]
10667    pub health_url: Option<String>,
10668    /// Optional regex to extract public URL from command stdout
10669    #[serde(default)]
10670    pub url_pattern: Option<String>,
10671}
10672
10673// ── Channels ─────────────────────────────────────────────────────
10674
10675/// Top-level channel configurations (`[channels]` section).
10676///
10677/// each channel type is a keyed table of named instances (aliases).
10678/// `[channels.telegram.default]` is the conventional single-instance key.
10679/// Access via `config.channels.telegram.get("default")`.
10680#[allow(clippy::struct_excessive_bools)]
10681#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
10682#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
10683#[prefix = "channels"]
10684pub struct ChannelsConfig {
10685    /// Enable the CLI interactive channel. Default: `true`.
10686    #[serde(default = "default_true")]
10687    pub cli: bool,
10688    /// Telegram bot channel instances (`[channels.telegram.<alias>]`).
10689    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10690    #[nested]
10691    pub telegram: HashMap<String, TelegramConfig>,
10692    /// Discord bot channel instances (`[channels.discord.<alias>]`).
10693    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10694    #[nested]
10695    pub discord: HashMap<String, DiscordConfig>,
10696    /// Slack bot channel instances (`[channels.slack.<alias>]`).
10697    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10698    #[nested]
10699    pub slack: HashMap<String, SlackConfig>,
10700    /// Mattermost bot channel instances (`[channels.mattermost.<alias>]`).
10701    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10702    #[nested]
10703    pub mattermost: HashMap<String, MattermostConfig>,
10704    /// Webhook channel instances (`[channels.webhook.<alias>]`).
10705    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10706    #[nested]
10707    pub webhook: HashMap<String, WebhookConfig>,
10708    /// iMessage channel instances (`[channels.imessage.<alias>]`, macOS only).
10709    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10710    #[nested]
10711    pub imessage: HashMap<String, IMessageConfig>,
10712    /// Matrix channel instances (`[channels.matrix.<alias>]`).
10713    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10714    #[nested]
10715    pub matrix: HashMap<String, MatrixConfig>,
10716    /// Signal channel instances (`[channels.signal.<alias>]`).
10717    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10718    #[nested]
10719    pub signal: HashMap<String, SignalConfig>,
10720    /// WhatsApp channel instances (`[channels.whatsapp.<alias>]`).
10721    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10722    #[nested]
10723    pub whatsapp: HashMap<String, WhatsAppConfig>,
10724    /// Linq Partner API channel instances (`[channels.linq.<alias>]`).
10725    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10726    #[nested]
10727    pub linq: HashMap<String, LinqConfig>,
10728    /// WATI WhatsApp Business API channel instances (`[channels.wati.<alias>]`).
10729    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10730    #[nested]
10731    pub wati: HashMap<String, WatiConfig>,
10732    /// Nextcloud Talk bot channel instances (`[channels.nextcloud_talk.<alias>]`).
10733    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10734    #[nested]
10735    pub nextcloud_talk: HashMap<String, NextcloudTalkConfig>,
10736    /// Email channel instances (`[channels.email.<alias>]`).
10737    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10738    #[nested]
10739    pub email: HashMap<String, crate::scattered_types::EmailConfig>,
10740    /// Gmail Pub/Sub push notification channel instances (`[channels.gmail_push.<alias>]`).
10741    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10742    #[nested]
10743    pub gmail_push: HashMap<String, crate::scattered_types::GmailPushConfig>,
10744    /// IRC channel instances (`[channels.irc.<alias>]`).
10745    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10746    #[nested]
10747    pub irc: HashMap<String, IrcConfig>,
10748    /// Twitch chat channel instances (`[channels.twitch.<alias>]`).
10749    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10750    #[nested]
10751    pub twitch: HashMap<String, TwitchConfig>,
10752    /// Lark channel instances (`[channels.lark.<alias>]`).
10753    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10754    #[nested]
10755    pub lark: HashMap<String, LarkConfig>,
10756    /// LINE Messaging API channel instances (`[channels.line.<alias>]`).
10757    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10758    #[nested]
10759    pub line: HashMap<String, LineConfig>,
10760    /// DingTalk channel instances (`[channels.dingtalk.<alias>]`).
10761    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10762    #[nested]
10763    pub dingtalk: HashMap<String, DingTalkConfig>,
10764    /// WeCom (WeChat Enterprise) Bot Webhook channel instances (`[channels.wecom.<alias>]`).
10765    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10766    #[nested]
10767    pub wecom: HashMap<String, WeComConfig>,
10768    /// WeCom AI Bot WebSocket channel instances (`[channels.wecom_ws.<alias>]`).
10769    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10770    #[nested]
10771    pub wecom_ws: HashMap<String, WeComWsConfig>,
10772    /// WeChat personal iLink Bot channel instances (`[channels.wechat.<alias>]`).
10773    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10774    #[nested]
10775    pub wechat: HashMap<String, WeChatConfig>,
10776    /// QQ Official Bot channel instances (`[channels.qq.<alias>]`).
10777    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10778    #[nested]
10779    pub qq: HashMap<String, QQConfig>,
10780    /// X/Twitter channel instances (`[channels.twitter.<alias>]`).
10781    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10782    #[nested]
10783    pub twitter: HashMap<String, TwitterConfig>,
10784    /// Mochat customer service channel instances (`[channels.mochat.<alias>]`).
10785    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10786    #[nested]
10787    pub mochat: HashMap<String, MochatConfig>,
10788    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10789    #[nested]
10790    pub nostr: HashMap<String, NostrConfig>,
10791    /// ClawdTalk voice channel instances (`[channels.clawdtalk.<alias>]`).
10792    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10793    #[nested]
10794    pub clawdtalk: HashMap<String, crate::scattered_types::ClawdTalkConfig>,
10795    /// Reddit channel instances (`[channels.reddit.<alias>]`).
10796    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10797    #[nested]
10798    pub reddit: HashMap<String, RedditConfig>,
10799    /// Bluesky channel instances (`[channels.bluesky.<alias>]`).
10800    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10801    #[nested]
10802    pub bluesky: HashMap<String, BlueskyConfig>,
10803    /// Voice call channel instances (`[channels.voice_call.<alias>]`).
10804    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10805    #[nested]
10806    pub voice_call: HashMap<String, crate::scattered_types::VoiceCallConfig>,
10807    /// Voice wake word detection channel instances (`[channels.voice_wake.<alias>]`).
10808    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10809    #[nested]
10810    pub voice_wake: HashMap<String, VoiceWakeConfig>,
10811    /// Voice duplex instances (`[channels.voice_duplex.<alias>]`).
10812    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10813    #[nested]
10814    pub voice_duplex: HashMap<String, VoiceDuplexConfig>,
10815    /// MQTT channel instances (`[channels.mqtt.<alias>]`).
10816    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10817    #[nested]
10818    pub mqtt: HashMap<String, MqttConfig>,
10819    /// AMQP channel instances (`[channels.amqp.<alias>]`).
10820    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
10821    #[nested]
10822    pub amqp: HashMap<String, AmqpConfig>,
10823    /// Base timeout in seconds for processing a single channel message (LLM + tools).
10824    /// Runtime uses this as a per-turn budget that scales with tool-loop depth
10825    /// (up to 4x, capped) so one slow/retried model call does not consume the
10826    /// entire conversation budget.
10827    /// Default: 300s for on-device LLMs (Ollama) which are slower than cloud APIs.
10828    #[serde(default = "default_channel_message_timeout_secs")]
10829    pub message_timeout_secs: u64,
10830    /// Per-channel multiplier for the global channel message in-flight budget.
10831    /// Runtime multiplies this value by the configured channel count, then
10832    /// applies its global minimum and maximum bounds to one shared dispatcher
10833    /// semaphore. Default: `4`.
10834    #[serde(default = "default_channel_max_concurrent_per_channel")]
10835    pub max_concurrent_per_channel: usize,
10836    /// Whether to add acknowledgement reactions (👀 on receipt, ✅/⚠️ on
10837    /// completion) to incoming channel messages. Default: `true`.
10838    #[serde(default = "default_true")]
10839    pub ack_reactions: bool,
10840    /// Whether to send tool-call notification messages (e.g. `🔧 web_search_tool: …`)
10841    /// to channel users. When `false`, tool calls are still logged server-side but
10842    /// not forwarded as individual channel messages. Default: `false`.
10843    #[serde(default = "default_false")]
10844    pub show_tool_calls: bool,
10845    /// Persist channel conversation history to JSONL files so sessions survive
10846    /// daemon restarts. Files are stored in `{workspace}/sessions/`. Default: `true`.
10847    #[serde(default = "default_true")]
10848    pub session_persistence: bool,
10849    /// Session persistence backend: `"jsonl"` (legacy) or `"sqlite"` (new default).
10850    /// SQLite provides FTS5 search, metadata tracking, and TTL cleanup.
10851    #[serde(default = "default_session_backend")]
10852    pub session_backend: String,
10853    /// Auto-archive stale sessions older than this many hours. `0` disables. Default: `0`.
10854    #[serde(default)]
10855    pub session_ttl_hours: u32,
10856    /// Inbound message debounce window in milliseconds. When a sender fires
10857    /// multiple messages within this window, they are accumulated and dispatched
10858    /// as a single concatenated message. `0` disables debouncing. Default: `0`.
10859    #[serde(default)]
10860    pub debounce_ms: u64,
10861}
10862
10863impl ChannelsConfig {
10864    /// Returns metadata and configuration status for every known channel type.
10865    ///
10866    /// Always returns the full set of channel types regardless of compile-time
10867    /// feature flags — the `configured` flag reflects whether the operator has
10868    /// populated that channel's config section.  For a list restricted to only
10869    /// the channels compiled into this binary use
10870    /// `zeroclaw_channels::listing::compiled_channels` instead.
10871    pub fn channels(&self) -> Vec<super::traits::ChannelInfo> {
10872        use super::traits::ChannelInfo;
10873        vec![
10874            ChannelInfo {
10875                kind: "telegram",
10876                name: "Telegram",
10877                desc: "connect your bot",
10878                configured: !self.telegram.is_empty(),
10879            },
10880            ChannelInfo {
10881                kind: "discord",
10882                name: "Discord",
10883                desc: "connect your bot",
10884                configured: !self.discord.is_empty(),
10885            },
10886            ChannelInfo {
10887                kind: "slack",
10888                name: "Slack",
10889                desc: "connect your bot",
10890                configured: !self.slack.is_empty(),
10891            },
10892            ChannelInfo {
10893                kind: "mattermost",
10894                name: "Mattermost",
10895                desc: "connect to your bot",
10896                configured: !self.mattermost.is_empty(),
10897            },
10898            ChannelInfo {
10899                kind: "imessage",
10900                name: "iMessage",
10901                desc: "macOS only",
10902                configured: !self.imessage.is_empty(),
10903            },
10904            ChannelInfo {
10905                kind: "matrix",
10906                name: "Matrix",
10907                desc: "self-hosted chat",
10908                configured: !self.matrix.is_empty(),
10909            },
10910            ChannelInfo {
10911                kind: "signal",
10912                name: "Signal",
10913                desc: "An open-source, encrypted messaging service",
10914                configured: !self.signal.is_empty(),
10915            },
10916            ChannelInfo {
10917                kind: "whatsapp",
10918                name: "WhatsApp",
10919                desc: "Business Cloud API",
10920                configured: !self.whatsapp.is_empty(),
10921            },
10922            ChannelInfo {
10923                kind: "whatsapp-web",
10924                name: "WhatsApp Web",
10925                desc: "native WhatsApp Web (wa-rs)",
10926                configured: self.whatsapp.values().any(|c| c.is_web_config()),
10927            },
10928            ChannelInfo {
10929                kind: "linq",
10930                name: "Linq",
10931                desc: "iMessage/RCS/SMS via Linq API",
10932                configured: !self.linq.is_empty(),
10933            },
10934            ChannelInfo {
10935                kind: "wati",
10936                name: "WATI",
10937                desc: "WhatsApp via WATI Business API",
10938                configured: !self.wati.is_empty(),
10939            },
10940            ChannelInfo {
10941                kind: "nextcloud",
10942                name: "NextCloud Talk",
10943                desc: "NextCloud Talk platform",
10944                configured: !self.nextcloud_talk.is_empty(),
10945            },
10946            ChannelInfo {
10947                kind: "email",
10948                name: "Email",
10949                desc: "Email over IMAP/SMTP",
10950                configured: !self.email.is_empty(),
10951            },
10952            ChannelInfo {
10953                kind: "gmail-push",
10954                name: "Gmail Push",
10955                desc: "Gmail Pub/Sub push notifications",
10956                configured: !self.gmail_push.is_empty(),
10957            },
10958            ChannelInfo {
10959                kind: "twitch",
10960                name: "Twitch",
10961                desc: "Twitch chat (IRC)",
10962                configured: !self.twitch.is_empty(),
10963            },
10964            ChannelInfo {
10965                kind: "irc",
10966                name: "IRC",
10967                desc: "IRC over TLS",
10968                configured: !self.irc.is_empty(),
10969            },
10970            ChannelInfo {
10971                kind: "lark",
10972                name: "Lark",
10973                desc: "Lark Bot",
10974                configured: !self.lark.is_empty(),
10975            },
10976            ChannelInfo {
10977                kind: "dingtalk",
10978                name: "DingTalk",
10979                desc: "DingTalk Stream Mode",
10980                configured: !self.dingtalk.is_empty(),
10981            },
10982            ChannelInfo {
10983                kind: "wecom",
10984                name: "WeCom",
10985                desc: "WeCom Bot Webhook",
10986                configured: !self.wecom.is_empty(),
10987            },
10988            ChannelInfo {
10989                kind: "wecom-ws",
10990                name: "WeCom WebSocket",
10991                desc: "WeCom AI Bot long connection",
10992                configured: !self.wecom_ws.is_empty(),
10993            },
10994            ChannelInfo {
10995                kind: "wechat",
10996                name: "WeChat",
10997                desc: "WeChat iLink Bot",
10998                configured: !self.wechat.is_empty(),
10999            },
11000            ChannelInfo {
11001                kind: "qq",
11002                name: "QQ Official",
11003                desc: "Tencent QQ Bot",
11004                configured: !self.qq.is_empty(),
11005            },
11006            ChannelInfo {
11007                kind: "nostr",
11008                name: "Nostr",
11009                desc: "Nostr DMs",
11010                configured: !self.nostr.is_empty(),
11011            },
11012            ChannelInfo {
11013                kind: "clawdtalk",
11014                name: "ClawdTalk",
11015                desc: "ClawdTalk Channel",
11016                configured: !self.clawdtalk.is_empty(),
11017            },
11018            ChannelInfo {
11019                kind: "reddit",
11020                name: "Reddit",
11021                desc: "Reddit bot (OAuth2)",
11022                configured: !self.reddit.is_empty(),
11023            },
11024            ChannelInfo {
11025                kind: "bluesky",
11026                name: "Bluesky",
11027                desc: "AT Protocol",
11028                configured: !self.bluesky.is_empty(),
11029            },
11030            ChannelInfo {
11031                kind: "twitter",
11032                name: "X/Twitter",
11033                desc: "X/Twitter Bot via API v2",
11034                configured: !self.twitter.is_empty(),
11035            },
11036            ChannelInfo {
11037                kind: "mochat",
11038                name: "Mochat",
11039                desc: "Mochat Customer Service",
11040                configured: !self.mochat.is_empty(),
11041            },
11042            ChannelInfo {
11043                kind: "line",
11044                name: "LINE",
11045                desc: "connect your LINE bot",
11046                configured: !self.line.is_empty(),
11047            },
11048            ChannelInfo {
11049                kind: "voice-call",
11050                name: "Voice Call",
11051                desc: "outbound voice call channel",
11052                configured: !self.voice_call.is_empty(),
11053            },
11054            ChannelInfo {
11055                kind: "voice-wake",
11056                name: "VoiceWake",
11057                desc: "voice wake word detection",
11058                configured: !self.voice_wake.is_empty(),
11059            },
11060            ChannelInfo {
11061                kind: "mqtt",
11062                name: "MQTT",
11063                desc: "MQTT SOP Listener",
11064                configured: !self.mqtt.is_empty(),
11065            },
11066            ChannelInfo {
11067                kind: "amqp",
11068                name: "AMQP",
11069                desc: "AMQP topic consumer",
11070                configured: !self.amqp.is_empty(),
11071            },
11072            ChannelInfo {
11073                kind: "webhook",
11074                name: "Webhook",
11075                desc: "HTTP endpoint",
11076                configured: !self.webhook.is_empty(),
11077            },
11078        ]
11079    }
11080
11081    /// Returns `true` when at least one channel entry across all channel types
11082    /// has `enabled = true`. Used by the daemon to decide whether the channels
11083    /// supervisor should be started — a config with only `enabled = false`
11084    /// entries (e.g. partially-configured or disabled bots) must not start the
11085    /// supervisor, otherwise it exits immediately and restarts in a tight loop.
11086    pub fn has_any_enabled(&self) -> bool {
11087        self.telegram.values().any(|c| c.enabled)
11088            || self.discord.values().any(|c| c.enabled)
11089            || self.slack.values().any(|c| c.enabled)
11090            || self.mattermost.values().any(|c| c.enabled)
11091            || self.webhook.values().any(|c| c.enabled)
11092            || self.imessage.values().any(|c| c.enabled)
11093            || self.matrix.values().any(|c| c.enabled)
11094            || self.signal.values().any(|c| c.enabled)
11095            || self.whatsapp.values().any(|c| c.enabled)
11096            || self.linq.values().any(|c| c.enabled)
11097            || self.wati.values().any(|c| c.enabled)
11098            || self.nextcloud_talk.values().any(|c| c.enabled)
11099            || self.email.values().any(|c| c.enabled)
11100            || self.gmail_push.values().any(|c| c.enabled)
11101            || self.irc.values().any(|c| c.enabled)
11102            || self.twitch.values().any(|c| c.enabled)
11103            || self.lark.values().any(|c| c.enabled)
11104            || self.line.values().any(|c| c.enabled)
11105            || self.dingtalk.values().any(|c| c.enabled)
11106            || self.wecom.values().any(|c| c.enabled)
11107            || self.wecom_ws.values().any(|c| c.enabled)
11108            || self.wechat.values().any(|c| c.enabled)
11109            || self.qq.values().any(|c| c.enabled)
11110            || self.twitter.values().any(|c| c.enabled)
11111            || self.mochat.values().any(|c| c.enabled)
11112            || self.nostr.values().any(|c| c.enabled)
11113            || self.clawdtalk.values().any(|c| c.enabled)
11114            || self.reddit.values().any(|c| c.enabled)
11115            || self.bluesky.values().any(|c| c.enabled)
11116            || self.voice_call.values().any(|c| c.enabled)
11117            || self.voice_wake.values().any(|c| c.enabled)
11118            || self.voice_duplex.values().any(|c| c.enabled)
11119            || self.mqtt.values().any(|c| c.enabled)
11120            || self.amqp.values().any(|c| c.enabled)
11121    }
11122}
11123
11124fn default_channel_message_timeout_secs() -> u64 {
11125    300
11126}
11127
11128fn default_channel_max_concurrent_per_channel() -> usize {
11129    4
11130}
11131
11132fn default_session_backend() -> String {
11133    "sqlite".into()
11134}
11135
11136impl Default for ChannelsConfig {
11137    fn default() -> Self {
11138        Self {
11139            cli: true,
11140            telegram: HashMap::new(),
11141            discord: HashMap::new(),
11142            slack: HashMap::new(),
11143            mattermost: HashMap::new(),
11144            webhook: HashMap::new(),
11145            imessage: HashMap::new(),
11146            matrix: HashMap::new(),
11147            signal: HashMap::new(),
11148            whatsapp: HashMap::new(),
11149            linq: HashMap::new(),
11150            wati: HashMap::new(),
11151            nextcloud_talk: HashMap::new(),
11152            email: HashMap::new(),
11153            gmail_push: HashMap::new(),
11154            irc: HashMap::new(),
11155            twitch: HashMap::new(),
11156            lark: HashMap::new(),
11157            line: HashMap::new(),
11158            dingtalk: HashMap::new(),
11159            wecom: HashMap::new(),
11160            wecom_ws: HashMap::new(),
11161            wechat: HashMap::new(),
11162            qq: HashMap::new(),
11163            twitter: HashMap::new(),
11164            mochat: HashMap::new(),
11165            nostr: HashMap::new(),
11166            clawdtalk: HashMap::new(),
11167            reddit: HashMap::new(),
11168            bluesky: HashMap::new(),
11169            voice_call: HashMap::new(),
11170            voice_wake: HashMap::new(),
11171            voice_duplex: HashMap::new(),
11172            mqtt: HashMap::new(),
11173            amqp: HashMap::new(),
11174            message_timeout_secs: default_channel_message_timeout_secs(),
11175            max_concurrent_per_channel: default_channel_max_concurrent_per_channel(),
11176            ack_reactions: true,
11177            show_tool_calls: false,
11178            session_persistence: true,
11179            session_backend: default_session_backend(),
11180            session_ttl_hours: 0,
11181            debounce_ms: 0,
11182        }
11183    }
11184}
11185
11186/// Streaming mode for channels that support progressive message updates.
11187#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
11188#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11189#[serde(rename_all = "lowercase")]
11190pub enum StreamMode {
11191    /// No streaming -- send the complete response as a single message (default).
11192    #[default]
11193    Off,
11194    /// Update a draft message with every flush interval.
11195    Partial,
11196    /// Send the response as multiple separate messages at paragraph boundaries.
11197    #[serde(rename = "multi_message")]
11198    MultiMessage,
11199}
11200
11201fn default_draft_update_interval_ms() -> u64 {
11202    1000
11203}
11204
11205fn default_multi_message_delay_ms() -> u64 {
11206    800
11207}
11208
11209fn default_telegram_approval_timeout_secs() -> u64 {
11210    120
11211}
11212
11213fn default_channel_approval_timeout_secs() -> u64 {
11214    300
11215}
11216
11217fn default_matrix_draft_update_interval_ms() -> u64 {
11218    1500
11219}
11220
11221/// Telegram bot channel configuration.
11222#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11223#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11224#[prefix = "channels.telegram"]
11225pub struct TelegramConfig {
11226    /// Whether this channel is active. The runtime only loads channels whose
11227    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11228    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11229    /// live before the rest of its config is filled in.
11230    #[tab(Behavior)]
11231    #[serde(default)]
11232    pub enabled: bool,
11233    /// Telegram Bot API token (from @BotFather).
11234    #[secret]
11235    #[tab(Connection)]
11236    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11237    pub bot_token: String,
11238    /// Streaming mode for progressive response delivery via message edits.
11239    #[tab(Behavior)]
11240    #[serde(default)]
11241    pub stream_mode: StreamMode,
11242    /// Minimum interval (ms) between draft message edits to avoid rate limits.
11243    #[tab(Behavior)]
11244    #[serde(default = "default_draft_update_interval_ms")]
11245    pub draft_update_interval_ms: u64,
11246    /// When true, a newer Telegram message from the same sender in the same chat
11247    /// cancels the in-flight request and starts a fresh response with preserved history.
11248    #[tab(Behavior)]
11249    #[serde(default)]
11250    pub interrupt_on_new_message: bool,
11251    /// When true, only respond to messages that @-mention the bot in groups.
11252    /// Direct messages are always processed.
11253    #[tab(Behavior)]
11254    #[serde(default)]
11255    pub mention_only: bool,
11256    /// Override for the top-level `ack_reactions` setting. When `None`, the
11257    /// channel falls back to `[channels].ack_reactions`. When set
11258    /// explicitly, it takes precedence.
11259    #[tab(Behavior)]
11260    #[serde(default)]
11261    pub ack_reactions: Option<bool>,
11262    /// Per-channel proxy URL (http, https, socks5, socks5h).
11263    /// Overrides the global `[proxy]` setting for this channel only.
11264    #[tab(Advanced)]
11265    #[serde(default)]
11266    pub proxy_url: Option<String>,
11267    /// How long (seconds) to wait for the operator to tap an inline-keyboard
11268    /// button on a tool approval prompt before auto-denying. Default: 120.
11269    #[tab(Behavior)]
11270    #[serde(default = "default_telegram_approval_timeout_secs")]
11271    pub approval_timeout_secs: u64,
11272
11273    /// Tools excluded from this channel's tool spec. When set, these tools
11274    /// are not exposed to the model when responding via this channel.
11275    #[tab(Behavior)]
11276    #[serde(default)]
11277    pub excluded_tools: Vec<String>,
11278    /// Per-(channel, recipient) outbound pacing floor in seconds.
11279    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
11280    #[serde(default)]
11281    pub reply_min_interval_secs: u64,
11282    /// Per-(channel, recipient) outbound pacing queue depth.
11283    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
11284    /// and this value is `0`, the pacing wrapper substitutes
11285    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
11286    /// newest send is dropped and a `WARN` is logged.
11287    #[serde(default)]
11288    pub reply_queue_depth_max: u16,
11289}
11290
11291impl ChannelConfig for TelegramConfig {
11292    fn name() -> &'static str {
11293        "Telegram"
11294    }
11295    fn desc() -> &'static str {
11296        "connect your bot"
11297    }
11298}
11299
11300/// Discord bot channel configuration.
11301#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11302#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11303#[prefix = "channels.discord"]
11304#[allow(clippy::struct_excessive_bools)]
11305pub struct DiscordConfig {
11306    /// Whether this channel is active. The runtime only loads channels whose
11307    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11308    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11309    /// live before the rest of its config is filled in.
11310    #[tab(Behavior)]
11311    #[serde(default)]
11312    pub enabled: bool,
11313    /// Discord bot token (from Discord Developer Portal).
11314    #[secret]
11315    #[tab(Connection)]
11316    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11317    pub bot_token: String,
11318    /// Guild (server) IDs to restrict the bot to. Empty = listen across all
11319    /// guilds the bot is invited to. Migrated from the legacy `guild_id`
11320    /// singular field.
11321    #[tab(Advanced)]
11322    #[serde(default)]
11323    pub guild_ids: Vec<String>,
11324    /// Channel IDs to watch. Empty = watch every channel the bot can see.
11325    /// Used by the archive sidecar (when `archive = true`) and by the
11326    /// in-channel filter when set.
11327    #[tab(Advanced)]
11328    #[serde(default)]
11329    pub channel_ids: Vec<String>,
11330    /// When true, the channel opens a sidecar `discord.db` SQLite memory
11331    /// backend, archives every non-bot message it sees, and registers the
11332    /// `discord_search` tool against it. Default: false. Folded in from
11333    /// the legacy `[channels.discord-history]` block.
11334    #[tab(Advanced)]
11335    #[serde(default)]
11336    pub archive: bool,
11337    /// When true, process messages from other bots (not just humans).
11338    /// The bot still ignores its own messages to prevent feedback loops.
11339    #[tab(Advanced)]
11340    #[serde(default)]
11341    pub listen_to_bots: bool,
11342    /// When true, a newer Discord message from the same sender in the same channel
11343    /// cancels the in-flight request and starts a fresh response with preserved history.
11344    #[tab(Behavior)]
11345    #[serde(default)]
11346    pub interrupt_on_new_message: bool,
11347    /// When true, only respond to messages that @-mention the bot.
11348    /// Other messages in the guild are silently ignored.
11349    #[tab(Behavior)]
11350    #[serde(default)]
11351    pub mention_only: bool,
11352    /// Per-channel proxy URL (http, https, socks5, socks5h).
11353    /// Overrides the global `[proxy]` setting for this channel only.
11354    #[tab(Advanced)]
11355    #[serde(default)]
11356    pub proxy_url: Option<String>,
11357    /// Streaming mode for progressive response delivery.
11358    /// `off` (default): single message. `partial`: editable draft updates.
11359    /// `multi_message`: split response into separate messages at paragraph boundaries.
11360    #[tab(Behavior)]
11361    #[serde(default)]
11362    pub stream_mode: StreamMode,
11363    /// Minimum interval (ms) between draft message edits to avoid rate limits.
11364    /// Only used when `stream_mode = "partial"`.
11365    #[tab(Behavior)]
11366    #[serde(default = "default_draft_update_interval_ms")]
11367    pub draft_update_interval_ms: u64,
11368    /// Delay (ms) between sending each message chunk in multi-message mode.
11369    /// Only used when `stream_mode = "multi_message"`.
11370    #[tab(Behavior)]
11371    #[serde(default = "default_multi_message_delay_ms")]
11372    pub multi_message_delay_ms: u64,
11373    /// Stall-watchdog timeout in seconds. When non-zero, the bot will abort
11374    /// and retry if no progress is made within this duration. 0 = disabled.
11375    #[tab(Advanced)]
11376    #[serde(default)]
11377    pub stall_timeout_secs: u64,
11378    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
11379    #[tab(Behavior)]
11380    #[serde(default = "default_channel_approval_timeout_secs")]
11381    pub approval_timeout_secs: u64,
11382
11383    /// Tools excluded from this channel's tool spec. When set, these tools
11384    /// are not exposed to the model when responding via this channel.
11385    #[tab(Behavior)]
11386    #[serde(default)]
11387    pub excluded_tools: Vec<String>,
11388    /// Per-(channel, recipient) outbound pacing floor in seconds.
11389    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
11390    #[serde(default)]
11391    pub reply_min_interval_secs: u64,
11392    /// Per-(channel, recipient) outbound pacing queue depth.
11393    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
11394    /// and this value is `0`, the pacing wrapper substitutes
11395    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
11396    /// newest send is dropped and a `WARN` is logged.
11397    #[serde(default)]
11398    pub reply_queue_depth_max: u16,
11399}
11400
11401impl ChannelConfig for DiscordConfig {
11402    fn name() -> &'static str {
11403        "Discord"
11404    }
11405    fn desc() -> &'static str {
11406        "connect your bot"
11407    }
11408}
11409
11410/// Slack bot channel configuration.
11411#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11412#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11413#[prefix = "channels.slack"]
11414#[allow(clippy::struct_excessive_bools)]
11415pub struct SlackConfig {
11416    /// Whether this channel is active. The runtime only loads channels whose
11417    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11418    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11419    /// live before the rest of its config is filled in.
11420    #[tab(Behavior)]
11421    #[serde(default)]
11422    pub enabled: bool,
11423    /// Slack bot OAuth token (xoxb-...).
11424    #[secret]
11425    #[tab(Connection)]
11426    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11427    pub bot_token: String,
11428    /// Slack app-level token for Socket Mode (xapp-...).
11429    #[secret]
11430    #[tab(Connection)]
11431    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11432    pub app_token: Option<String>,
11433    /// Explicit list of channel IDs to watch.
11434    /// Empty = listen across all accessible channels.
11435    /// Migrated from the legacy `channel_id` singular field.
11436    #[tab(Advanced)]
11437    #[serde(default)]
11438    pub channel_ids: Vec<String>,
11439    /// When true, a newer Slack message from the same sender in the same channel
11440    /// cancels the in-flight request and starts a fresh response with preserved history.
11441    #[tab(Behavior)]
11442    #[serde(default)]
11443    pub interrupt_on_new_message: bool,
11444    /// When true (default), replies stay in the originating Slack thread.
11445    /// When false, replies go to the channel root instead.
11446    #[tab(Advanced)]
11447    #[serde(default)]
11448    pub thread_replies: Option<bool>,
11449    /// When true, only respond to messages that @-mention the bot in groups.
11450    /// Direct messages remain allowed.
11451    #[tab(Behavior)]
11452    #[serde(default)]
11453    pub mention_only: bool,
11454    /// When true (and `mention_only` is also true), messages inside a Slack
11455    /// thread must also @-mention the bot to trigger a response. By default,
11456    /// thread replies are allowed through without a mention so the bot can
11457    /// keep a back-and-forth going without the user repeating @-mentions.
11458    /// Set this to true in channels shared with human discussion where the
11459    /// bot should stay silent unless explicitly addressed.
11460    #[tab(Advanced)]
11461    #[serde(default)]
11462    pub strict_mention_in_thread: bool,
11463    /// Use the newer Slack `markdown` block type (12 000 char limit, richer formatting).
11464    /// Defaults to false (uses universally supported `section` blocks with `mrkdwn`).
11465    /// Enable this only if your Slack workspace supports the `markdown` block type.
11466    #[tab(Advanced)]
11467    #[serde(default)]
11468    pub use_markdown_blocks: bool,
11469    /// Per-channel proxy URL (http, https, socks5, socks5h).
11470    /// Overrides the global `[proxy]` setting for this channel only.
11471    #[tab(Advanced)]
11472    #[serde(default)]
11473    pub proxy_url: Option<String>,
11474    /// Enable progressive draft message streaming via `chat.update`.
11475    #[tab(Behavior)]
11476    #[serde(default)]
11477    pub stream_drafts: bool,
11478    /// Minimum interval (ms) between draft message edits to avoid Slack rate limits.
11479    #[tab(Behavior)]
11480    #[serde(default = "default_slack_draft_update_interval_ms")]
11481    pub draft_update_interval_ms: u64,
11482    /// Emoji reaction name (without colons) that cancels an in-flight request.
11483    /// For example, `"x"` means reacting with `:x:` cancels the task.
11484    /// Leave unset to disable reaction-based cancellation.
11485    #[tab(Advanced)]
11486    #[serde(default)]
11487    pub cancel_reaction: Option<String>,
11488    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
11489    #[tab(Behavior)]
11490    #[serde(default = "default_channel_approval_timeout_secs")]
11491    pub approval_timeout_secs: u64,
11492
11493    /// Tools excluded from this channel's tool spec. When set, these tools
11494    /// are not exposed to the model when responding via this channel.
11495    #[tab(Behavior)]
11496    #[serde(default)]
11497    pub excluded_tools: Vec<String>,
11498    /// Per-(channel, recipient) outbound pacing floor in seconds.
11499    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
11500    #[serde(default)]
11501    pub reply_min_interval_secs: u64,
11502    /// Per-(channel, recipient) outbound pacing queue depth.
11503    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
11504    /// and this value is `0`, the pacing wrapper substitutes
11505    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
11506    /// newest send is dropped and a `WARN` is logged.
11507    #[serde(default)]
11508    pub reply_queue_depth_max: u16,
11509}
11510
11511fn default_slack_draft_update_interval_ms() -> u64 {
11512    1200
11513}
11514
11515impl ChannelConfig for SlackConfig {
11516    fn name() -> &'static str {
11517        "Slack"
11518    }
11519    fn desc() -> &'static str {
11520        "connect your bot"
11521    }
11522}
11523
11524/// Mattermost bot channel configuration.
11525#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11526#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11527#[prefix = "channels.mattermost"]
11528pub struct MattermostConfig {
11529    /// Whether this channel is active. The runtime only loads channels whose
11530    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11531    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11532    /// live before the rest of its config is filled in.
11533    #[tab(Behavior)]
11534    #[serde(default)]
11535    pub enabled: bool,
11536    /// Mattermost server URL (e.g. `"https://mattermost.example.com"`).
11537    #[tab(Connection)]
11538    pub url: String,
11539    /// Mattermost bot access token. When unset, the channel falls back to
11540    /// the login flow using `login_id` + `password`.
11541    #[secret]
11542    #[tab(Connection)]
11543    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11544    #[serde(default)]
11545    pub bot_token: Option<String>,
11546    /// Login ID (email or username) for the password login flow. Used only
11547    /// when `bot_token` is unset; both `login_id` and `password` must be
11548    /// set together.
11549    #[tab(Connection)]
11550    #[serde(default)]
11551    pub login_id: Option<String>,
11552    /// Account password for the login flow. Used only when `bot_token` is
11553    /// unset; both `login_id` and `password` must be set together.
11554    #[secret]
11555    #[tab(Connection)]
11556    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11557    #[serde(default)]
11558    pub password: Option<String>,
11559    /// Channel IDs to restrict the bot to. Empty or `["*"]` = auto-discover
11560    /// every channel the bot can read (public, private, DMs, group DMs) and
11561    /// poll them all. Explicit IDs disable discovery and pin the bot to the
11562    /// listed channels only. Migrated from the legacy `channel_id` singular
11563    /// field.
11564    #[tab(Advanced)]
11565    #[serde(default)]
11566    pub channel_ids: Vec<String>,
11567    /// Team IDs to restrict auto-discovery to. Empty = discover across every
11568    /// team the bot belongs to. Non-empty = only discover public/private
11569    /// channels whose `team_id` is in this list. DMs and group DMs (which
11570    /// have no team) are governed by `discover_dms` instead.
11571    #[tab(Advanced)]
11572    #[serde(default)]
11573    pub team_ids: Vec<String>,
11574    /// When true (default), auto-discovery includes DM (`type=D`) and group
11575    /// DM (`type=G`) channels. Set false to restrict the bot to public and
11576    /// private team channels only. Has no effect when `channel_ids` lists
11577    /// explicit IDs. Defaults to `true` at the call site via
11578    /// `discover_dms.unwrap_or(true)`.
11579    #[tab(Advanced)]
11580    #[serde(default)]
11581    pub discover_dms: Option<bool>,
11582    /// When true (default), replies thread on the original post.
11583    /// When false, replies go to the channel root.
11584    #[tab(Advanced)]
11585    #[serde(default)]
11586    pub thread_replies: Option<bool>,
11587    /// When true, only respond to messages that @-mention the bot. Other
11588    /// messages in the channel are silently ignored. DM and group DM
11589    /// channels always bypass this filter: a 1:1 (or small-group) direct
11590    /// conversation has no ambient noise to gate against, so every message
11591    /// is treated as addressed to the bot.
11592    #[tab(Behavior)]
11593    #[serde(default)]
11594    pub mention_only: Option<bool>,
11595    /// When true, a newer Mattermost message from the same sender in the same channel
11596    /// cancels the in-flight request and starts a fresh response with preserved history.
11597    #[tab(Behavior)]
11598    #[serde(default)]
11599    pub interrupt_on_new_message: bool,
11600    /// Per-channel proxy URL (http, https, socks5, socks5h).
11601    /// Overrides the global `[proxy]` setting for this channel only.
11602    #[tab(Advanced)]
11603    #[serde(default)]
11604    pub proxy_url: Option<String>,
11605
11606    /// Tools excluded from this channel's tool spec. When set, these tools
11607    /// are not exposed to the model when responding via this channel.
11608    #[tab(Behavior)]
11609    #[serde(default)]
11610    pub excluded_tools: Vec<String>,
11611    /// Per-(channel, recipient) outbound pacing floor in seconds.
11612    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
11613    #[serde(default)]
11614    pub reply_min_interval_secs: u64,
11615    /// Per-(channel, recipient) outbound pacing queue depth.
11616    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
11617    /// and this value is `0`, the pacing wrapper substitutes
11618    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
11619    /// newest send is dropped and a `WARN` is logged.
11620    #[serde(default)]
11621    pub reply_queue_depth_max: u16,
11622}
11623
11624impl ChannelConfig for MattermostConfig {
11625    fn name() -> &'static str {
11626        "Mattermost"
11627    }
11628    fn desc() -> &'static str {
11629        "connect to your bot"
11630    }
11631}
11632
11633/// Webhook channel configuration.
11634///
11635/// Receives messages via HTTP POST and sends replies to a configurable outbound URL.
11636/// This is the "universal adapter" for any system that supports webhooks.
11637#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11638#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11639#[prefix = "channels.webhook"]
11640pub struct WebhookConfig {
11641    /// Whether this channel is active. The runtime only loads channels whose
11642    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11643    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11644    /// live before the rest of its config is filled in.
11645    #[tab(Behavior)]
11646    #[serde(default)]
11647    pub enabled: bool,
11648    /// Port to listen on for incoming webhooks.
11649    #[tab(Advanced)]
11650    #[serde(default = "default_webhook_channel_port")]
11651    pub port: u16,
11652    /// URL path to listen on (default: `/webhook`).
11653    #[tab(Advanced)]
11654    #[serde(default)]
11655    pub listen_path: Option<String>,
11656    /// URL to POST/PUT outbound messages to.
11657    #[tab(Advanced)]
11658    #[serde(default)]
11659    pub send_url: Option<String>,
11660    /// HTTP method for outbound messages (`POST` or `PUT`). Default: `POST`.
11661    #[tab(Advanced)]
11662    #[serde(default)]
11663    pub send_method: Option<String>,
11664    /// Optional `Authorization` header value for outbound requests.
11665    #[tab(Connection)]
11666    #[serde(default)]
11667    #[secret]
11668    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11669    pub auth_header: Option<String>,
11670    /// Optional shared secret for webhook signature verification (HMAC-SHA256).
11671    #[secret]
11672    #[tab(Connection)]
11673    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11674    pub secret: Option<String>,
11675
11676    /// Tools excluded from this channel's tool spec. When set, these tools
11677    /// are not exposed to the model when responding via this channel.
11678    #[tab(Behavior)]
11679    #[serde(default)]
11680    pub excluded_tools: Vec<String>,
11681    /// Per-(channel, recipient) outbound pacing floor in seconds.
11682    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
11683    #[serde(default)]
11684    pub reply_min_interval_secs: u64,
11685    /// Per-(channel, recipient) outbound pacing queue depth.
11686    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
11687    /// and this value is `0`, the pacing wrapper substitutes
11688    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
11689    /// newest send is dropped and a `WARN` is logged.
11690    #[serde(default)]
11691    pub reply_queue_depth_max: u16,
11692
11693    /// Maximum number of retry attempts for outbound sends on transient failures
11694    /// (network errors, 429, 5xx). Set to `0` to disable retries. Default: `3`.
11695    #[serde(default)]
11696    pub max_retries: Option<u32>,
11697    /// Base delay in milliseconds for exponential backoff between retries. Default: `500`.
11698    /// Values below `1` are clamped to `1ms` at runtime to avoid busy-retry loops.
11699    #[serde(default)]
11700    pub retry_base_delay_ms: Option<u64>,
11701    /// Maximum delay cap in milliseconds for any single retry wait. Default: `30000` (30s).
11702    /// Values below `1` are clamped to `1ms` at runtime to avoid busy-retry loops.
11703    #[serde(default)]
11704    pub retry_max_delay_ms: Option<u64>,
11705}
11706
11707fn default_webhook_channel_port() -> u16 {
11708    8090
11709}
11710
11711impl ChannelConfig for WebhookConfig {
11712    fn name() -> &'static str {
11713        "Webhook"
11714    }
11715    fn desc() -> &'static str {
11716        "HTTP endpoint"
11717    }
11718}
11719
11720/// iMessage channel configuration (macOS only).
11721#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11722#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11723#[prefix = "channels.imessage"]
11724pub struct IMessageConfig {
11725    /// Whether this channel is active. The runtime only loads channels whose
11726    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11727    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11728    /// live before the rest of its config is filled in.
11729    #[tab(Behavior)]
11730    #[serde(default)]
11731    pub enabled: bool,
11732    /// Tools excluded from this channel's tool spec. When set, these tools
11733    /// are not exposed to the model when responding via this channel.
11734    #[tab(Behavior)]
11735    #[serde(default)]
11736    pub excluded_tools: Vec<String>,
11737    /// Per-(channel, recipient) outbound pacing floor in seconds.
11738    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
11739    #[serde(default)]
11740    pub reply_min_interval_secs: u64,
11741    /// Per-(channel, recipient) outbound pacing queue depth.
11742    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
11743    /// and this value is `0`, the pacing wrapper substitutes
11744    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
11745    /// newest send is dropped and a `WARN` is logged.
11746    #[serde(default)]
11747    pub reply_queue_depth_max: u16,
11748}
11749
11750impl ChannelConfig for IMessageConfig {
11751    fn name() -> &'static str {
11752        "iMessage"
11753    }
11754    fn desc() -> &'static str {
11755        "macOS only"
11756    }
11757}
11758
11759/// Matrix channel configuration.
11760#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11761#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11762#[prefix = "channels.matrix"]
11763pub struct MatrixConfig {
11764    /// Whether this channel is active. The runtime only loads channels whose
11765    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11766    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11767    /// live before the rest of its config is filled in.
11768    #[tab(Behavior)]
11769    #[serde(default)]
11770    pub enabled: bool,
11771    /// Matrix homeserver URL (e.g. `"https://matrix.org"`).
11772    #[tab(Connection)]
11773    pub homeserver: String,
11774    /// Matrix access token for the bot account. When unset, the channel
11775    /// falls back to password login using `user_id` + `password`.
11776    #[secret]
11777    #[credential_class = "encrypted_secret"]
11778    #[tab(Connection)]
11779    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11780    #[serde(default)]
11781    pub access_token: Option<String>,
11782    /// Optional Matrix user ID (e.g. `"@bot:matrix.org"`).
11783    #[tab(Connection)]
11784    #[serde(default)]
11785    pub user_id: Option<String>,
11786    /// Optional Matrix device ID.
11787    #[tab(Connection)]
11788    #[serde(default)]
11789    pub device_id: Option<String>,
11790    /// Allowed Matrix room IDs. Empty = allow all rooms the bot has joined.
11791    /// Entries are matched literally against the canonical room ID
11792    /// (`!abc:server`) of each incoming message; `#room:server` aliases are
11793    /// not resolved for this allowlist (they are resolved only for outbound
11794    /// delivery targets such as cron `delivery.to`).
11795    #[tab(Behavior)]
11796    #[serde(default)]
11797    pub allowed_rooms: Vec<String>,
11798    /// Whether to interrupt an in-flight agent response when a new message arrives.
11799    #[tab(Behavior)]
11800    #[serde(default)]
11801    pub interrupt_on_new_message: bool,
11802    /// Streaming mode for progressive response delivery.
11803    /// `"off"` (default): single message. `"partial"`: edit-in-place draft.
11804    /// `"multi_message"`: paragraph-split delivery.
11805    #[tab(Behavior)]
11806    #[serde(default)]
11807    pub stream_mode: StreamMode,
11808    /// Minimum interval (ms) between draft message edits in Partial mode.
11809    #[tab(Behavior)]
11810    #[serde(default = "default_matrix_draft_update_interval_ms")]
11811    pub draft_update_interval_ms: u64,
11812    /// Delay (ms) between sending each paragraph in MultiMessage mode.
11813    #[tab(Behavior)]
11814    #[serde(default = "default_multi_message_delay_ms")]
11815    pub multi_message_delay_ms: u64,
11816    /// When true, only respond to messages that @-mention the bot in groups.
11817    /// Direct messages are always processed.
11818    #[tab(Behavior)]
11819    #[serde(default)]
11820    pub mention_only: bool,
11821    /// Optional Matrix recovery key for automatic E2EE key backup restore.
11822    /// When set, ZeroClaw recovers room keys and cross-signing secrets on startup.
11823    #[secret]
11824    #[credential_class = "encrypted_secret"]
11825    #[tab(Connection)]
11826    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11827    #[serde(default)]
11828    pub recovery_key: Option<String>,
11829    /// Optional login password for Matrix account (used for initial login flow).
11830    #[secret]
11831    #[credential_class = "encrypted_secret"]
11832    #[tab(Connection)]
11833    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
11834    #[serde(default)]
11835    pub password: Option<String>,
11836    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
11837    #[tab(Behavior)]
11838    #[serde(default = "default_channel_approval_timeout_secs")]
11839    pub approval_timeout_secs: u64,
11840    /// When true (default), replies are sent as thread replies. Starts a new thread from the
11841    /// incoming message when none exists. When false, only continues existing threads.
11842    #[tab(Behavior)]
11843    #[serde(default = "default_true")]
11844    pub reply_in_thread: bool,
11845    /// Override for the top-level `[channels].ack_reactions`. When
11846    /// `None`, falls back to the channels-wide default. When set
11847    /// explicitly (`true`/`false`), takes precedence for this Matrix
11848    /// instance only.
11849    #[tab(Behavior)]
11850    #[serde(default)]
11851    pub ack_reactions: Option<bool>,
11852
11853    /// Tools excluded from this channel's tool spec. When set, these tools
11854    /// are not exposed to the model when responding via this channel.
11855    #[tab(Behavior)]
11856    #[serde(default)]
11857    pub excluded_tools: Vec<String>,
11858    /// Per-(channel, recipient) outbound pacing floor in seconds.
11859    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
11860    #[serde(default)]
11861    pub reply_min_interval_secs: u64,
11862    /// Per-(channel, recipient) outbound pacing queue depth.
11863    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
11864    /// and this value is `0`, the pacing wrapper substitutes
11865    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
11866    /// newest send is dropped and a `WARN` is logged.
11867    #[serde(default)]
11868    pub reply_queue_depth_max: u16,
11869}
11870
11871impl ChannelConfig for MatrixConfig {
11872    fn name() -> &'static str {
11873        "Matrix"
11874    }
11875    fn desc() -> &'static str {
11876        "self-hosted chat"
11877    }
11878}
11879
11880#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11881#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11882#[prefix = "channels.signal"]
11883pub struct SignalConfig {
11884    /// Whether this channel is active. The runtime only loads channels whose
11885    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11886    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11887    /// live before the rest of its config is filled in.
11888    #[tab(Behavior)]
11889    #[serde(default)]
11890    pub enabled: bool,
11891    /// Base URL for the signal-cli HTTP daemon (e.g. `"http://127.0.0.1:8686"`).
11892    #[tab(Connection)]
11893    pub http_url: String,
11894    /// E.164 phone number of the signal-cli account (e.g. "+1234567890").
11895    #[tab(Connection)]
11896    pub account: String,
11897    /// Group IDs to filter messages. Empty = accept all messages (DMs and
11898    /// groups). When non-empty, only messages from listed groups are
11899    /// accepted (DMs are still accepted unless `dm_only` flips the policy
11900    /// to DMs-only). Migrated from the legacy `group_id` singular field.
11901    #[tab(Advanced)]
11902    #[serde(default)]
11903    pub group_ids: Vec<String>,
11904    /// When true, only accept direct messages and ignore all group traffic.
11905    /// Mutually exclusive with `group_ids` (which is ignored when this is
11906    /// set). Migrated from the legacy `group_id = "dm"` sentinel.
11907    #[tab(Advanced)]
11908    #[serde(default)]
11909    pub dm_only: bool,
11910    /// Skip messages that are attachment-only (no text body).
11911    #[tab(Advanced)]
11912    #[serde(default)]
11913    pub ignore_attachments: bool,
11914    /// Skip incoming story messages.
11915    #[tab(Advanced)]
11916    #[serde(default)]
11917    pub ignore_stories: bool,
11918    /// Per-channel proxy URL (http, https, socks5, socks5h).
11919    /// Overrides the global `[proxy]` setting for this channel only.
11920    #[tab(Advanced)]
11921    #[serde(default)]
11922    pub proxy_url: Option<String>,
11923    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
11924    #[tab(Behavior)]
11925    #[serde(default = "default_channel_approval_timeout_secs")]
11926    pub approval_timeout_secs: u64,
11927
11928    /// Tools excluded from this channel's tool spec. When set, these tools
11929    /// are not exposed to the model when responding via this channel.
11930    #[tab(Behavior)]
11931    #[serde(default)]
11932    pub excluded_tools: Vec<String>,
11933    /// Per-(channel, recipient) outbound pacing floor in seconds.
11934    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
11935    #[serde(default)]
11936    pub reply_min_interval_secs: u64,
11937    /// Per-(channel, recipient) outbound pacing queue depth.
11938    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
11939    /// and this value is `0`, the pacing wrapper substitutes
11940    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
11941    /// newest send is dropped and a `WARN` is logged.
11942    #[serde(default)]
11943    pub reply_queue_depth_max: u16,
11944}
11945
11946impl ChannelConfig for SignalConfig {
11947    fn name() -> &'static str {
11948        "Signal"
11949    }
11950    fn desc() -> &'static str {
11951        "An open-source, encrypted messaging service"
11952    }
11953}
11954
11955/// WhatsApp Web usage mode.
11956///
11957/// `Personal` treats the account as a personal phone — the bot only responds to
11958/// incoming messages that pass the DM/group/self-chat policy filters.
11959/// `Business` (default) responds to all incoming messages, subject only to the
11960/// `allowed_numbers` allowlist.
11961#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
11962#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11963#[serde(rename_all = "snake_case")]
11964pub enum WhatsAppWebMode {
11965    /// Respond to all messages passing the allowlist (default).
11966    #[default]
11967    Business,
11968    /// Apply per-chat-type policies (dm_policy, group_policy, self_chat_mode).
11969    Personal,
11970}
11971
11972/// Policy for a particular WhatsApp chat type (DMs or groups) when
11973/// `mode = "personal"`.
11974#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
11975#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11976#[serde(rename_all = "snake_case")]
11977pub enum WhatsAppChatPolicy {
11978    /// Only respond to senders on the `allowed_numbers` list (default).
11979    #[default]
11980    Allowlist,
11981    /// Ignore all messages in this chat type.
11982    Ignore,
11983    /// Respond to every message regardless of allowlist.
11984    All,
11985}
11986
11987/// WhatsApp channel configuration (Cloud API or Web mode).
11988///
11989/// Set `phone_number_id` for Cloud API mode, or `session_path` for Web mode.
11990#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
11991#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
11992#[prefix = "channels.whatsapp"]
11993pub struct WhatsAppConfig {
11994    /// Whether this channel is active. The runtime only loads channels whose
11995    /// `enabled = true`. Default: `false` so an operator who pastes a partial
11996    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
11997    /// live before the rest of its config is filled in.
11998    #[tab(Behavior)]
11999    #[serde(default)]
12000    pub enabled: bool,
12001    /// Access token from Meta Business Suite (Cloud API mode)
12002    #[serde(default)]
12003    #[secret]
12004    #[tab(Connection)]
12005    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12006    pub access_token: Option<String>,
12007    /// Phone number ID from Meta Business API (Cloud API mode)
12008    #[tab(Connection)]
12009    #[serde(default)]
12010    pub phone_number_id: Option<String>,
12011    /// Webhook verify token (you define this, Meta sends it back for verification)
12012    /// Only used in Cloud API mode
12013    #[serde(default)]
12014    #[secret]
12015    #[tab(Connection)]
12016    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12017    pub verify_token: Option<String>,
12018    /// App secret from Meta Business Suite (for webhook signature verification)
12019    /// Can also be set via `ZEROCLAW_WHATSAPP_APP_SECRET` environment variable
12020    /// Only used in Cloud API mode
12021    #[serde(default)]
12022    #[secret]
12023    #[tab(Connection)]
12024    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12025    pub app_secret: Option<String>,
12026    /// Session database path for WhatsApp Web client (Web mode)
12027    /// When set, enables native WhatsApp Web mode with wa-rs
12028    #[tab(Connection)]
12029    #[serde(default)]
12030    pub session_path: Option<String>,
12031    /// Phone number for pair code linking (Web mode, optional)
12032    /// Format: country code + number (e.g., "15551234567")
12033    /// If not set, QR code pairing will be used
12034    #[tab(Connection)]
12035    #[serde(default)]
12036    pub pair_phone: Option<String>,
12037    /// Custom pair code for linking (Web mode, optional)
12038    /// Leave empty to let WhatsApp generate one
12039    #[tab(Connection)]
12040    #[serde(default)]
12041    pub pair_code: Option<String>,
12042    /// Override the WhatsApp Web WebSocket URL (Web mode, optional). Used
12043    /// by integration tests and proxy setups; leave unset to use the
12044    /// default endpoint that ships with `wa-rs`.
12045    #[tab(Connection)]
12046    #[serde(default)]
12047    pub ws_url: Option<String>,
12048    /// When true, only respond to messages that @-mention the bot in groups (Web mode only).
12049    /// Direct messages are always processed.
12050    /// Bot identity is resolved from the wa-rs device at runtime; `pair_phone` seeds it on first connect.
12051    #[tab(Behavior)]
12052    #[serde(default)]
12053    pub mention_only: bool,
12054    /// Cancel an in-flight response from this channel sender when a newer
12055    /// WhatsApp message arrives. Default: `false`.
12056    #[serde(default)]
12057    pub interrupt_on_new_message: bool,
12058    /// Usage mode for WhatsApp Web: "business" (default) or "personal".
12059    /// In personal mode the bot applies dm_policy, group_policy, and
12060    /// self_chat_mode to decide which chats to respond in.
12061    #[tab(Advanced)]
12062    #[serde(default)]
12063    pub mode: WhatsAppWebMode,
12064    /// Policy for direct messages when mode = "personal".
12065    /// "allowlist" (default) | "ignore" | "all".
12066    #[tab(Advanced)]
12067    #[serde(default)]
12068    pub dm_policy: WhatsAppChatPolicy,
12069    /// Policy for group chats when mode = "personal".
12070    /// "allowlist" (default) | "ignore" | "all".
12071    #[tab(Advanced)]
12072    #[serde(default)]
12073    pub group_policy: WhatsAppChatPolicy,
12074    /// When true and mode = "personal", always respond to messages in the
12075    /// user's own self-chat (Notes to Self). Defaults to false.
12076    #[tab(Advanced)]
12077    #[serde(default)]
12078    pub self_chat_mode: bool,
12079    /// Regex patterns for DM mention gating (case-insensitive).
12080    /// When non-empty, only direct messages matching at least one pattern are
12081    /// processed; matched fragments are stripped from the forwarded content.
12082    /// Example: `["@?ZeroClaw", "\\+?15555550123"]`
12083    #[tab(Advanced)]
12084    #[serde(default)]
12085    pub dm_mention_patterns: Vec<String>,
12086    /// Regex patterns for group-chat mention gating (case-insensitive).
12087    /// When non-empty, only group messages matching at least one pattern are
12088    /// processed; matched fragments are stripped from the forwarded content.
12089    /// Example: `["@?ZeroClaw", "\\+?15555550123"]`
12090    #[tab(Advanced)]
12091    #[serde(default)]
12092    pub group_mention_patterns: Vec<String>,
12093    /// Per-channel proxy URL (http, https, socks5, socks5h).
12094    /// Overrides the global `[proxy]` setting for this channel only.
12095    #[tab(Advanced)]
12096    #[serde(default)]
12097    pub proxy_url: Option<String>,
12098    /// Seconds to wait for operator approval on `always_ask` tools before auto-denying.
12099    #[tab(Behavior)]
12100    #[serde(default = "default_channel_approval_timeout_secs")]
12101    pub approval_timeout_secs: u64,
12102
12103    /// Tools excluded from this channel's tool spec. When set, these tools
12104    /// are not exposed to the model when responding via this channel.
12105    #[tab(Behavior)]
12106    #[serde(default)]
12107    pub excluded_tools: Vec<String>,
12108    /// Per-(channel, recipient) outbound pacing floor in seconds.
12109    /// Range: `0..=REPLY_MIN_INTERVAL_MAX_SECS` (0 disables).
12110    #[serde(default)]
12111    pub reply_min_interval_secs: u64,
12112    /// Per-(channel, recipient) outbound pacing queue depth.
12113    /// Range: `0..=REPLY_QUEUE_DEPTH_CEILING`. When `reply_min_interval_secs > 0`
12114    /// and this value is `0`, the pacing wrapper substitutes
12115    /// `DEFAULT_REPLY_QUEUE_DEPTH` (16). When the queue is full, the
12116    /// newest send is dropped and a `WARN` is logged.
12117    #[serde(default)]
12118    pub reply_queue_depth_max: u16,
12119}
12120
12121impl ChannelConfig for WhatsAppConfig {
12122    fn name() -> &'static str {
12123        "WhatsApp"
12124    }
12125    fn desc() -> &'static str {
12126        "Business Cloud API"
12127    }
12128}
12129
12130impl_reply_pacing!(
12131    TelegramConfig,
12132    DiscordConfig,
12133    SlackConfig,
12134    MattermostConfig,
12135    WebhookConfig,
12136    IMessageConfig,
12137    MatrixConfig,
12138    SignalConfig,
12139    WhatsAppConfig,
12140);
12141
12142#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12143#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12144#[prefix = "channels.linq"]
12145pub struct LinqConfig {
12146    /// Whether this channel is active. The runtime only loads channels whose
12147    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12148    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12149    /// live before the rest of its config is filled in.
12150    #[tab(Behavior)]
12151    #[serde(default)]
12152    pub enabled: bool,
12153    /// Linq Partner API token (Bearer auth)
12154    #[secret]
12155    #[tab(Connection)]
12156    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12157    pub api_token: String,
12158    /// Phone number to send from (E.164 format)
12159    #[tab(Advanced)]
12160    pub from_phone: String,
12161    /// Webhook signing secret for signature verification
12162    #[serde(default)]
12163    #[secret]
12164    #[tab(Connection)]
12165    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12166    pub signing_secret: Option<String>,
12167
12168    /// Tools excluded from this channel's tool spec. When set, these tools
12169    /// are not exposed to the model when responding via this channel.
12170    #[tab(Behavior)]
12171    #[serde(default)]
12172    pub excluded_tools: Vec<String>,
12173}
12174
12175impl ChannelConfig for LinqConfig {
12176    fn name() -> &'static str {
12177        "Linq"
12178    }
12179    fn desc() -> &'static str {
12180        "iMessage/RCS/SMS via Linq API"
12181    }
12182}
12183
12184/// WATI WhatsApp Business API channel configuration.
12185#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12186#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12187#[prefix = "channels.wati"]
12188pub struct WatiConfig {
12189    /// Whether this channel is active. The runtime only loads channels whose
12190    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12191    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12192    /// live before the rest of its config is filled in.
12193    #[tab(Behavior)]
12194    #[serde(default)]
12195    pub enabled: bool,
12196    /// WATI API token (Bearer auth).
12197    #[secret]
12198    #[tab(Connection)]
12199    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12200    pub api_token: String,
12201    /// WATI API base URL (default: <https://live-mt-server.wati.io>).
12202    #[tab(Advanced)]
12203    #[serde(default = "default_wati_api_url")]
12204    pub api_url: String,
12205    /// Tenant ID for multi-channel setups (optional).
12206    #[tab(Advanced)]
12207    #[serde(default)]
12208    pub tenant_id: Option<String>,
12209    /// Per-channel proxy URL (http, https, socks5, socks5h).
12210    /// Overrides the global `[proxy]` setting for this channel only.
12211    #[tab(Advanced)]
12212    #[serde(default)]
12213    pub proxy_url: Option<String>,
12214
12215    /// Tools excluded from this channel's tool spec. When set, these tools
12216    /// are not exposed to the model when responding via this channel.
12217    #[tab(Behavior)]
12218    #[serde(default)]
12219    pub excluded_tools: Vec<String>,
12220}
12221
12222fn default_wati_api_url() -> String {
12223    "https://live-mt-server.wati.io".to_string()
12224}
12225
12226impl ChannelConfig for WatiConfig {
12227    fn name() -> &'static str {
12228        "WATI"
12229    }
12230    fn desc() -> &'static str {
12231        "WhatsApp via WATI Business API"
12232    }
12233}
12234
12235/// Nextcloud Talk bot configuration (webhook receive + OCS send API).
12236#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12237#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12238#[prefix = "channels.nextcloud_talk"]
12239pub struct NextcloudTalkConfig {
12240    /// Whether this channel is active. The runtime only loads channels whose
12241    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12242    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12243    /// live before the rest of its config is filled in.
12244    #[tab(Behavior)]
12245    #[serde(default)]
12246    pub enabled: bool,
12247    /// Nextcloud base URL (e.g. `"https://cloud.example.com"`).
12248    #[tab(Connection)]
12249    pub base_url: String,
12250    /// Bot app token used for OCS API bearer auth.
12251    #[secret]
12252    #[tab(Connection)]
12253    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12254    pub app_token: String,
12255    /// Shared secret for webhook signature verification.
12256    ///
12257    /// Can also be set via `ZEROCLAW_NEXTCLOUD_TALK_WEBHOOK_SECRET`.
12258    #[serde(default)]
12259    #[secret]
12260    #[tab(Connection)]
12261    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12262    pub webhook_secret: Option<String>,
12263    /// Per-channel proxy URL (http, https, socks5, socks5h).
12264    /// Overrides the global `[proxy]` setting for this channel only.
12265    #[tab(Advanced)]
12266    #[serde(default)]
12267    pub proxy_url: Option<String>,
12268    /// Display name of the bot in Nextcloud Talk (e.g. "zeroclaw").
12269    /// Used to filter out the bot's own messages and prevent feedback loops.
12270    /// If not set, defaults to an empty string (no self-message filtering by name).
12271    #[tab(Advanced)]
12272    #[serde(default)]
12273    pub bot_name: Option<String>,
12274    /// Tools excluded from this channel's tool spec. When set, these tools
12275    /// are not exposed to the model when responding via this channel.
12276    #[tab(Behavior)]
12277    #[serde(default)]
12278    pub excluded_tools: Vec<String>,
12279    /// Controls whether and how streaming draft updates are delivered.
12280    ///
12281    /// - `"off"` (default): responses are sent as a single final message.
12282    /// - `"partial"`: a placeholder is posted first and edited incrementally
12283    ///   as tokens arrive, making long responses visible in real time.
12284    #[tab(Behavior)]
12285    #[serde(default)]
12286    pub stream_mode: StreamMode,
12287    /// Minimum interval in milliseconds between consecutive OCS edit calls per
12288    /// room when `stream_mode = "partial"`. Default: 1000 ms.
12289    #[tab(Behavior)]
12290    #[serde(default = "default_draft_update_interval_ms")]
12291    pub draft_update_interval_ms: u64,
12292}
12293
12294impl ChannelConfig for NextcloudTalkConfig {
12295    fn name() -> &'static str {
12296        "NextCloud Talk"
12297    }
12298    fn desc() -> &'static str {
12299        "NextCloud Talk platform"
12300    }
12301}
12302
12303impl WhatsAppConfig {
12304    /// Detect which backend to use based on config fields.
12305    /// Returns "cloud" if phone_number_id is set, "web" if session_path is set.
12306    pub fn backend_type(&self) -> &'static str {
12307        if self.phone_number_id.is_some() {
12308            "cloud"
12309        } else if self.session_path.is_some() {
12310            "web"
12311        } else {
12312            // Default to Cloud API for backward compatibility
12313            "cloud"
12314        }
12315    }
12316
12317    /// Check if this is a valid Cloud API config
12318    pub fn is_cloud_config(&self) -> bool {
12319        self.phone_number_id.is_some() && self.access_token.is_some() && self.verify_token.is_some()
12320    }
12321
12322    /// Check if this is a valid Web config
12323    pub fn is_web_config(&self) -> bool {
12324        self.session_path.is_some()
12325    }
12326
12327    /// Returns true when both Cloud and Web selectors are present.
12328    ///
12329    /// Runtime currently prefers Cloud mode in this case for backward compatibility.
12330    pub fn is_ambiguous_config(&self) -> bool {
12331        self.phone_number_id.is_some() && self.session_path.is_some()
12332    }
12333}
12334
12335/// MQTT channel configuration (SOP listener).
12336///
12337/// Subscribes to MQTT topics and dispatches incoming messages
12338/// to the SOP engine for processing.
12339#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12340#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12341#[prefix = "channels.mqtt"]
12342pub struct MqttConfig {
12343    /// Whether this channel is active. The runtime only loads channels whose
12344    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12345    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12346    /// live before the rest of its config is filled in.
12347    #[tab(Behavior)]
12348    #[serde(default)]
12349    pub enabled: bool,
12350    /// MQTT broker URL (e.g., `mqtt://localhost:1883` or `mqtts://broker.example.com:8883`).
12351    /// Use `mqtt://` for plain connections or `mqtts://` for TLS.
12352    #[tab(Connection)]
12353    pub broker_url: String,
12354    /// MQTT client ID (must be unique per broker).
12355    #[tab(Advanced)]
12356    pub client_id: String,
12357    /// Topics to subscribe to (e.g., `sensors/#`, `alerts/+/critical`).
12358    /// At least one topic is required.
12359    #[tab(Advanced)]
12360    #[serde(default)]
12361    pub topics: Vec<String>,
12362    /// MQTT QoS level (0 = at-most-once, 1 = at-least-once, 2 = exactly-once). Default: 1.
12363    #[tab(Advanced)]
12364    #[serde(default = "default_mqtt_qos")]
12365    pub qos: u8,
12366    /// Username for authentication (optional).
12367    #[tab(Connection)]
12368    pub username: Option<String>,
12369    /// Password for authentication (optional).
12370    #[secret]
12371    #[tab(Connection)]
12372    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12373    pub password: Option<String>,
12374    /// Enable TLS encryption. Must match the broker_url scheme:
12375    /// - `mqtt://` → `use_tls: false`
12376    /// - `mqtts://` → `use_tls: true`
12377    #[tab(Advanced)]
12378    #[serde(default)]
12379    pub use_tls: bool,
12380    /// Keep-alive interval in seconds (default: 30). Prevents broker disconnect on idle.
12381    #[tab(Advanced)]
12382    #[serde(default = "default_mqtt_keep_alive_secs")]
12383    pub keep_alive_secs: u64,
12384
12385    /// Tools excluded from this channel's tool spec. When set, these tools
12386    /// are not exposed to the model when responding via this channel.
12387    #[tab(Behavior)]
12388    #[serde(default)]
12389    pub excluded_tools: Vec<String>,
12390}
12391
12392impl MqttConfig {
12393    /// Validate the MQTT configuration.
12394    ///
12395    /// Checks:
12396    /// - QoS is 0, 1, or 2
12397    /// - broker_url uses valid scheme (`mqtt://` or `mqtts://`)
12398    /// - `use_tls` flag matches broker_url scheme
12399    /// - At least one topic is configured
12400    /// - client_id is non-empty
12401    pub fn validate(&self) -> anyhow::Result<()> {
12402        // QoS validation
12403        if self.qos > 2 {
12404            anyhow::bail!("qos must be 0, 1, or 2, got {}", self.qos);
12405        }
12406
12407        // Broker URL validation
12408        let is_tls_scheme = self.broker_url.starts_with("mqtts://");
12409        let is_mqtt_scheme = self.broker_url.starts_with("mqtt://");
12410
12411        if !is_tls_scheme && !is_mqtt_scheme {
12412            anyhow::bail!(
12413                "broker_url must start with 'mqtt://' or 'mqtts://', got: {}",
12414                self.broker_url
12415            );
12416        }
12417
12418        // TLS flag validation
12419        if is_mqtt_scheme && self.use_tls {
12420            anyhow::bail!("use_tls is true but broker_url uses 'mqtt://' (not 'mqtts://')");
12421        }
12422
12423        if is_tls_scheme && !self.use_tls {
12424            anyhow::bail!(
12425                "use_tls is false but broker_url uses 'mqtts://' (requires use_tls: true)"
12426            );
12427        }
12428
12429        // Topics validation
12430        if self.topics.is_empty() {
12431            anyhow::bail!("at least one topic must be configured");
12432        }
12433
12434        // Client ID validation
12435        if self.client_id.is_empty() {
12436            validation_bail!(
12437                RequiredFieldEmpty,
12438                "client_id",
12439                "client_id must not be empty"
12440            );
12441        }
12442
12443        Ok(())
12444    }
12445}
12446
12447impl ChannelConfig for MqttConfig {
12448    fn name() -> &'static str {
12449        "MQTT"
12450    }
12451    fn desc() -> &'static str {
12452        "MQTT SOP Listener"
12453    }
12454}
12455
12456fn default_mqtt_qos() -> u8 {
12457    1
12458}
12459
12460fn default_mqtt_keep_alive_secs() -> u64 {
12461    30
12462}
12463
12464/// Generic AMQP 0-9-1 channel configuration (RabbitMQ, Fedora Messaging, etc.).
12465///
12466/// Subscribes to an exchange via routing keys and lifts each delivery into an
12467/// inbound `ChannelMessage`. The mapping from a JSON delivery body to message
12468/// fields is config-driven (`content_template`, `thread_id_field`) so a new
12469/// source — Anitya, an internal bus, anything publishing JSON — is onboarded by
12470/// configuration rather than code.
12471#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12472#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12473#[prefix = "channels.amqp"]
12474pub struct AmqpConfig {
12475    /// Whether this channel is active. The runtime only loads channels whose
12476    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12477    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12478    /// live before the rest of its config is filled in.
12479    #[tab(Behavior)]
12480    #[serde(default)]
12481    pub enabled: bool,
12482    /// AMQP broker URL. Use `amqp://` for plain or `amqps://` for TLS
12483    /// (e.g. `amqps://fedora:@rabbitmq.fedoraproject.org/%2Fpublic_pubsub`).
12484    #[tab(Connection)]
12485    pub amqp_url: String,
12486    /// Exchange to bind the consumer queue to (e.g. `amq.topic`).
12487    #[tab(Advanced)]
12488    pub exchange: String,
12489    /// Routing keys to bind. Scope these to the topics of interest; binding
12490    /// `#` consumes the entire exchange and is almost never what you want.
12491    #[tab(Advanced)]
12492    #[serde(default)]
12493    pub routing_keys: Vec<String>,
12494    /// Queue name. Leave unset for a server-generated, transient,
12495    /// auto-deleted, exclusive queue. Set a stable name (UUID recommended)
12496    /// only when durable delivery across reconnects is required.
12497    #[tab(Advanced)]
12498    pub queue: Option<String>,
12499    /// Path to the CA certificate bundle for `amqps://` connections.
12500    #[tab(Connection)]
12501    pub ca_cert: Option<PathBuf>,
12502    /// Path to the client certificate for broker mutual-TLS auth
12503    /// (Fedora Messaging requires a client cert).
12504    #[tab(Connection)]
12505    pub client_cert: Option<PathBuf>,
12506    /// Path to the client private key matching `client_cert`.
12507    #[tab(Connection)]
12508    pub client_key: Option<PathBuf>,
12509    /// Value placed in `ChannelMessage.sender` for every delivery from this
12510    /// source (e.g. `anitya`). Lets the orchestrator's self-loop guard and
12511    /// per-channel routing identify the origin.
12512    #[tab(Behavior)]
12513    #[serde(default = "default_amqp_sender_label")]
12514    pub sender_label: String,
12515    /// Template for the inbound message content. `{field}` placeholders are
12516    /// interpolated from the JSON delivery body's top-level keys. When empty,
12517    /// the raw delivery body is used verbatim.
12518    #[tab(Behavior)]
12519    #[serde(default)]
12520    pub content_template: String,
12521    /// Dotted path into the JSON delivery body whose value becomes the
12522    /// message `thread_ts`, correlating replies to the originating event
12523    /// (e.g. `message.project.name`). Empty disables threading.
12524    #[tab(Behavior)]
12525    #[serde(default)]
12526    pub thread_id_field: String,
12527    /// Acknowledgement mode. When `true` (default), deliveries are acked only
12528    /// after the message is durably handed to the agent loop, giving
12529    /// at-least-once semantics: a crash before hand-off redelivers the event.
12530    /// Set `false` for at-most-once (broker acks on dispatch), which silently
12531    /// drops in-flight events on crash and is only appropriate for
12532    /// non-side-effecting, drop-on-overload consumers.
12533    #[tab(Behavior)]
12534    #[serde(default = "default_amqp_durable_ack")]
12535    pub durable_ack: bool,
12536    /// Tools excluded from this channel's tool spec. When set, these tools
12537    /// are not exposed to the model when responding via this channel.
12538    #[tab(Behavior)]
12539    #[serde(default)]
12540    pub excluded_tools: Vec<String>,
12541}
12542
12543impl AmqpConfig {
12544    /// Validate the AMQP configuration.
12545    ///
12546    /// Checks:
12547    /// - `amqp_url` uses a valid scheme (`amqp://` or `amqps://`)
12548    /// - `amqps://` connections carry a CA certificate
12549    /// - `client_cert` and `client_key` are supplied together (mutual TLS)
12550    /// - the exchange is non-empty
12551    /// - at least one routing key is bound
12552    pub fn validate(&self) -> anyhow::Result<()> {
12553        let is_tls = self.amqp_url.starts_with("amqps://");
12554        let is_plain = self.amqp_url.starts_with("amqp://");
12555
12556        if !is_tls && !is_plain {
12557            anyhow::bail!(
12558                "amqp_url must start with 'amqp://' or 'amqps://', got: {}",
12559                self.amqp_url
12560            );
12561        }
12562
12563        if is_tls && self.ca_cert.is_none() {
12564            anyhow::bail!("amqps:// requires ca_cert to verify the broker");
12565        }
12566
12567        match (self.client_cert.is_some(), self.client_key.is_some()) {
12568            (true, false) => {
12569                anyhow::bail!(
12570                    "client_cert is set but client_key is missing (both are required for mutual TLS)"
12571                )
12572            }
12573            (false, true) => {
12574                anyhow::bail!(
12575                    "client_key is set but client_cert is missing (both are required for mutual TLS)"
12576                )
12577            }
12578            _ => {}
12579        }
12580
12581        if self.exchange.is_empty() {
12582            validation_bail!(RequiredFieldEmpty, "exchange", "exchange must not be empty");
12583        }
12584
12585        if self.routing_keys.is_empty() {
12586            anyhow::bail!("at least one routing key must be configured");
12587        }
12588
12589        Ok(())
12590    }
12591}
12592
12593impl ChannelConfig for AmqpConfig {
12594    fn name() -> &'static str {
12595        "AMQP"
12596    }
12597    fn desc() -> &'static str {
12598        "AMQP topic consumer"
12599    }
12600}
12601
12602fn default_amqp_sender_label() -> String {
12603    "amqp".to_string()
12604}
12605
12606fn default_amqp_durable_ack() -> bool {
12607    true
12608}
12609
12610/// IRC channel configuration.
12611#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12612#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12613#[prefix = "channels.irc"]
12614pub struct IrcConfig {
12615    /// Whether this channel is active. The runtime only loads channels whose
12616    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12617    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12618    /// live before the rest of its config is filled in.
12619    #[tab(Behavior)]
12620    #[serde(default)]
12621    pub enabled: bool,
12622    /// IRC server hostname
12623    #[tab(Advanced)]
12624    pub server: String,
12625    /// IRC server port (default: 6697 for TLS)
12626    #[tab(Advanced)]
12627    #[serde(default = "default_irc_port")]
12628    pub port: u16,
12629    /// Bot nickname
12630    #[tab(Advanced)]
12631    pub nickname: String,
12632    /// Username (defaults to nickname if not set)
12633    #[tab(Connection)]
12634    pub username: Option<String>,
12635    /// Channels to join on connect
12636    #[tab(Advanced)]
12637    #[serde(default)]
12638    pub channels: Vec<String>,
12639    /// Server password (for bouncers like ZNC)
12640    #[secret]
12641    #[tab(Connection)]
12642    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12643    pub server_password: Option<String>,
12644    /// NickServ IDENTIFY password
12645    #[secret]
12646    #[tab(Connection)]
12647    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12648    pub nickserv_password: Option<String>,
12649    /// SASL PLAIN password (IRCv3)
12650    #[secret]
12651    #[tab(Connection)]
12652    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12653    pub sasl_password: Option<String>,
12654    /// Verify TLS certificate (default: true)
12655    #[tab(Advanced)]
12656    pub verify_tls: Option<bool>,
12657    /// When true, only respond to messages that mention the bot.
12658    /// Other messages in the channel are silently ignored.
12659    #[tab(Behavior)]
12660    #[serde(default)]
12661    pub mention_only: bool,
12662
12663    /// Tools excluded from this channel's tool spec. When set, these tools
12664    /// are not exposed to the model when responding via this channel.
12665    #[tab(Behavior)]
12666    #[serde(default)]
12667    pub excluded_tools: Vec<String>,
12668}
12669
12670impl ChannelConfig for IrcConfig {
12671    fn name() -> &'static str {
12672        "IRC"
12673    }
12674    fn desc() -> &'static str {
12675        "IRC over TLS"
12676    }
12677}
12678
12679/// Twitch chat channel configuration. A thin adapter over IRC
12680/// (`irc.chat.twitch.tv:6697` over TLS); see the `twitch` channel module.
12681#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12682#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12683#[prefix = "channels.twitch"]
12684pub struct TwitchConfig {
12685    /// Whether this channel is active. The runtime only loads channels whose
12686    /// `enabled = true`. Default: `false`.
12687    #[tab(Behavior)]
12688    #[serde(default)]
12689    pub enabled: bool,
12690    /// Twitch login name of the bot account (case-insensitive — lowercased
12691    /// before send).
12692    #[tab(Connection)]
12693    pub bot_username: String,
12694    /// Twitch OAuth user-access token. The `oauth:` prefix is added
12695    /// automatically if missing, so both `"oauth:abcdef"` and `"abcdef"`
12696    /// work. Mint via <https://twitchapps.com/tmi/> or the Twitch CLI.
12697    #[secret]
12698    #[tab(Connection)]
12699    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12700    pub oauth_token: String,
12701    /// Twitch channels to join. Each entry receives a `#` prefix if missing
12702    /// and is lowercased before send (Twitch channel names are
12703    /// case-insensitive). E.g. `["mychannel", "#anotherchannel"]`.
12704    #[tab(Advanced)]
12705    #[serde(default)]
12706    pub channels: Vec<String>,
12707    /// When true, only respond to messages that mention the bot's login
12708    /// name. Default: `false`.
12709    #[tab(Behavior)]
12710    #[serde(default)]
12711    pub mention_only: bool,
12712}
12713
12714impl ChannelConfig for TwitchConfig {
12715    fn name() -> &'static str {
12716        "Twitch"
12717    }
12718    fn desc() -> &'static str {
12719        "Twitch chat (IRC)"
12720    }
12721}
12722
12723fn default_irc_port() -> u16 {
12724    6697
12725}
12726
12727/// How ZeroClaw receives events from Feishu / Lark.
12728///
12729/// - `websocket` (default) — persistent WSS long-connection; no public URL required.
12730/// - `webhook`             — HTTP callback server; requires a public HTTPS endpoint.
12731#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
12732#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12733#[serde(rename_all = "lowercase")]
12734pub enum LarkReceiveMode {
12735    #[default]
12736    Websocket,
12737    Webhook,
12738}
12739
12740/// Lark/Feishu configuration for messaging integration.
12741/// Lark is the international version; Feishu is the Chinese version.
12742#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12743#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12744#[prefix = "channels.lark"]
12745pub struct LarkConfig {
12746    /// Whether this channel is active. The runtime only loads channels whose
12747    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12748    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12749    /// live before the rest of its config is filled in.
12750    #[tab(Behavior)]
12751    #[serde(default)]
12752    pub enabled: bool,
12753    /// App ID from Lark/Feishu developer console
12754    #[tab(Connection)]
12755    pub app_id: String,
12756    /// App Secret from Lark/Feishu developer console
12757    #[secret]
12758    #[tab(Connection)]
12759    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12760    pub app_secret: String,
12761    /// Encrypt key for webhook message decryption (optional)
12762    #[serde(default)]
12763    #[secret]
12764    #[tab(Connection)]
12765    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12766    pub encrypt_key: Option<String>,
12767    /// Verification token for webhook validation (optional)
12768    #[serde(default)]
12769    #[secret]
12770    #[tab(Connection)]
12771    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12772    pub verification_token: Option<String>,
12773    /// When true, only respond to messages that @-mention the bot in groups.
12774    /// Direct messages are always processed.
12775    #[tab(Behavior)]
12776    #[serde(default)]
12777    pub mention_only: bool,
12778    /// Whether to use the Feishu (Chinese) endpoint instead of Lark (International)
12779    #[tab(Advanced)]
12780    #[serde(default)]
12781    pub use_feishu: bool,
12782    /// Event receive mode: "websocket" (default) or "webhook"
12783    #[tab(Advanced)]
12784    #[serde(default)]
12785    pub receive_mode: LarkReceiveMode,
12786    /// HTTP port for webhook mode only. Must be set when receive_mode = "webhook".
12787    /// Not required (and ignored) for websocket mode.
12788    #[tab(Advanced)]
12789    #[serde(default)]
12790    pub port: Option<u16>,
12791    /// Per-channel proxy URL (http, https, socks5, socks5h).
12792    /// Overrides the global `[proxy]` setting for this channel only.
12793    #[tab(Advanced)]
12794    #[serde(default)]
12795    pub proxy_url: Option<String>,
12796
12797    /// Tools excluded from this channel's tool spec. When set, these tools
12798    /// are not exposed to the model when responding via this channel.
12799    #[tab(Behavior)]
12800    #[serde(default)]
12801    pub excluded_tools: Vec<String>,
12802
12803    /// Time in seconds an approval card waits for user response before
12804    /// the runtime auto-denies. Default: 300 (5 minutes).
12805    #[tab(Behavior)]
12806    #[serde(default = "default_channel_approval_timeout_secs")]
12807    pub approval_timeout_secs: u64,
12808    /// When `true`, group-chat sessions key on the sender's open_id, so
12809    /// distinct members of the same group chat don't share conversation
12810    /// context. When `false` (default), all members of a group share one
12811    /// session keyed on chat_id (matches the existing behavior). 1-on-1
12812    /// chats are unaffected (chat_id is already unique per user-bot pair).
12813    #[tab(Behavior)]
12814    #[serde(default)]
12815    pub per_user_session: bool,
12816
12817    /// Streaming mode for the LLM response: `off` (default) routes every
12818    /// response through `send()`; `partial` opens a Feishu interactive
12819    /// card and edits it incrementally via `update_draft` /
12820    /// `finalize_draft`; `multi_message` is rejected for Lark (Feishu has
12821    /// no equivalent surface — falls back to `off` with a warning).
12822    #[tab(Behavior)]
12823    #[serde(default)]
12824    pub stream_mode: StreamMode,
12825
12826    /// Minimum interval between consecutive `update_draft` PATCH calls in
12827    /// milliseconds. Default 1000 ms tunes to Feishu's 5 QPS-per-message
12828    /// edit cap; raise on enterprise plans with higher quotas.
12829    #[tab(Behavior)]
12830    #[serde(default = "default_draft_update_interval_ms")]
12831    pub draft_update_interval_ms: u64,
12832}
12833
12834impl ChannelConfig for LarkConfig {
12835    fn name() -> &'static str {
12836        "Lark"
12837    }
12838    fn desc() -> &'static str {
12839        "Lark Bot"
12840    }
12841}
12842
12843/// DM (1:1 chat) access policy for the LINE channel.
12844#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
12845#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12846#[serde(rename_all = "lowercase")]
12847pub enum LineDmPolicy {
12848    /// Respond to every DM regardless of who sent it.
12849    Open,
12850    /// Require a one-time `/bind <code>` handshake before responding (default).
12851    /// ZeroClaw prints the bind code on startup; send it once to unlock access.
12852    #[default]
12853    Pairing,
12854    /// Respond only to LINE user IDs listed in `allowed_users`.
12855    Allowlist,
12856}
12857
12858/// Group / multi-person chat policy for the LINE channel.
12859#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
12860#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12861#[serde(rename_all = "lowercase")]
12862pub enum LineGroupPolicy {
12863    /// Respond to every message in group/room chats.
12864    Open,
12865    /// Respond only when the bot is @mentioned (default).
12866    #[default]
12867    Mention,
12868    /// Ignore all messages in group/room chats.
12869    Disabled,
12870}
12871
12872/// LINE Messaging API channel configuration.
12873#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
12874#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12875#[prefix = "channels.line"]
12876pub struct LineConfig {
12877    /// Whether this channel is active. The runtime only loads channels whose
12878    /// `enabled = true`. Default: `false` so an operator who pastes a partial
12879    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
12880    /// live before the rest of its config is filled in.
12881    #[tab(Behavior)]
12882    #[serde(default)]
12883    pub enabled: bool,
12884    /// Long-lived channel access token (from LINE Developers Console).
12885    /// Used for both the Reply API and the Push API fallback.
12886    /// Falls back to the `LINE_CHANNEL_ACCESS_TOKEN` environment variable if empty.
12887    #[serde(default)]
12888    #[secret]
12889    #[tab(Connection)]
12890    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12891    pub channel_access_token: String,
12892    /// Channel secret (from LINE Developers Console).
12893    /// Used to verify the `X-Line-Signature` header on incoming webhooks.
12894    /// Falls back to the `LINE_CHANNEL_SECRET` environment variable if empty.
12895    #[serde(default)]
12896    #[secret]
12897    #[tab(Connection)]
12898    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
12899    pub channel_secret: String,
12900    /// DM (1:1 chat) access policy. Default: `pairing`.
12901    ///
12902    /// - `open`: respond to everyone
12903    /// - `pairing`: require one-time `/bind <code>` handshake on first contact
12904    /// - `allowlist`: respond only to user IDs listed in `allowed_users`
12905    #[tab(Advanced)]
12906    #[serde(default)]
12907    pub dm_policy: LineDmPolicy,
12908    /// Group / multi-person chat policy. Default: `mention`.
12909    ///
12910    /// - `open`: respond to every message
12911    /// - `mention`: respond only when @mentioned
12912    /// - `disabled`: ignore all group messages
12913    #[tab(Advanced)]
12914    #[serde(default)]
12915    pub group_policy: LineGroupPolicy,
12916    /// TCP port the embedded webhook server listens on. Default: `8443`.
12917    #[tab(Advanced)]
12918    #[serde(default = "default_line_webhook_port")]
12919    pub webhook_port: u16,
12920    /// Per-channel proxy URL (http, https, socks5, socks5h).
12921    /// Overrides the global `[proxy]` setting for this channel only.
12922    #[tab(Advanced)]
12923    #[serde(default)]
12924    pub proxy_url: Option<String>,
12925
12926    /// Tools excluded from this channel's tool spec. When set, these tools
12927    /// are not exposed to the model when responding via this channel.
12928    #[tab(Behavior)]
12929    #[serde(default)]
12930    pub excluded_tools: Vec<String>,
12931}
12932
12933fn default_line_webhook_port() -> u16 {
12934    8443
12935}
12936
12937impl ChannelConfig for LineConfig {
12938    fn name() -> &'static str {
12939        "LINE"
12940    }
12941    fn desc() -> &'static str {
12942        "connect your LINE bot"
12943    }
12944}
12945
12946// ── Security Config ─────────────────────────────────────────────────
12947
12948/// Security configuration for audit logging, OTP, e-stop, IAM/SSO, and WebAuthn.
12949///
12950/// Sandbox backend and resource limits live on per-agent risk profiles
12951/// (see `RiskProfileConfig::sandbox_*` and `RiskProfileConfig::max_*`); the
12952/// runtime resolves them via `Config::active_risk_profile(agent_alias)`.
12953#[derive(Debug, Clone, Serialize, Deserialize, Default, Configurable)]
12954#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12955#[prefix = "security"]
12956pub struct SecurityConfig {
12957    /// Audit logging configuration
12958    #[serde(default)]
12959    #[nested]
12960    pub audit: AuditConfig,
12961
12962    /// OTP gating configuration for sensitive actions/domains.
12963    #[serde(default)]
12964    #[nested]
12965    pub otp: OtpConfig,
12966
12967    /// Emergency-stop state machine configuration.
12968    #[serde(default)]
12969    #[nested]
12970    pub estop: EstopConfig,
12971
12972    /// Nevis IAM integration for SSO/MFA authentication and role-based access.
12973    #[serde(default)]
12974    #[nested]
12975    pub nevis: NevisConfig,
12976
12977    /// WebAuthn / FIDO2 hardware key authentication configuration.
12978    #[serde(default)]
12979    #[nested]
12980    pub webauthn: WebAuthnConfig,
12981}
12982
12983/// WebAuthn / FIDO2 hardware key authentication configuration (`[security.webauthn]`).
12984///
12985/// Enables registration and authentication via hardware security keys
12986/// (YubiKey, SoloKey, etc.) and platform authenticators (Touch ID, Windows Hello).
12987#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
12988#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
12989#[prefix = "security.webauthn"]
12990pub struct WebAuthnConfig {
12991    /// Enable WebAuthn authentication. Default: false.
12992    #[serde(default)]
12993    pub enabled: bool,
12994    /// Relying Party ID (domain name, e.g. "example.com"). Default: "localhost".
12995    #[serde(default = "default_webauthn_rp_id")]
12996    pub rp_id: String,
12997    /// Relying Party origin URL (e.g. `"https://example.com"`). Default: `"http://localhost:42617"`.
12998    #[serde(default = "default_webauthn_rp_origin")]
12999    pub rp_origin: String,
13000    /// Relying Party display name. Default: "ZeroClaw".
13001    #[serde(default = "default_webauthn_rp_name")]
13002    pub rp_name: String,
13003}
13004
13005impl Default for WebAuthnConfig {
13006    fn default() -> Self {
13007        Self {
13008            enabled: false,
13009            rp_id: default_webauthn_rp_id(),
13010            rp_origin: default_webauthn_rp_origin(),
13011            rp_name: default_webauthn_rp_name(),
13012        }
13013    }
13014}
13015
13016fn default_webauthn_rp_id() -> String {
13017    "localhost".into()
13018}
13019
13020fn default_webauthn_rp_origin() -> String {
13021    "http://localhost:42617".into()
13022}
13023
13024fn default_webauthn_rp_name() -> String {
13025    "ZeroClaw".into()
13026}
13027
13028/// OTP validation strategy.
13029#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
13030#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13031#[serde(rename_all = "kebab-case")]
13032pub enum OtpMethod {
13033    /// Time-based one-time password (RFC 6238).
13034    #[default]
13035    Totp,
13036    /// Future method for paired-device confirmations.
13037    Pairing,
13038    /// Future method for local CLI challenge prompts.
13039    CliPrompt,
13040}
13041
13042/// Security OTP configuration.
13043#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13044#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13045#[prefix = "security.otp"]
13046#[serde(deny_unknown_fields)]
13047pub struct OtpConfig {
13048    /// Enable OTP gating. Defaults to disabled for backward compatibility.
13049    #[serde(default)]
13050    pub enabled: bool,
13051
13052    /// OTP method.
13053    #[serde(default)]
13054    pub method: OtpMethod,
13055
13056    /// TOTP time-step in seconds.
13057    #[serde(default = "default_otp_token_ttl_secs")]
13058    pub token_ttl_secs: u64,
13059
13060    /// Reuse window for recently validated OTP codes.
13061    #[serde(default = "default_otp_cache_valid_secs")]
13062    pub cache_valid_secs: u64,
13063
13064    /// Tool/action names gated by OTP. Empty or malformed entries are rejected
13065    /// at config load; an entry that does not match a known gated action is
13066    /// accepted but logged at WARN, since it cannot be enforced.
13067    #[serde(default = "default_otp_gated_actions")]
13068    pub gated_actions: Vec<String>,
13069
13070    /// Explicit domain patterns gated by OTP.
13071    #[serde(default)]
13072    pub gated_domains: Vec<String>,
13073
13074    /// Domain-category presets expanded into `gated_domains`.
13075    #[serde(default)]
13076    pub gated_domain_categories: Vec<String>,
13077
13078    /// Maximum number of OTP challenge attempts before lockout.
13079    #[serde(default = "default_otp_challenge_max_attempts")]
13080    pub challenge_max_attempts: u32,
13081}
13082
13083fn default_otp_token_ttl_secs() -> u64 {
13084    30
13085}
13086
13087fn default_otp_cache_valid_secs() -> u64 {
13088    300
13089}
13090
13091fn default_otp_challenge_max_attempts() -> u32 {
13092    3
13093}
13094
13095fn default_otp_gated_actions() -> Vec<String> {
13096    vec![
13097        "shell".to_string(),
13098        "file_write".to_string(),
13099        "browser_open".to_string(),
13100        "browser".to_string(),
13101        "memory_forget".to_string(),
13102    ]
13103}
13104
13105impl Default for OtpConfig {
13106    fn default() -> Self {
13107        Self {
13108            enabled: false,
13109            method: OtpMethod::Totp,
13110            token_ttl_secs: default_otp_token_ttl_secs(),
13111            cache_valid_secs: default_otp_cache_valid_secs(),
13112            gated_actions: default_otp_gated_actions(),
13113            gated_domains: Vec::new(),
13114            gated_domain_categories: Vec::new(),
13115            challenge_max_attempts: default_otp_challenge_max_attempts(),
13116        }
13117    }
13118}
13119
13120/// Emergency stop configuration.
13121#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13122#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13123#[prefix = "security.estop"]
13124#[serde(deny_unknown_fields)]
13125pub struct EstopConfig {
13126    /// Enable emergency stop controls.
13127    #[serde(default)]
13128    pub enabled: bool,
13129
13130    /// File path used to persist estop state.
13131    #[serde(default = "default_estop_state_file")]
13132    pub state_file: String,
13133
13134    /// Require a valid OTP before resume operations.
13135    #[serde(default = "default_true")]
13136    pub require_otp_to_resume: bool,
13137}
13138
13139fn default_estop_state_file() -> String {
13140    default_path_under_config_dir("estop-state.json")
13141}
13142
13143impl Default for EstopConfig {
13144    fn default() -> Self {
13145        Self {
13146            enabled: false,
13147            state_file: default_estop_state_file(),
13148            require_otp_to_resume: true,
13149        }
13150    }
13151}
13152
13153/// Nevis IAM integration configuration.
13154///
13155/// When `enabled` is true, ZeroClaw validates incoming requests against a Nevis
13156/// Security Suite instance and maps Nevis roles to tool/workspace permissions.
13157#[derive(Clone, Serialize, Deserialize, Configurable)]
13158#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13159#[prefix = "security.nevis"]
13160#[serde(deny_unknown_fields)]
13161pub struct NevisConfig {
13162    /// Enable Nevis IAM integration. Defaults to false for backward compatibility.
13163    #[serde(default)]
13164    pub enabled: bool,
13165
13166    /// Base URL of the Nevis instance (e.g. `https://nevis.example.com`).
13167    #[serde(default)]
13168    pub instance_url: String,
13169
13170    /// Nevis realm to authenticate against.
13171    #[serde(default = "default_nevis_realm")]
13172    pub realm: String,
13173
13174    /// OAuth2 client ID registered in Nevis.
13175    #[serde(default)]
13176    pub client_id: String,
13177
13178    /// OAuth2 client secret. Encrypted via SecretStore when stored on disk.
13179    #[serde(default)]
13180    #[secret]
13181    #[credential_class = "encrypted_secret"]
13182    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13183    pub client_secret: Option<String>,
13184
13185    /// Token validation strategy: `"local"` (JWKS) or `"remote"` (introspection).
13186    #[serde(default = "default_nevis_token_validation")]
13187    pub token_validation: String,
13188
13189    /// JWKS endpoint URL for local token validation.
13190    #[serde(default)]
13191    pub jwks_url: Option<String>,
13192
13193    /// Nevis role to ZeroClaw permission mappings.
13194    #[serde(default)]
13195    pub role_mapping: Vec<NevisRoleMappingConfig>,
13196
13197    /// Require MFA verification for all Nevis-authenticated requests.
13198    #[serde(default)]
13199    pub require_mfa: bool,
13200
13201    /// Session timeout in seconds.
13202    #[serde(default = "default_nevis_session_timeout_secs")]
13203    pub session_timeout_secs: u64,
13204}
13205
13206impl std::fmt::Debug for NevisConfig {
13207    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
13208        f.debug_struct("NevisConfig")
13209            .field("enabled", &self.enabled)
13210            .field("instance_url", &self.instance_url)
13211            .field("realm", &self.realm)
13212            .field("client_id", &self.client_id)
13213            .field(
13214                "client_secret",
13215                &self.client_secret.as_ref().map(|_| "[REDACTED]"),
13216            )
13217            .field("token_validation", &self.token_validation)
13218            .field("jwks_url", &self.jwks_url)
13219            .field("role_mapping", &self.role_mapping)
13220            .field("require_mfa", &self.require_mfa)
13221            .field("session_timeout_secs", &self.session_timeout_secs)
13222            .finish()
13223    }
13224}
13225
13226impl NevisConfig {
13227    /// Validate that required fields are present when Nevis is enabled.
13228    ///
13229    /// Call at config load time to fail fast on invalid configuration rather
13230    /// than deferring errors to the first authentication request.
13231    pub fn validate(&self) -> Result<(), String> {
13232        if !self.enabled {
13233            return Ok(());
13234        }
13235
13236        if self.instance_url.trim().is_empty() {
13237            return Err("nevis.instance_url is required when Nevis IAM is enabled".into());
13238        }
13239
13240        if self.client_id.trim().is_empty() {
13241            return Err("nevis.client_id is required when Nevis IAM is enabled".into());
13242        }
13243
13244        if self.realm.trim().is_empty() {
13245            return Err("nevis.realm is required when Nevis IAM is enabled".into());
13246        }
13247
13248        match self.token_validation.as_str() {
13249            "local" | "remote" => {}
13250            other => {
13251                return Err(format!(
13252                    "nevis.token_validation has invalid value '{other}': \
13253                     expected 'local' or 'remote'"
13254                ));
13255            }
13256        }
13257
13258        if self.token_validation == "local" && self.jwks_url.is_none() {
13259            return Err("nevis.jwks_url is required when token_validation is 'local'".into());
13260        }
13261
13262        if self.session_timeout_secs == 0 {
13263            return Err("nevis.session_timeout_secs must be greater than 0".into());
13264        }
13265
13266        Ok(())
13267    }
13268}
13269
13270fn default_nevis_realm() -> String {
13271    "master".into()
13272}
13273
13274fn default_nevis_token_validation() -> String {
13275    "local".into()
13276}
13277
13278fn default_nevis_session_timeout_secs() -> u64 {
13279    3600
13280}
13281
13282impl Default for NevisConfig {
13283    fn default() -> Self {
13284        Self {
13285            enabled: false,
13286            instance_url: String::new(),
13287            realm: default_nevis_realm(),
13288            client_id: String::new(),
13289            client_secret: None,
13290            token_validation: default_nevis_token_validation(),
13291            jwks_url: None,
13292            role_mapping: Vec::new(),
13293            require_mfa: false,
13294            session_timeout_secs: default_nevis_session_timeout_secs(),
13295        }
13296    }
13297}
13298
13299/// Maps a Nevis role to ZeroClaw tool permissions and workspace access.
13300#[derive(Debug, Clone, Serialize, Deserialize)]
13301#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13302#[serde(deny_unknown_fields)]
13303pub struct NevisRoleMappingConfig {
13304    /// Nevis role name (case-insensitive).
13305    pub nevis_role: String,
13306
13307    /// Tool names this role can access. Use `"all"` for unrestricted tool access.
13308    #[serde(default)]
13309    pub zeroclaw_permissions: Vec<String>,
13310
13311    /// Workspace names this role can access. Use `"all"` for unrestricted.
13312    #[serde(default)]
13313    pub workspace_access: Vec<String>,
13314}
13315
13316/// Sandbox configuration for OS-level isolation
13317#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13318#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13319#[prefix = "security.sandbox"]
13320pub struct SandboxConfig {
13321    /// Enable sandboxing (None = auto-detect, Some = explicit)
13322    #[serde(default)]
13323    pub enabled: Option<bool>,
13324
13325    /// Sandbox backend to use
13326    #[serde(default)]
13327    pub backend: SandboxBackend,
13328
13329    /// Custom Firejail arguments (when backend = firejail)
13330    #[serde(default)]
13331    pub firejail_args: Vec<String>,
13332}
13333
13334impl Default for SandboxConfig {
13335    fn default() -> Self {
13336        Self {
13337            enabled: None, // Auto-detect
13338            backend: SandboxBackend::Auto,
13339            firejail_args: Vec::new(),
13340        }
13341    }
13342}
13343
13344/// Sandbox backend selection
13345#[derive(Debug, Clone, Serialize, Deserialize, Default)]
13346#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13347#[serde(rename_all = "lowercase")]
13348pub enum SandboxBackend {
13349    /// Auto-detect best available (default)
13350    #[default]
13351    Auto,
13352    /// Landlock (Linux kernel LSM, native)
13353    Landlock,
13354    /// Firejail (user-space sandbox)
13355    Firejail,
13356    /// Bubblewrap (user namespaces)
13357    Bubblewrap,
13358    /// Docker container isolation
13359    Docker,
13360    /// macOS sandbox-exec (Seatbelt)
13361    #[serde(alias = "sandbox-exec")]
13362    SandboxExec,
13363    /// No sandboxing (application-layer only)
13364    None,
13365}
13366
13367/// Audit logging configuration
13368#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13369#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13370#[prefix = "security.audit"]
13371pub struct AuditConfig {
13372    /// Enable audit logging
13373    #[serde(default = "default_audit_enabled")]
13374    pub enabled: bool,
13375
13376    /// Path to audit log file (relative to zeroclaw dir)
13377    #[serde(default = "default_audit_log_path")]
13378    pub log_path: String,
13379
13380    /// Maximum log size in MB before rotation
13381    #[serde(default = "default_audit_max_size_mb")]
13382    pub max_size_mb: u32,
13383
13384    /// Sign events with HMAC for tamper evidence
13385    #[serde(default)]
13386    pub sign_events: bool,
13387}
13388
13389fn default_audit_enabled() -> bool {
13390    true
13391}
13392
13393fn default_audit_log_path() -> String {
13394    "audit.log".to_string()
13395}
13396
13397fn default_audit_max_size_mb() -> u32 {
13398    100
13399}
13400
13401impl Default for AuditConfig {
13402    fn default() -> Self {
13403        Self {
13404            enabled: default_audit_enabled(),
13405            log_path: default_audit_log_path(),
13406            max_size_mb: default_audit_max_size_mb(),
13407            sign_events: false,
13408        }
13409    }
13410}
13411
13412/// DingTalk configuration for Stream Mode messaging
13413#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13414#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13415#[prefix = "channels.dingtalk"]
13416pub struct DingTalkConfig {
13417    /// Whether this channel is active. The runtime only loads channels whose
13418    /// `enabled = true`. Default: `false` so an operator who pastes a partial
13419    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
13420    /// live before the rest of its config is filled in.
13421    #[tab(Behavior)]
13422    #[serde(default)]
13423    pub enabled: bool,
13424    /// Client ID (AppKey) from DingTalk developer console
13425    #[tab(Connection)]
13426    pub client_id: String,
13427    /// Client Secret (AppSecret) from DingTalk developer console
13428    #[secret]
13429    #[tab(Connection)]
13430    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13431    pub client_secret: String,
13432    /// Per-channel proxy URL (http, https, socks5, socks5h).
13433    /// Overrides the global `[proxy]` setting for this channel only.
13434    #[tab(Advanced)]
13435    #[serde(default)]
13436    pub proxy_url: Option<String>,
13437
13438    /// Tools excluded from this channel's tool spec. When set, these tools
13439    /// are not exposed to the model when responding via this channel.
13440    #[tab(Behavior)]
13441    #[serde(default)]
13442    pub excluded_tools: Vec<String>,
13443}
13444
13445impl ChannelConfig for DingTalkConfig {
13446    fn name() -> &'static str {
13447        "DingTalk"
13448    }
13449    fn desc() -> &'static str {
13450        "DingTalk Stream Mode"
13451    }
13452}
13453
13454/// WeCom (WeChat Enterprise) Bot Webhook configuration
13455#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13456#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13457#[prefix = "channels.wecom"]
13458pub struct WeComConfig {
13459    /// Whether this channel is active. The runtime only loads channels whose
13460    /// `enabled = true`. Default: `false` so an operator who pastes a partial
13461    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
13462    /// live before the rest of its config is filled in.
13463    #[tab(Behavior)]
13464    #[serde(default)]
13465    pub enabled: bool,
13466    /// Webhook key from WeCom Bot configuration
13467    #[secret]
13468    #[tab(Connection)]
13469    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13470    pub webhook_key: String,
13471
13472    /// Tools excluded from this channel's tool spec. When set, these tools
13473    /// are not exposed to the model when responding via this channel.
13474    #[tab(Behavior)]
13475    #[serde(default)]
13476    pub excluded_tools: Vec<String>,
13477}
13478
13479impl ChannelConfig for WeComConfig {
13480    fn name() -> &'static str {
13481        "WeCom"
13482    }
13483    fn desc() -> &'static str {
13484        "WeCom Bot Webhook"
13485    }
13486}
13487
13488fn default_wecom_ws_file_retention_days() -> u32 {
13489    7
13490}
13491
13492fn default_wecom_ws_max_file_size_mb() -> u64 {
13493    20
13494}
13495
13496fn default_wecom_ws_stream_mode() -> StreamMode {
13497    StreamMode::Partial
13498}
13499
13500/// WeCom AI Bot WebSocket configuration.
13501///
13502/// This is distinct from webhook-based [`WeComConfig`] and uses the WeCom AI
13503/// Bot long-connection API for inbound messages and active-session replies.
13504#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13505#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13506#[prefix = "channels.wecom_ws"]
13507pub struct WeComWsConfig {
13508    /// Whether this channel is active. The runtime only loads channels whose
13509    /// `enabled = true`. Default: `false` so an operator who pastes a partial
13510    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
13511    /// live before the rest of its config is filled in.
13512    #[serde(default)]
13513    pub enabled: bool,
13514    /// Bot ID for WeCom WebSocket subscription.
13515    pub bot_id: String,
13516    /// Secret for WeCom WebSocket subscription authentication.
13517    #[secret]
13518    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13519    pub secret: String,
13520    /// Allowed WeCom user IDs. Empty = deny all, "*" = allow all users.
13521    #[serde(default)]
13522    pub allowed_users: Vec<String>,
13523    /// Allowed WeCom group chat IDs. Empty = deny all groups, "*" = allow all groups.
13524    #[serde(default)]
13525    pub allowed_groups: Vec<String>,
13526    /// Display name or mention alias of the WeCom AI bot, for example `danya`.
13527    ///
13528    /// WeCom group text often arrives as plain text such as `@danya say hi`;
13529    /// passing this name through lets the generic reply-intent precheck
13530    /// recognize that a group message was addressed to the bot.
13531    #[serde(default)]
13532    pub bot_name: Option<String>,
13533    /// File retention days for downloaded WeCom attachments under the workspace cache.
13534    #[serde(default = "default_wecom_ws_file_retention_days")]
13535    pub file_retention_days: u32,
13536    /// Maximum accepted file size in MiB for WeCom attachment download attempts.
13537    #[serde(default = "default_wecom_ws_max_file_size_mb")]
13538    pub max_file_size_mb: u64,
13539    /// Streaming mode for progressive draft delivery over the WeCom long connection.
13540    #[serde(default = "default_wecom_ws_stream_mode")]
13541    pub stream_mode: StreamMode,
13542    /// Optional per-channel proxy override. Falls back to the global proxy config when empty.
13543    #[serde(default)]
13544    pub proxy_url: Option<String>,
13545    /// Tools excluded from this channel's tool spec. When set, these tools
13546    /// are not exposed to the model when responding via this channel.
13547    #[serde(default)]
13548    pub excluded_tools: Vec<String>,
13549}
13550
13551impl Default for WeComWsConfig {
13552    fn default() -> Self {
13553        Self {
13554            enabled: false,
13555            bot_id: String::new(),
13556            secret: String::new(),
13557            allowed_users: Vec::new(),
13558            allowed_groups: Vec::new(),
13559            bot_name: None,
13560            file_retention_days: default_wecom_ws_file_retention_days(),
13561            max_file_size_mb: default_wecom_ws_max_file_size_mb(),
13562            stream_mode: default_wecom_ws_stream_mode(),
13563            proxy_url: None,
13564            excluded_tools: Vec::new(),
13565        }
13566    }
13567}
13568
13569impl ChannelConfig for WeComWsConfig {
13570    fn name() -> &'static str {
13571        "WeCom WebSocket"
13572    }
13573    fn desc() -> &'static str {
13574        "WeCom AI Bot long connection"
13575    }
13576}
13577
13578/// WeChat personal iLink Bot channel configuration.
13579///
13580/// Uses the iLink Bot API (`ilinkai.weixin.qq.com`) with QR-code login.
13581/// The bot token is obtained by scanning a QR code and persisted to disk
13582/// so subsequent restarts do not require re-scanning.
13583#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13584#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13585#[prefix = "channels.wechat"]
13586pub struct WeChatConfig {
13587    /// Whether this channel is active. The runtime only loads channels whose
13588    /// `enabled = true`. Default: `false` so an operator who pastes a partial
13589    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
13590    /// live before the rest of its config is filled in.
13591    #[tab(Behavior)]
13592    #[serde(default)]
13593    pub enabled: bool,
13594    /// Override the iLink API base URL. Default: `https://ilinkai.weixin.qq.com`.
13595    #[tab(Advanced)]
13596    #[serde(default)]
13597    pub api_base_url: Option<String>,
13598    /// Override the CDN base URL. Default: `https://novac2c.cdn.weixin.qq.com/c2c`.
13599    #[tab(Advanced)]
13600    #[serde(default)]
13601    pub cdn_base_url: Option<String>,
13602    /// Directory to persist bot token and sync cursor.
13603    /// Default: `~/.zeroclaw/wechat/`.
13604    #[tab(Advanced)]
13605    #[serde(default)]
13606    pub state_dir: Option<String>,
13607
13608    /// Tools excluded from this channel's tool spec. When set, these tools
13609    /// are not exposed to the model when responding via this channel.
13610    #[tab(Behavior)]
13611    #[serde(default)]
13612    pub excluded_tools: Vec<String>,
13613}
13614
13615impl ChannelConfig for WeChatConfig {
13616    fn name() -> &'static str {
13617        "WeChat"
13618    }
13619    fn desc() -> &'static str {
13620        "WeChat iLink Bot"
13621    }
13622}
13623
13624/// QQ Official Bot configuration (Tencent QQ Bot SDK)
13625#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13626#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13627#[prefix = "channels.qq"]
13628pub struct QQConfig {
13629    /// Whether this channel is active. The runtime only loads channels whose
13630    /// `enabled = true`. Default: `false` so an operator who pastes a partial
13631    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
13632    /// live before the rest of its config is filled in.
13633    #[tab(Behavior)]
13634    #[serde(default)]
13635    pub enabled: bool,
13636    /// App ID from QQ Bot developer console
13637    #[tab(Connection)]
13638    pub app_id: String,
13639    /// App Secret from QQ Bot developer console
13640    #[secret]
13641    #[tab(Connection)]
13642    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13643    pub app_secret: String,
13644    /// Per-channel proxy URL (http, https, socks5, socks5h).
13645    /// Overrides the global `[proxy]` setting for this channel only.
13646    #[tab(Advanced)]
13647    #[serde(default)]
13648    pub proxy_url: Option<String>,
13649
13650    /// Tools excluded from this channel's tool spec. When set, these tools
13651    /// are not exposed to the model when responding via this channel.
13652    #[tab(Behavior)]
13653    #[serde(default)]
13654    pub excluded_tools: Vec<String>,
13655}
13656
13657impl ChannelConfig for QQConfig {
13658    fn name() -> &'static str {
13659        "QQ Official"
13660    }
13661    fn desc() -> &'static str {
13662        "Tencent QQ Bot"
13663    }
13664}
13665
13666/// X/Twitter channel configuration (Twitter API v2)
13667#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13668#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13669#[prefix = "channels.twitter"]
13670pub struct TwitterConfig {
13671    /// Whether this channel is active. The runtime only loads channels whose
13672    /// `enabled = true`. Default: `false` so an operator who pastes a partial
13673    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
13674    /// live before the rest of its config is filled in.
13675    #[tab(Behavior)]
13676    #[serde(default)]
13677    pub enabled: bool,
13678    /// Twitter API v2 Bearer Token (OAuth 2.0)
13679    #[secret]
13680    #[tab(Connection)]
13681    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13682    pub bearer_token: String,
13683
13684    /// Tools excluded from this channel's tool spec. When set, these tools
13685    /// are not exposed to the model when responding via this channel.
13686    #[tab(Behavior)]
13687    #[serde(default)]
13688    pub excluded_tools: Vec<String>,
13689}
13690
13691impl ChannelConfig for TwitterConfig {
13692    fn name() -> &'static str {
13693        "X/Twitter"
13694    }
13695    fn desc() -> &'static str {
13696        "X/Twitter Bot via API v2"
13697    }
13698}
13699
13700/// Mochat channel configuration (Mochat customer service API)
13701#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13702#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13703#[prefix = "channels.mochat"]
13704pub struct MochatConfig {
13705    /// Whether this channel is active. The runtime only loads channels whose
13706    /// `enabled = true`. Default: `false` so an operator who pastes a partial
13707    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
13708    /// live before the rest of its config is filled in.
13709    #[tab(Behavior)]
13710    #[serde(default)]
13711    pub enabled: bool,
13712    /// Mochat API base URL
13713    #[tab(Advanced)]
13714    pub api_url: String,
13715    /// Mochat API token
13716    #[secret]
13717    #[tab(Connection)]
13718    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13719    pub api_token: String,
13720    /// Poll interval in seconds for new messages. Default: 5
13721    #[tab(Advanced)]
13722    #[serde(default = "default_mochat_poll_interval")]
13723    pub poll_interval_secs: u64,
13724
13725    /// Tools excluded from this channel's tool spec. When set, these tools
13726    /// are not exposed to the model when responding via this channel.
13727    #[tab(Behavior)]
13728    #[serde(default)]
13729    pub excluded_tools: Vec<String>,
13730}
13731
13732fn default_mochat_poll_interval() -> u64 {
13733    5
13734}
13735
13736impl ChannelConfig for MochatConfig {
13737    fn name() -> &'static str {
13738        "Mochat"
13739    }
13740    fn desc() -> &'static str {
13741        "Mochat Customer Service"
13742    }
13743}
13744
13745/// Reddit channel configuration (OAuth2 bot).
13746#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13747#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13748#[prefix = "channels.reddit"]
13749pub struct RedditConfig {
13750    /// Whether this channel is active. The runtime only loads channels whose
13751    /// `enabled = true`. Default: `false` so an operator who pastes a partial
13752    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
13753    /// live before the rest of its config is filled in.
13754    #[tab(Behavior)]
13755    #[serde(default)]
13756    pub enabled: bool,
13757    /// Reddit OAuth2 client ID.
13758    #[tab(Connection)]
13759    pub client_id: String,
13760    /// Reddit OAuth2 client secret.
13761    #[secret]
13762    #[tab(Connection)]
13763    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13764    pub client_secret: String,
13765    /// Reddit OAuth2 refresh token for persistent access.
13766    #[secret]
13767    #[tab(Connection)]
13768    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13769    pub refresh_token: String,
13770    /// Reddit bot username (without `u/` prefix).
13771    #[tab(Advanced)]
13772    pub username: String,
13773    /// Subreddits to filter messages (without `r/` prefix). Empty = accept
13774    /// from any subreddit the bot has access to. Migrated from the legacy
13775    /// `subreddit` singular field.
13776    #[tab(Advanced)]
13777    #[serde(default)]
13778    pub subreddits: Vec<String>,
13779
13780    /// Tools excluded from this channel's tool spec. When set, these tools
13781    /// are not exposed to the model when responding via this channel.
13782    #[tab(Behavior)]
13783    #[serde(default)]
13784    pub excluded_tools: Vec<String>,
13785}
13786
13787impl ChannelConfig for RedditConfig {
13788    fn name() -> &'static str {
13789        "Reddit"
13790    }
13791    fn desc() -> &'static str {
13792        "Reddit bot (OAuth2)"
13793    }
13794}
13795
13796/// Bluesky channel configuration (AT Protocol).
13797#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13798#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13799#[prefix = "channels.bluesky"]
13800pub struct BlueskyConfig {
13801    /// Whether this channel is active. The runtime only loads channels whose
13802    /// `enabled = true`. Default: `false` so an operator who pastes a partial
13803    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
13804    /// live before the rest of its config is filled in.
13805    #[tab(Behavior)]
13806    #[serde(default)]
13807    pub enabled: bool,
13808    /// Bluesky handle (e.g. `"mybot.bsky.social"`).
13809    #[tab(Connection)]
13810    pub handle: String,
13811    /// App-specific password (from Bluesky settings).
13812    #[secret]
13813    #[tab(Connection)]
13814    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13815    pub app_password: String,
13816
13817    /// Tools excluded from this channel's tool spec. When set, these tools
13818    /// are not exposed to the model when responding via this channel.
13819    #[tab(Behavior)]
13820    #[serde(default)]
13821    pub excluded_tools: Vec<String>,
13822}
13823
13824impl ChannelConfig for BlueskyConfig {
13825    fn name() -> &'static str {
13826        "Bluesky"
13827    }
13828    fn desc() -> &'static str {
13829        "AT Protocol"
13830    }
13831}
13832
13833/// Voice duplex configuration (`[channels.voice_duplex]`).
13834///
13835/// Enables full-duplex voice event handling over WebSocket.
13836/// When disabled (default), voice events are rejected as unknown types.
13837#[derive(Debug, Clone, Serialize, Deserialize, Configurable, Default)]
13838#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13839pub struct VoiceDuplexConfig {
13840    /// Whether this channel is active. The runtime only loads channels whose
13841    /// `enabled = true`. Default: `false` so an operator who pastes a partial
13842    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
13843    /// live before the rest of its config is filled in.
13844    #[serde(default)]
13845    pub enabled: bool,
13846    /// Tools excluded from this channel's tool spec. When set, these tools
13847    /// are not exposed to the model when responding via this channel.
13848    #[serde(default)]
13849    pub excluded_tools: Vec<String>,
13850}
13851
13852/// Voice wake word detection channel configuration.
13853///
13854/// Listens on the default microphone for a configurable wake word,
13855/// then captures the following utterance and transcribes it via the
13856/// existing transcription API.
13857#[derive(Debug, Clone, Serialize, Deserialize, zeroclaw_macros::Configurable)]
13858#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13859#[prefix = "voice_wake"]
13860pub struct VoiceWakeConfig {
13861    /// Whether this channel is active. The runtime only loads channels whose
13862    /// `enabled = true`. Default: `false` so an operator who pastes a partial
13863    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
13864    /// live before the rest of its config is filled in.
13865    #[serde(default)]
13866    pub enabled: bool,
13867    /// Wake word phrase to listen for (case-insensitive substring match).
13868    /// Default: `"hey zeroclaw"`.
13869    #[serde(default = "default_voice_wake_word")]
13870    pub wake_word: String,
13871    /// Silence timeout in milliseconds: how long to wait after the last
13872    /// energy spike before finalizing a capture window. Default: `2000`.
13873    #[serde(default = "default_voice_wake_silence_timeout_ms")]
13874    pub silence_timeout_ms: u32,
13875    /// RMS energy threshold for voice activity detection. Samples below
13876    /// this level are treated as silence. Default: `0.01`.
13877    #[serde(default = "default_voice_wake_energy_threshold")]
13878    pub energy_threshold: f32,
13879    /// Maximum capture duration in seconds before forcing transcription.
13880    /// Default: `30`.
13881    #[serde(default = "default_voice_wake_max_capture_secs")]
13882    pub max_capture_secs: u32,
13883
13884    /// Tools excluded from this channel's tool spec. When set, these tools
13885    /// are not exposed to the model when responding via this channel.
13886    #[serde(default)]
13887    pub excluded_tools: Vec<String>,
13888}
13889
13890fn default_voice_wake_word() -> String {
13891    "hey zeroclaw".into()
13892}
13893
13894fn default_voice_wake_silence_timeout_ms() -> u32 {
13895    2000
13896}
13897
13898fn default_voice_wake_energy_threshold() -> f32 {
13899    0.01
13900}
13901
13902fn default_voice_wake_max_capture_secs() -> u32 {
13903    30
13904}
13905
13906impl Default for VoiceWakeConfig {
13907    fn default() -> Self {
13908        Self {
13909            enabled: false,
13910            wake_word: default_voice_wake_word(),
13911            silence_timeout_ms: default_voice_wake_silence_timeout_ms(),
13912            energy_threshold: default_voice_wake_energy_threshold(),
13913            max_capture_secs: default_voice_wake_max_capture_secs(),
13914            excluded_tools: Vec::new(),
13915        }
13916    }
13917}
13918
13919impl ChannelConfig for VoiceWakeConfig {
13920    fn name() -> &'static str {
13921        "VoiceWake"
13922    }
13923    fn desc() -> &'static str {
13924        "voice wake word detection"
13925    }
13926}
13927
13928/// Nostr channel configuration (NIP-04 + NIP-17 private messages)
13929#[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
13930#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13931#[prefix = "channels.nostr"]
13932pub struct NostrConfig {
13933    /// Whether this channel is active. The runtime only loads channels whose
13934    /// `enabled = true`. Default: `false` so an operator who pastes a partial
13935    /// `[channels.<type>.<alias>]` block doesn't accidentally bring a channel
13936    /// live before the rest of its config is filled in.
13937    #[tab(Behavior)]
13938    #[serde(default)]
13939    pub enabled: bool,
13940    /// Private key in hex or nsec bech32 format
13941    #[secret]
13942    #[tab(Connection)]
13943    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13944    pub private_key: String,
13945    /// Relay URLs (wss://). Defaults to popular public relays if omitted.
13946    #[tab(Advanced)]
13947    #[serde(default = "default_nostr_relays")]
13948    pub relays: Vec<String>,
13949
13950    /// Tools excluded from this channel's tool spec. When set, these tools
13951    /// are not exposed to the model when responding via this channel.
13952    #[tab(Behavior)]
13953    #[serde(default)]
13954    pub excluded_tools: Vec<String>,
13955}
13956
13957impl ChannelConfig for NostrConfig {
13958    fn name() -> &'static str {
13959        "Nostr"
13960    }
13961    fn desc() -> &'static str {
13962        "Nostr DMs"
13963    }
13964}
13965
13966pub fn default_nostr_relays() -> Vec<String> {
13967    vec![
13968        "wss://relay.damus.io".to_string(),
13969        "wss://nos.lol".to_string(),
13970        "wss://relay.primal.net".to_string(),
13971        "wss://relay.snort.social".to_string(),
13972    ]
13973}
13974
13975// -- Notion --
13976
13977/// Notion integration configuration (`[notion]`).
13978///
13979/// When `enabled = true`, the agent polls a Notion database for pending tasks
13980/// and exposes a `notion` tool for querying, reading, creating, and updating pages.
13981/// Requires `api_key` (or the `NOTION_API_KEY` env var) and `database_id`.
13982#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
13983#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
13984#[prefix = "notion"]
13985pub struct NotionConfig {
13986    #[serde(default)]
13987    pub enabled: bool,
13988    #[serde(default)]
13989    #[secret]
13990    #[credential_class = "encrypted_secret"]
13991    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
13992    pub api_key: String,
13993    #[serde(default)]
13994    pub database_id: String,
13995    #[serde(default = "default_notion_poll_interval")]
13996    pub poll_interval_secs: u64,
13997    #[serde(default = "default_notion_status_prop")]
13998    pub status_property: String,
13999    #[serde(default = "default_notion_input_prop")]
14000    pub input_property: String,
14001    #[serde(default = "default_notion_result_prop")]
14002    pub result_property: String,
14003    #[serde(default = "default_notion_max_concurrent")]
14004    pub max_concurrent: usize,
14005    #[serde(default = "default_notion_recover_stale")]
14006    pub recover_stale: bool,
14007}
14008
14009fn default_notion_poll_interval() -> u64 {
14010    5
14011}
14012fn default_notion_status_prop() -> String {
14013    "Status".into()
14014}
14015fn default_notion_input_prop() -> String {
14016    "Input".into()
14017}
14018fn default_notion_result_prop() -> String {
14019    "Result".into()
14020}
14021fn default_notion_max_concurrent() -> usize {
14022    4
14023}
14024fn default_notion_recover_stale() -> bool {
14025    true
14026}
14027
14028impl Default for NotionConfig {
14029    fn default() -> Self {
14030        Self {
14031            enabled: false,
14032            api_key: String::new(),
14033            database_id: String::new(),
14034            poll_interval_secs: default_notion_poll_interval(),
14035            status_property: default_notion_status_prop(),
14036            input_property: default_notion_input_prop(),
14037            result_property: default_notion_result_prop(),
14038            max_concurrent: default_notion_max_concurrent(),
14039            recover_stale: default_notion_recover_stale(),
14040        }
14041    }
14042}
14043
14044/// Jira integration configuration (`[jira]`).
14045///
14046/// When `enabled = true`, registers the `jira` tool which can get tickets,
14047/// search with JQL, and add comments. Requires `base_url` and `api_token`
14048/// (or the `JIRA_API_TOKEN` env var).
14049///
14050/// ## Defaults
14051/// - `enabled`: `false`
14052/// - `allowed_actions`: `["get_ticket"]` — read-only by default.
14053///   Add `"search_tickets"` or `"comment_ticket"` to unlock them.
14054/// - `timeout_secs`: `30`
14055///
14056/// ## Auth
14057/// Jira Cloud uses HTTP Basic auth: `email` + `api_token`.
14058/// Jira Server/Data Center uses Bearer token auth: omit `email` and set
14059/// `api_token` to a personal access token.
14060/// `api_token` is stored encrypted at rest; set it here or via `JIRA_API_TOKEN`.
14061#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
14062#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
14063#[prefix = "jira"]
14064pub struct JiraConfig {
14065    /// Enable the `jira` tool. Default: `false`.
14066    #[serde(default)]
14067    pub enabled: bool,
14068    /// Atlassian instance base URL, e.g. `https://yourco.atlassian.net`.
14069    #[serde(default)]
14070    pub base_url: String,
14071    /// Jira account email used for Basic auth (Cloud).
14072    /// Omit for Server/DC deployments using Bearer token auth.
14073    /// An empty string (`email = ""`) deserializes as `None`. Configs
14074    /// that round-tripped the empty default to disk would otherwise
14075    /// silently regress to Basic auth with empty username, since the
14076    /// email-required validation was dropped when Server/DC Bearer-token
14077    /// support landed.
14078    #[serde(
14079        default,
14080        skip_serializing_if = "Option::is_none",
14081        deserialize_with = "deserialize_optional_email_skip_empty"
14082    )]
14083    pub email: Option<String>,
14084    /// Jira API token. Encrypted at rest. Falls back to `JIRA_API_TOKEN` env var.
14085    #[serde(default)]
14086    #[secret]
14087    #[credential_class = "encrypted_secret"]
14088    #[cfg_attr(feature = "schema-export", schemars(extend("x-secret" = true)))]
14089    pub api_token: String,
14090    /// Actions the agent is permitted to call.
14091    /// Valid values: `"get_ticket"`, `"search_tickets"`, `"comment_ticket"`,
14092    /// `"list_projects"`, `"myself"`, `"list_transitions"`,
14093    /// `"transition_ticket"`, `"create_ticket"`.
14094    /// Defaults to `["get_ticket"]` (read-only).
14095    #[serde(default = "default_jira_allowed_actions")]
14096    pub allowed_actions: Vec<String>,
14097    /// Request timeout in seconds. Default: `30`.
14098    #[serde(default = "default_jira_timeout_secs")]
14099    pub timeout_secs: u64,
14100}
14101
14102fn default_jira_allowed_actions() -> Vec<String> {
14103    vec!["get_ticket".to_string()]
14104}
14105
14106fn default_jira_timeout_secs() -> u64 {
14107    30
14108}
14109
14110impl Default for JiraConfig {
14111    fn default() -> Self {
14112        Self {
14113            enabled: false,
14114            base_url: String::new(),
14115            email: None,
14116            api_token: String::new(),
14117            allowed_actions: default_jira_allowed_actions(),
14118            timeout_secs: default_jira_timeout_secs(),
14119        }
14120    }
14121}
14122
14123///
14124/// Controls the read-only cloud transformation analysis tools:
14125/// IaC review, migration assessment, cost analysis, and architecture review.
14126#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
14127#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
14128#[prefix = "cloud_ops"]
14129pub struct CloudOpsConfig {
14130    /// Enable cloud operations tools. Default: false.
14131    #[serde(default)]
14132    pub enabled: bool,
14133    /// Default cloud model_provider for analysis context. Default: "aws".
14134    #[serde(default = "default_cloud_ops_cloud")]
14135    pub default_cloud: String,
14136    /// Supported cloud model_providers. Default: [`aws`, `azure`, `gcp`].
14137    #[serde(default = "default_cloud_ops_supported_clouds")]
14138    pub supported_clouds: Vec<String>,
14139    /// Supported IaC tools for review. Default: \[`terraform`\].
14140    #[serde(default = "default_cloud_ops_iac_tools")]
14141    pub iac_tools: Vec<String>,
14142    /// Monthly USD threshold to flag cost items. Default: 100.0.
14143    #[serde(default = "default_cloud_ops_cost_threshold")]
14144    pub cost_threshold_monthly_usd: f64,
14145    /// Well-Architected Frameworks to check against. Default: \[`aws-waf`\].
14146    #[serde(default = "default_cloud_ops_waf")]
14147    pub well_architected_frameworks: Vec<String>,
14148}
14149
14150impl Default for CloudOpsConfig {
14151    fn default() -> Self {
14152        Self {
14153            enabled: false,
14154            default_cloud: default_cloud_ops_cloud(),
14155            supported_clouds: default_cloud_ops_supported_clouds(),
14156            iac_tools: default_cloud_ops_iac_tools(),
14157            cost_threshold_monthly_usd: default_cloud_ops_cost_threshold(),
14158            well_architected_frameworks: default_cloud_ops_waf(),
14159        }
14160    }
14161}
14162
14163impl CloudOpsConfig {
14164    pub fn validate(&self) -> Result<()> {
14165        if self.enabled {
14166            if self.default_cloud.trim().is_empty() {
14167                anyhow::bail!(
14168                    "cloud_ops.default_cloud must not be empty when cloud_ops is enabled"
14169                );
14170            }
14171            if self.supported_clouds.is_empty() {
14172                anyhow::bail!(
14173                    "cloud_ops.supported_clouds must not be empty when cloud_ops is enabled"
14174                );
14175            }
14176            for (i, cloud) in self.supported_clouds.iter().enumerate() {
14177                if cloud.trim().is_empty() {
14178                    validation_bail!(
14179                        RequiredFieldEmpty,
14180                        format!("cloud_ops.supported_clouds[{i}]"),
14181                        "cloud_ops.supported_clouds[{i}] must not be empty"
14182                    );
14183                }
14184            }
14185            if !self.supported_clouds.contains(&self.default_cloud) {
14186                anyhow::bail!(
14187                    "cloud_ops.default_cloud '{}' is not in cloud_ops.supported_clouds {:?}",
14188                    self.default_cloud,
14189                    self.supported_clouds
14190                );
14191            }
14192            if self.cost_threshold_monthly_usd < 0.0 {
14193                anyhow::bail!(
14194                    "cloud_ops.cost_threshold_monthly_usd must be non-negative, got {}",
14195                    self.cost_threshold_monthly_usd
14196                );
14197            }
14198            if self.iac_tools.is_empty() {
14199                anyhow::bail!("cloud_ops.iac_tools must not be empty when cloud_ops is enabled");
14200            }
14201        }
14202        Ok(())
14203    }
14204}
14205
14206fn default_cloud_ops_cloud() -> String {
14207    "aws".into()
14208}
14209
14210fn default_cloud_ops_supported_clouds() -> Vec<String> {
14211    vec!["aws".into(), "azure".into(), "gcp".into()]
14212}
14213
14214fn default_cloud_ops_iac_tools() -> Vec<String> {
14215    vec!["terraform".into()]
14216}
14217
14218fn default_cloud_ops_cost_threshold() -> f64 {
14219    100.0
14220}
14221
14222fn default_cloud_ops_waf() -> Vec<String> {
14223    vec!["aws-waf".into()]
14224}
14225
14226// ── Conversational AI ──────────────────────────────────────────────
14227
14228fn default_conversational_ai_language() -> String {
14229    "en".into()
14230}
14231
14232fn default_conversational_ai_supported_languages() -> Vec<String> {
14233    vec!["en".into(), "de".into(), "fr".into(), "it".into()]
14234}
14235
14236fn default_conversational_ai_escalation_threshold() -> f64 {
14237    0.3
14238}
14239
14240fn default_conversational_ai_max_turns() -> usize {
14241    50
14242}
14243
14244fn default_conversational_ai_timeout_secs() -> u64 {
14245    1800
14246}
14247
14248/// Conversational AI agent builder configuration (`[conversational_ai]` section).
14249///
14250/// **Status: Reserved for future use.** This configuration is parsed but not yet
14251/// consumed by the runtime. Setting `enabled = true` will produce a startup warning.
14252#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
14253#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
14254#[prefix = "conversational_ai"]
14255pub struct ConversationalAiConfig {
14256    /// Enable conversational AI features. Default: false.
14257    #[serde(default)]
14258    pub enabled: bool,
14259    /// Default language for conversations (BCP-47 tag). Default: "en".
14260    #[serde(default = "default_conversational_ai_language")]
14261    pub default_language: String,
14262    /// Supported languages for conversations. Default: [`en`, `de`, `fr`, `it`].
14263    #[serde(default = "default_conversational_ai_supported_languages")]
14264    pub supported_languages: Vec<String>,
14265    /// Automatically detect user language from message content. Default: true.
14266    #[serde(default = "default_true")]
14267    pub auto_detect_language: bool,
14268    /// Intent confidence below this threshold triggers escalation. Default: 0.3.
14269    #[serde(default = "default_conversational_ai_escalation_threshold")]
14270    pub escalation_confidence_threshold: f64,
14271    /// Maximum conversation turns before auto-ending. Default: 50.
14272    #[serde(default = "default_conversational_ai_max_turns")]
14273    pub max_conversation_turns: usize,
14274    /// Conversation timeout in seconds (inactivity). Default: 1800.
14275    #[serde(default = "default_conversational_ai_timeout_secs")]
14276    pub conversation_timeout_secs: u64,
14277    /// Enable conversation analytics tracking. Default: false (privacy-by-default).
14278    #[serde(default)]
14279    pub analytics_enabled: bool,
14280    /// Optional tool name for RAG-based knowledge base lookup during conversations.
14281    #[serde(default)]
14282    pub knowledge_base_tool: Option<String>,
14283}
14284
14285impl ConversationalAiConfig {
14286    /// Returns `true` when the feature is disabled (the default).
14287    ///
14288    /// Used by `#[serde(skip_serializing_if)]` to omit the entire
14289    /// `[conversational_ai]` section from newly-generated config files,
14290    /// avoiding user confusion over an undocumented / experimental section.
14291    pub fn is_disabled(&self) -> bool {
14292        !self.enabled
14293    }
14294}
14295
14296impl Default for ConversationalAiConfig {
14297    fn default() -> Self {
14298        Self {
14299            enabled: false,
14300            default_language: default_conversational_ai_language(),
14301            supported_languages: default_conversational_ai_supported_languages(),
14302            auto_detect_language: true,
14303            escalation_confidence_threshold: default_conversational_ai_escalation_threshold(),
14304            max_conversation_turns: default_conversational_ai_max_turns(),
14305            conversation_timeout_secs: default_conversational_ai_timeout_secs(),
14306            analytics_enabled: false,
14307            knowledge_base_tool: None,
14308        }
14309    }
14310}
14311
14312// ── Security ops config ─────────────────────────────────────────
14313
14314/// Managed Cybersecurity Service (MCSS) dashboard agent configuration (`[security_ops]`).
14315#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
14316#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
14317#[prefix = "security_ops"]
14318pub struct SecurityOpsConfig {
14319    /// Enable security operations tools.
14320    #[serde(default)]
14321    pub enabled: bool,
14322    /// Directory containing incident response playbook definitions (JSON).
14323    #[serde(default = "default_playbooks_dir")]
14324    pub playbooks_dir: String,
14325    /// Automatically triage incoming alerts without user prompt.
14326    #[serde(default)]
14327    pub auto_triage: bool,
14328    /// Require human approval before executing playbook actions.
14329    #[serde(default = "default_require_approval")]
14330    pub require_approval_for_actions: bool,
14331    /// Maximum severity level that can be auto-remediated without approval.
14332    /// One of: "low", "medium", "high", "critical". Default: "low".
14333    #[serde(default = "default_max_auto_severity")]
14334    pub max_auto_severity: String,
14335    /// Directory for generated security reports.
14336    #[serde(default = "default_report_output_dir")]
14337    pub report_output_dir: String,
14338    /// Optional SIEM webhook URL for alert ingestion.
14339    #[serde(default)]
14340    pub siem_integration: Option<String>,
14341}
14342
14343fn default_playbooks_dir() -> String {
14344    default_path_under_config_dir("playbooks")
14345}
14346
14347fn default_require_approval() -> bool {
14348    true
14349}
14350
14351fn default_max_auto_severity() -> String {
14352    "low".into()
14353}
14354
14355fn default_report_output_dir() -> String {
14356    default_path_under_config_dir("security-reports")
14357}
14358
14359impl Default for SecurityOpsConfig {
14360    fn default() -> Self {
14361        Self {
14362            enabled: false,
14363            playbooks_dir: default_playbooks_dir(),
14364            auto_triage: false,
14365            require_approval_for_actions: true,
14366            max_auto_severity: default_max_auto_severity(),
14367            report_output_dir: default_report_output_dir(),
14368            siem_integration: None,
14369        }
14370    }
14371}
14372
14373// ── Config impl ──────────────────────────────────────────────────
14374
14375impl Default for Config {
14376    fn default() -> Self {
14377        let home =
14378            UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
14379        let zeroclaw_dir = home.join(".zeroclaw");
14380
14381        Self {
14382            data_dir: zeroclaw_dir.join("data"),
14383            config_path: zeroclaw_dir.join("config.toml"),
14384            env_overridden_paths: std::collections::HashSet::new(),
14385            pre_override_snapshots: std::collections::HashMap::new(),
14386            onepassword_reference_snapshots: std::collections::HashMap::new(),
14387            dirty_paths: std::collections::HashSet::new(),
14388            degraded_security: Vec::new(),
14389            schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
14390            providers: crate::providers::Providers::default(),
14391            model_routes: Vec::new(),
14392            embedding_routes: Vec::new(),
14393            observability: ObservabilityConfig::default(),
14394            trust: crate::scattered_types::TrustConfig::default(),
14395            backup: BackupConfig::default(),
14396            data_retention: DataRetentionConfig::default(),
14397            cloud_ops: CloudOpsConfig::default(),
14398            conversational_ai: ConversationalAiConfig::default(),
14399            security: SecurityConfig::default(),
14400            security_ops: SecurityOpsConfig::default(),
14401            runtime: RuntimeConfig::default(),
14402            reliability: ReliabilityConfig::default(),
14403            scheduler: SchedulerConfig::default(),
14404            pacing: PacingConfig::default(),
14405            skills: SkillsConfig::default(),
14406            pipeline: PipelineConfig::default(),
14407            heartbeat: HeartbeatConfig::default(),
14408            cron: HashMap::new(),
14409            acp: AcpConfig::default(),
14410            channels: ChannelsConfig::default(),
14411            memory: MemoryConfig::default(),
14412            storage: StorageConfig::default(),
14413            tunnel: TunnelConfig::default(),
14414            gateway: GatewayConfig::default(),
14415            wss: WssConfig::default(),
14416            composio: ComposioConfig::default(),
14417            microsoft365: Microsoft365Config::default(),
14418            secrets: SecretsConfig::default(),
14419            browser: BrowserConfig::default(),
14420            browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
14421            http_request: HttpRequestConfig::default(),
14422            multimodal: MultimodalConfig::default(),
14423            media_pipeline: MediaPipelineConfig::default(),
14424            web_fetch: WebFetchConfig::default(),
14425            link_enricher: LinkEnricherConfig::default(),
14426            text_browser: TextBrowserConfig::default(),
14427            web_search: WebSearchConfig::default(),
14428            project_intel: ProjectIntelConfig::default(),
14429            google_workspace: GoogleWorkspaceConfig::default(),
14430            proxy: ProxyConfig::default(),
14431            cost: CostConfig::default(),
14432            peripherals: PeripheralsConfig::default(),
14433            delegate: DelegateToolConfig::default(),
14434            agents: HashMap::new(),
14435            risk_profiles: HashMap::new(),
14436            runtime_profiles: HashMap::new(),
14437            skill_bundles: HashMap::new(),
14438            knowledge_bundles: HashMap::new(),
14439            mcp_bundles: HashMap::new(),
14440            peer_groups: HashMap::new(),
14441            hooks: HooksConfig::default(),
14442            hardware: HardwareConfig::default(),
14443            query_classification: QueryClassificationConfig::default(),
14444            transcription: TranscriptionConfig::default(),
14445            tts: TtsConfig::default(),
14446            mcp: McpConfig::default(),
14447            nodes: NodesConfig::default(),
14448            onboard_state: OnboardStateConfig::default(),
14449            notion: NotionConfig::default(),
14450            jira: JiraConfig::default(),
14451            node_transport: NodeTransportConfig::default(),
14452            knowledge: KnowledgeConfig::default(),
14453            linkedin: LinkedInConfig::default(),
14454            image_gen: ImageGenConfig::default(),
14455            file_upload: FileUploadConfig::default(),
14456            file_upload_bundle: FileUploadBundleConfig::default(),
14457            file_download: FileDownloadConfig::default(),
14458            plugins: PluginsConfig::default(),
14459            locale: None,
14460            verifiable_intent: VerifiableIntentConfig::default(),
14461            claude_code: ClaudeCodeConfig::default(),
14462            claude_code_runner: ClaudeCodeRunnerConfig::default(),
14463            codex_cli: CodexCliConfig::default(),
14464            gemini_cli: GeminiCliConfig::default(),
14465            opencode_cli: OpenCodeCliConfig::default(),
14466            sop: SopConfig::default(),
14467            shell_tool: ShellToolConfig::default(),
14468            escalation: EscalationConfig::default(),
14469        }
14470    }
14471}
14472
14473fn default_config_and_data_dirs() -> Result<(PathBuf, PathBuf)> {
14474    let config_dir = default_config_dir()?;
14475    // The second value is the shared instance data directory
14476    // (databases + state files). Per-agent identity + markdown lives
14477    // at `<config-dir>/agents/<alias>/workspace/`, resolved separately
14478    // via `Config::agent_workspace_dir`.
14479    Ok((config_dir.clone(), config_dir.join("data")))
14480}
14481
14482fn default_config_dir() -> Result<PathBuf> {
14483    if let Ok(custom) = std::env::var("ZEROCLAW_CONFIG_DIR") {
14484        let custom = custom.trim();
14485        if !custom.is_empty() {
14486            return Ok(expand_tilde_path(custom));
14487        }
14488    }
14489
14490    if let Ok(home) = std::env::var("HOME")
14491        && !home.is_empty()
14492    {
14493        return Ok(PathBuf::from(home).join(".zeroclaw"));
14494    }
14495
14496    let home = UserDirs::new()
14497        .map(|u| u.home_dir().to_path_buf())
14498        .context("Could not find home directory")?;
14499    Ok(home.join(".zeroclaw"))
14500}
14501
14502/// Canonical on-disk directory for a locale's runtime/zerocode FTL catalogues:
14503/// `<config_dir>/data/ftl/<locale>/`. This is where `zeroclaw locales fetch`
14504/// writes downloaded translations and where the runtime i18n loader reads them.
14505/// `<config_dir>` honors `ZEROCLAW_CONFIG_DIR` and otherwise defaults to
14506/// `~/.zeroclaw`. The zerocode binary mirrors this path inline (it carries no
14507/// `zeroclaw-*` dependency).
14508pub fn ftl_locale_dir(locale: &str) -> Result<PathBuf> {
14509    Ok(default_config_dir()?.join("data").join("ftl").join(locale))
14510}
14511
14512/// The FTL catalogues that `zeroclaw locales fetch` / the daemon's
14513/// `locales/fetch` RPC can download, as `(name, upstream-path-template,
14514/// output-filename)`. `{locale}` is substituted per request. This is the single
14515/// source of truth — a caller supplies only a catalog *name* matched against
14516/// this table, never a path.
14517pub const FTL_CATALOGS: &[(&str, &str, &str)] = &[
14518    (
14519        "cli",
14520        "crates/zeroclaw-runtime/locales/{locale}/cli.ftl",
14521        "cli.ftl",
14522    ),
14523    (
14524        "tools",
14525        "crates/zeroclaw-runtime/locales/{locale}/tools.ftl",
14526        "tools.ftl",
14527    ),
14528    (
14529        "zerocode",
14530        "apps/zerocode/locales/{locale}/zerocode.ftl",
14531        "zerocode.ftl",
14532    ),
14533];
14534
14535/// Build a default path string by joining `relative` onto the resolved
14536/// platform config dir. The form sees the resolved absolute path
14537/// (`/home/<user>/.zeroclaw/<relative>` on Linux,
14538/// `C:\Users\<user>\.zeroclaw\<relative>` on Windows, etc.) instead of a
14539/// literal `~/...` token that doesn't expand on Windows. Falls back to
14540/// `~/.zeroclaw/<relative>` if the platform dir can't be resolved (rare —
14541/// e.g. no HOME and `directories::UserDirs` returns None); the runtime's
14542/// `expand_tilde_path()` handles that literal at use-time.
14543///
14544/// Switching to platform-native config locations (`~/Library/Application
14545/// Support/zeroclaw/` on macOS, `%APPDATA%\zeroclaw\` on Windows) is the
14546/// schema-v3 follow-up tracked in #5947 — that needs a migration to move
14547/// existing users' configs.
14548fn default_path_under_config_dir(relative: &str) -> String {
14549    match default_config_dir() {
14550        Ok(dir) => dir.join(relative).to_string_lossy().into_owned(),
14551        Err(_) => format!("~/.zeroclaw/{relative}"),
14552    }
14553}
14554
14555pub fn resolve_config_dir_for_data(data_dir: &Path) -> (PathBuf, PathBuf) {
14556    let data_config_dir = data_dir.to_path_buf();
14557    if data_config_dir.join("config.toml").exists() {
14558        return (data_config_dir.clone(), data_config_dir.join("data"));
14559    }
14560
14561    let legacy_config_dir = data_dir.parent().map(|parent| parent.join(".zeroclaw"));
14562    if let Some(legacy_dir) = legacy_config_dir {
14563        if legacy_dir.join("config.toml").exists() {
14564            return (legacy_dir, data_config_dir);
14565        }
14566
14567        // Accept either the new "data" suffix or the legacy "workspace"
14568        // suffix; the V2->V3 filesystem migration renames the on-disk
14569        // dir but operator-set env-var paths from before the rename
14570        // still resolve correctly.
14571        if data_dir.file_name().is_some_and(|name| {
14572            name == std::ffi::OsStr::new("data") || name == std::ffi::OsStr::new("workspace")
14573        }) {
14574            return (legacy_dir, data_config_dir);
14575        }
14576    }
14577
14578    (data_config_dir.clone(), data_config_dir.join("data"))
14579}
14580
14581/// Resolve the current runtime config/data directories.
14582///
14583/// This mirrors the same precedence used by `Config::load_or_init()`:
14584/// `ZEROCLAW_CONFIG_DIR` > `ZEROCLAW_DATA_DIR` > `ZEROCLAW_WORKSPACE`
14585/// (deprecated) > defaults.
14586pub async fn resolve_runtime_dirs() -> Result<(PathBuf, PathBuf)> {
14587    let (default_zeroclaw_dir, default_data_dir) = default_config_and_data_dirs()?;
14588    let (config_dir, data_dir, _) =
14589        resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_data_dir).await?;
14590    Ok((config_dir, data_dir))
14591}
14592
14593#[derive(Clone, Copy, Debug, Eq, PartialEq)]
14594enum ConfigResolutionSource {
14595    EnvConfigDir,
14596    EnvDataDir,
14597    EnvWorkspaceLegacy,
14598    DefaultConfigDir,
14599    HomebrewConfigDir,
14600}
14601
14602impl ConfigResolutionSource {
14603    const fn as_str(self) -> &'static str {
14604        match self {
14605            Self::EnvConfigDir => "ZEROCLAW_CONFIG_DIR",
14606            Self::EnvDataDir => "ZEROCLAW_DATA_DIR",
14607            Self::EnvWorkspaceLegacy => "ZEROCLAW_WORKSPACE",
14608            Self::DefaultConfigDir => "default",
14609            Self::HomebrewConfigDir => "homebrew",
14610        }
14611    }
14612}
14613
14614/// Expand tilde in paths, falling back to `UserDirs` when HOME is unset.
14615///
14616/// In non-TTY environments (e.g. cron), HOME may not be set, causing
14617/// `shellexpand::tilde` to return the literal `~` unexpanded. This helper
14618/// detects that case and uses `directories::UserDirs` as a fallback.
14619fn expand_tilde_path(path: &str) -> PathBuf {
14620    let expanded = shellexpand::tilde(path);
14621    let expanded_str = expanded.as_ref();
14622
14623    // If the path still starts with '~', tilde expansion failed (HOME unset)
14624    if expanded_str.starts_with('~') {
14625        if let Some(user_dirs) = UserDirs::new() {
14626            let home = user_dirs.home_dir();
14627            // Replace leading ~ with home directory
14628            if let Some(rest) = expanded_str.strip_prefix('~') {
14629                return home.join(rest.trim_start_matches(['/', '\\']));
14630            }
14631        }
14632        // If UserDirs also fails, log a warning and use the literal path
14633        ::zeroclaw_log::record!(
14634            WARN,
14635            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14636                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
14637                .with_attrs(::serde_json::json!({"path": path})),
14638            "Failed to expand tilde: HOME environment variable is not set and UserDirs failed. \
14639             In cron/non-TTY environments, use absolute paths or set HOME explicitly."
14640        );
14641    }
14642
14643    PathBuf::from(expanded_str)
14644}
14645
14646/// Detect if an executable path lives under a macOS Homebrew prefix and return
14647/// the Homebrew-managed config directory.
14648///
14649/// Homebrew can execute ZeroClaw from `<prefix>/Cellar/zeroclaw/<version>/bin/`,
14650/// `<prefix>/bin/`, or `<prefix>/opt/zeroclaw/bin/`.
14651async fn try_resolve_macos_homebrew_config_dir(exe: &Path) -> Option<PathBuf> {
14652    let parts = exe.iter().collect::<Vec<_>>();
14653    let prefix = match parts.as_slice() {
14654        [prefix @ .., cellar, formula, _version, bin, exe_name]
14655            if *cellar == std::ffi::OsStr::new("Cellar")
14656                && *formula == std::ffi::OsStr::new("zeroclaw")
14657                && *bin == std::ffi::OsStr::new("bin")
14658                && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
14659        {
14660            prefix.iter().collect::<PathBuf>()
14661        }
14662        [prefix @ .., opt, formula, bin, exe_name]
14663            if *opt == std::ffi::OsStr::new("opt")
14664                && *formula == std::ffi::OsStr::new("zeroclaw")
14665                && *bin == std::ffi::OsStr::new("bin")
14666                && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
14667        {
14668            let prefix = prefix.iter().collect::<PathBuf>();
14669            if !prefix.as_os_str().is_empty()
14670                && fs::metadata(prefix.join("Cellar"))
14671                    .await
14672                    .is_ok_and(|metadata| metadata.is_dir())
14673            {
14674                prefix
14675            } else {
14676                return None;
14677            }
14678        }
14679        [prefix @ .., bin, exe_name]
14680            if *bin == std::ffi::OsStr::new("bin")
14681                && *exe_name == std::ffi::OsStr::new("zeroclaw") =>
14682        {
14683            let prefix = prefix.iter().collect::<PathBuf>();
14684            if !prefix.as_os_str().is_empty()
14685                && fs::metadata(prefix.join("Cellar"))
14686                    .await
14687                    .is_ok_and(|metadata| metadata.is_dir())
14688            {
14689                prefix
14690            } else {
14691                return None;
14692            }
14693        }
14694        _ => return None,
14695    };
14696    Some(prefix.join("var").join("zeroclaw"))
14697}
14698
14699async fn resolve_runtime_config_dirs(
14700    default_zeroclaw_dir: &Path,
14701    default_data_dir: &Path,
14702) -> Result<(PathBuf, PathBuf, ConfigResolutionSource)> {
14703    if let Ok(custom_config_dir) = std::env::var("ZEROCLAW_CONFIG_DIR") {
14704        let custom_config_dir = custom_config_dir.trim();
14705        if !custom_config_dir.is_empty() {
14706            // If the operator ALSO set ZEROCLAW_DATA_DIR or
14707            // ZEROCLAW_WORKSPACE, CONFIG_DIR wins; surface the
14708            // collision so they know which one took effect.
14709            if std::env::var("ZEROCLAW_DATA_DIR")
14710                .ok()
14711                .filter(|v| !v.trim().is_empty())
14712                .is_some()
14713            {
14714                ::zeroclaw_log::record!(
14715                    WARN,
14716                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14717                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
14718                    "ZEROCLAW_CONFIG_DIR is set; ZEROCLAW_DATA_DIR is ignored \
14719                     (CONFIG_DIR pins both the config directory and the data \
14720                     directory under it)."
14721                );
14722            }
14723            if std::env::var("ZEROCLAW_WORKSPACE")
14724                .ok()
14725                .filter(|v| !v.is_empty())
14726                .is_some()
14727            {
14728                ::zeroclaw_log::record!(
14729                    WARN,
14730                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14731                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
14732                    "ZEROCLAW_CONFIG_DIR is set; ZEROCLAW_WORKSPACE (deprecated) \
14733                     is ignored. ZEROCLAW_WORKSPACE will be removed in a future \
14734                     release; switch any remaining references to ZEROCLAW_DATA_DIR."
14735                );
14736            }
14737            let zeroclaw_dir = expand_tilde_path(custom_config_dir);
14738            return Ok((
14739                zeroclaw_dir.clone(),
14740                zeroclaw_dir.join("data"),
14741                ConfigResolutionSource::EnvConfigDir,
14742            ));
14743        }
14744    }
14745
14746    if let Ok(custom_data) = std::env::var("ZEROCLAW_DATA_DIR")
14747        && !custom_data.trim().is_empty()
14748    {
14749        if std::env::var("ZEROCLAW_WORKSPACE")
14750            .ok()
14751            .filter(|v| !v.is_empty())
14752            .is_some()
14753        {
14754            ::zeroclaw_log::record!(
14755                WARN,
14756                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14757                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
14758                "ZEROCLAW_DATA_DIR and ZEROCLAW_WORKSPACE are both set; \
14759                 ZEROCLAW_WORKSPACE (deprecated) is ignored. \
14760                 ZEROCLAW_WORKSPACE will be removed in a future release."
14761            );
14762        }
14763        let expanded = expand_tilde_path(&custom_data);
14764        let (zeroclaw_dir, data_dir) = resolve_config_dir_for_data(&expanded);
14765        return Ok((zeroclaw_dir, data_dir, ConfigResolutionSource::EnvDataDir));
14766    }
14767
14768    if let Ok(custom_workspace) = std::env::var("ZEROCLAW_WORKSPACE")
14769        && !custom_workspace.is_empty()
14770    {
14771        ::zeroclaw_log::record!(
14772            WARN,
14773            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
14774                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
14775            "ZEROCLAW_WORKSPACE is deprecated; use ZEROCLAW_DATA_DIR instead. \
14776             ZEROCLAW_WORKSPACE will be removed in a future release."
14777        );
14778        let expanded = expand_tilde_path(&custom_workspace);
14779        let (zeroclaw_dir, data_dir) = resolve_config_dir_for_data(&expanded);
14780        return Ok((
14781            zeroclaw_dir,
14782            data_dir,
14783            ConfigResolutionSource::EnvWorkspaceLegacy,
14784        ));
14785    }
14786
14787    if cfg!(target_os = "macos")
14788        && let Ok(exe) = std::env::current_exe()
14789        && let Some(homebrew_config_dir) = try_resolve_macos_homebrew_config_dir(&exe).await
14790    {
14791        return Ok((
14792            homebrew_config_dir.clone(),
14793            homebrew_config_dir.join("workspace"),
14794            ConfigResolutionSource::HomebrewConfigDir,
14795        ));
14796    }
14797
14798    Ok((
14799        default_zeroclaw_dir.to_path_buf(),
14800        default_data_dir.to_path_buf(),
14801        ConfigResolutionSource::DefaultConfigDir,
14802    ))
14803}
14804
14805fn config_dir_creation_error(path: &Path) -> String {
14806    format!(
14807        "Failed to create config directory: {}. If running as an OpenRC service, \
14808         ensure this path is writable by user 'zeroclaw'.",
14809        path.display()
14810    )
14811}
14812
14813/// Top-level keys that must always appear in the saved config even
14814/// when their value equals the default. `schema_version` is the
14815/// migration detector's anchor — dropping it from a freshly-saved
14816/// config would make the next load mis-detect the file as V1 (no
14817/// version key = V1).
14818const SAVE_PRESERVE_KEYS: &[&str] = &["schema_version"];
14819
14820/// Insert a blank line before every `[section]` header that doesn't
14821/// already have one, so the serialized TOML reads as discrete blocks
14822/// instead of running every section header directly after the
14823/// previous line (`toml::to_string_pretty` doesn't gap between a
14824/// trailing scalar and the next section header).
14825fn ensure_blank_line_before_sections(toml: &str) -> String {
14826    let mut out = String::with_capacity(toml.len() + 64);
14827    let mut prev_line_blank = true; // start of file counts as blank
14828    for line in toml.lines() {
14829        let is_section_header = line.starts_with('[');
14830        if is_section_header && !prev_line_blank {
14831            out.push('\n');
14832        }
14833        out.push_str(line);
14834        out.push('\n');
14835        prev_line_blank = line.trim().is_empty();
14836    }
14837    out
14838}
14839
14840/// Walk `actual` and drop every key whose value matches the same
14841/// key's value in `defaults`. Tables recurse; the recursion drops a
14842/// sub-table when every one of its keys was itself dropped (i.e. the
14843/// sub-table contained only defaults). Keys that don't appear in
14844/// `defaults` are operator-added and always survive.
14845///
14846/// HashMap-keyed sub-trees (e.g. `agents`, `providers.models.<family>`)
14847/// are not in the typed default tree, so their operator-added aliases
14848/// pass through this filter unchanged.
14849fn prune_default_values(actual: &mut toml::Table, defaults: &toml::Table) {
14850    let keys: Vec<String> = actual.keys().cloned().collect();
14851    for key in keys {
14852        if SAVE_PRESERVE_KEYS.contains(&key.as_str()) {
14853            continue;
14854        }
14855        let Some(default_value) = defaults.get(&key) else {
14856            // Operator added this key; not in the typed default tree.
14857            // Always keep — recursing in would either be a no-op or
14858            // strip operator content.
14859            continue;
14860        };
14861        let Some(child) = actual.remove(&key) else {
14862            continue;
14863        };
14864        let pruned = match (child, default_value) {
14865            (toml::Value::Table(mut child_table), toml::Value::Table(default_subtable)) => {
14866                prune_default_values(&mut child_table, default_subtable);
14867                if child_table.is_empty() {
14868                    None
14869                } else {
14870                    Some(toml::Value::Table(child_table))
14871                }
14872            }
14873            (child, default_value) => {
14874                if &child == default_value {
14875                    None
14876                } else {
14877                    Some(child)
14878                }
14879            }
14880        };
14881        if let Some(value) = pruned {
14882            actual.insert(key, value);
14883        }
14884    }
14885}
14886
14887fn is_local_ollama_endpoint(api_url: Option<&str>) -> bool {
14888    let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
14889        return true;
14890    };
14891
14892    reqwest::Url::parse(raw)
14893        .ok()
14894        .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
14895        .is_some_and(|host| matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1" | "0.0.0.0"))
14896}
14897
14898fn is_official_ollama_cloud_endpoint(api_url: Option<&str>) -> bool {
14899    let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
14900        return false;
14901    };
14902
14903    reqwest::Url::parse(raw)
14904        .ok()
14905        .and_then(|url| {
14906            url.host_str().map(|host| {
14907                host.eq_ignore_ascii_case("ollama.com")
14908                    || host.eq_ignore_ascii_case("api.ollama.com")
14909            })
14910        })
14911        .unwrap_or(false)
14912}
14913
14914fn has_ollama_cloud_credential(config_api_key: Option<&str>) -> bool {
14915    config_api_key
14916        .map(str::trim)
14917        .is_some_and(|value| !value.is_empty())
14918}
14919
14920/// Ensure that essential bootstrap files exist in the workspace directory.
14921///
14922/// When the workspace is created outside of Quickstart (e.g., non-tty
14923/// daemon/cron sessions), these files would otherwise be missing. This function
14924/// creates sensible defaults that allow the agent to operate with a basic identity.
14925pub async fn ensure_bootstrap_files(workspace_dir: &Path) -> Result<()> {
14926    let defaults: &[(&str, &str)] = &[
14927        (
14928            "IDENTITY.md",
14929            "# IDENTITY.md — Who Am I?\n\n\
14930             I am ZeroClaw, an autonomous AI agent.\n\n\
14931             ## Traits\n\
14932             - Helpful, precise, and safety-conscious\n\
14933             - I prioritize clarity and correctness\n",
14934        ),
14935        (
14936            "SOUL.md",
14937            "# SOUL.md — Who You Are\n\n\
14938             You are ZeroClaw, an autonomous AI agent.\n\n\
14939             ## Core Principles\n\
14940             - Be helpful and accurate\n\
14941             - Respect user intent and boundaries\n\
14942             - Ask before taking destructive actions\n\
14943             - Prefer safe, reversible operations\n",
14944        ),
14945    ];
14946
14947    for (filename, content) in defaults {
14948        let path = workspace_dir.join(filename);
14949        if !path.exists() {
14950            fs::write(&path, content)
14951                .await
14952                .with_context(|| format!("Failed to create default {filename} in workspace"))?;
14953        }
14954    }
14955
14956    Ok(())
14957}
14958
14959impl Config {
14960    /// External-peer usernames authorized on `<channel_type>.<alias>`.
14961    ///
14962    /// A `[peer_groups.<name>]` contributes when its `channel` field either
14963    /// matches `channel_type` (type-wide group, applies to every alias of
14964    /// that type) or matches the full dotted `"<channel_type>.<alias>"`
14965    /// (instance-scoped group, applies to that one alias only).
14966    pub fn channel_external_peers(&self, channel_type: &str, alias: &str) -> Vec<String> {
14967        let mut out: Vec<String> = Vec::new();
14968        let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
14969        for group in self.peer_groups.values() {
14970            let group_matches = match group.channel.split_once('.') {
14971                Some((ty, al)) => ty == channel_type && al == alias,
14972                None => group.channel == channel_type,
14973            };
14974            if !group_matches {
14975                continue;
14976            }
14977            for peer in &group.external_peers {
14978                let username = peer.as_str().to_string();
14979                if seen.insert(username.clone()) {
14980                    out.push(username);
14981                }
14982            }
14983        }
14984        out
14985    }
14986
14987    /// Collect the `IntegrationDescriptor` from every nested config that
14988    /// declares one via `#[integration(...)]`. Adding a new toggleable
14989    /// integration is one struct-level attribute on the new config + one
14990    /// row in this method. The integrations registry consumes the result
14991    /// without per-vendor branches.
14992    pub fn integration_descriptors(&self) -> Vec<crate::config::IntegrationDescriptor> {
14993        // BrowserConfig and GoogleWorkspaceConfig carry
14994        // `#[integration(...)]` annotations on V3, so the macro emits
14995        // `integration_descriptor()` on each. Cron has been flattened
14996        // to `HashMap<String, CronJobDecl>` with no enable toggle, so
14997        // it gets a hand-crafted descriptor whose `active` reflects
14998        // whether any job is configured. Display copy lives next to
14999        // the field so the registry never branches on a category name.
15000        vec![
15001            self.browser.integration_descriptor(),
15002            self.google_workspace.integration_descriptor(),
15003            crate::config::IntegrationDescriptor {
15004                display_name: "Cron",
15005                description: "Scheduled tasks",
15006                category: "ToolsAutomation",
15007                active: !self.cron.is_empty(),
15008            },
15009        ]
15010    }
15011
15012    /// Return top-level TOML keys in `raw_toml` that Config does not recognise.
15013    ///
15014    /// Keys present in `Config::default()` serialization pass immediately.
15015    /// Remaining keys are probed: the key is deserialized in isolation and
15016    /// the result compared to the default — a changed output means serde
15017    /// consumed it (covers `Option<T>` fields and `#[serde(alias)]` names).
15018    /// V1 legacy keys (consumed by migration) are also accepted.
15019    pub fn unknown_keys(raw_toml: &str) -> Vec<String> {
15020        let raw: toml::Table = match raw_toml.parse() {
15021            Ok(t) => t,
15022            Err(_) => return Vec::new(),
15023        };
15024        static DEFAULTS: OnceLock<toml::Table> = OnceLock::new();
15025        let defaults = DEFAULTS.get_or_init(|| {
15026            toml::to_string(&Config::default())
15027                .ok()
15028                .and_then(|s| s.parse().ok())
15029                .unwrap_or_default()
15030        });
15031        raw.keys()
15032            .filter(|key| {
15033                if defaults.contains_key(key.as_str()) {
15034                    return false;
15035                }
15036                if crate::migration::V1_LEGACY_KEYS.contains(&key.as_str()) {
15037                    return false;
15038                }
15039                let mut t = toml::Table::new();
15040                t.insert((*key).clone(), raw[key.as_str()].clone());
15041                let consumed = toml::to_string(&t)
15042                    .ok()
15043                    .and_then(|s| toml::from_str::<Config>(&s).ok())
15044                    .and_then(|c| toml::to_string(&c).ok())
15045                    .and_then(|s| s.parse::<toml::Table>().ok())
15046                    .is_some_and(|t| t != *defaults);
15047                !consumed
15048            })
15049            .cloned()
15050            .collect()
15051    }
15052
15053    /// Return `<kind>.<family>` entries under `[providers]` in `raw_toml`
15054    /// whose family is not a known typed slot (kinds: models, tts,
15055    /// transcription). Serde silently drops these sections at deserialize
15056    /// time, so a typo'd family (`[providers.models.antropic.x]`) or one
15057    /// from a newer binary vanishes on reload: the alias "works" in the
15058    /// session that created it, then disappears after restart.
15059    pub fn unknown_provider_families(raw_toml: &str) -> Vec<String> {
15060        let raw: toml::Table = match raw_toml.parse() {
15061            Ok(t) => t,
15062            Err(_) => return Vec::new(),
15063        };
15064        let Some(kinds) = raw.get("providers").and_then(toml::Value::as_table) else {
15065            return Vec::new();
15066        };
15067        let kind_slots: &[(&str, &[&str])] = &[
15068            ("models", crate::providers::ModelProviders::slot_names()),
15069            ("tts", crate::providers::TtsProviders::slot_names()),
15070            (
15071                "transcription",
15072                crate::providers::TranscriptionProviders::slot_names(),
15073            ),
15074        ];
15075        let mut out = Vec::new();
15076        for (kind, slots) in kind_slots {
15077            let Some(families) = kinds.get(*kind).and_then(toml::Value::as_table) else {
15078                continue;
15079            };
15080            out.extend(
15081                families
15082                    .keys()
15083                    .filter(|k| !slots.contains(&k.as_str()))
15084                    .map(|k| format!("{kind}.{k}")),
15085            );
15086        }
15087        out
15088    }
15089
15090    /// Returns `true` if `path` was populated by a `ZEROCLAW_*` env-var
15091    /// override at load time. O(1) HashSet lookup; safe to call per row in
15092    /// list-rendering paths (`config list`, dashboard, quickstart).
15093    pub fn prop_is_env_overridden(&self, path: &str) -> bool {
15094        self.env_overridden_paths.contains(path)
15095    }
15096
15097    pub async fn load_or_init() -> Result<Self> {
15098        let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_data_dirs()?;
15099
15100        // Resolve env overrides FIRST so the migration runs against
15101        // the install root the operator actually uses. Running the
15102        // migration against `default_zeroclaw_dir` would silently skip
15103        // any install reached via `ZEROCLAW_CONFIG_DIR` or
15104        // `ZEROCLAW_WORKSPACE`.
15105        let (zeroclaw_dir, _legacy_workspace_dir, resolution_source) =
15106            resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
15107
15108        // One-time, V<3 → V3 ONLY move of `<install>/workspace/` into
15109        // `<install>/agents/default/workspace/`. The "default" alias is
15110        // the migration bridge — it must NEVER appear on a fresh install
15111        // or on a V3 install that already declared its own aliases.
15112        //
15113        // Gate strictly on the on-disk config's `schema_version`:
15114        // - missing config.toml      → fresh install, skip.
15115        // - schema_version >= 3      → already V3, skip.
15116        // - schema_version 1 or 2    → upgrade in progress, run.
15117        // Anything else (parse failure, weird value) is treated as
15118        // "don't touch the filesystem"; the TOML migrator will surface
15119        // the real error.
15120        let config_toml_path = zeroclaw_dir.join("config.toml");
15121        let needs_fs_migration = config_toml_path.is_file()
15122            && matches!(
15123                std::fs::read_to_string(&config_toml_path)
15124                    .ok()
15125                    .and_then(|raw| toml::from_str::<toml::Value>(&raw).ok())
15126                    .and_then(|v| crate::migration::detect_version(&v).ok()),
15127                Some(v) if v < crate::migration::CURRENT_SCHEMA_VERSION
15128            );
15129        if needs_fs_migration
15130            && let Err(e) = crate::schema::v2::migrate_v2_to_v3_install_filesystem(&zeroclaw_dir)
15131        {
15132            ::zeroclaw_log::record!(
15133                WARN,
15134                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15135                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15136                    .with_attrs(::serde_json::json!({
15137                        "install": zeroclaw_dir.display().to_string(),
15138                        "error": format!("{}", e),
15139                    })),
15140                "[system] filesystem migration failed; continuing with legacy layout"
15141            );
15142        } else if !needs_fs_migration
15143            && let Err(e) =
15144                crate::schema::v2::relocate_default_agent_skills_to_shared(&zeroclaw_dir)
15145        {
15146            ::zeroclaw_log::record!(
15147                WARN,
15148                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15149                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15150                    .with_attrs(::serde_json::json!({
15151                        "install": zeroclaw_dir.display().to_string(),
15152                        "error": format!("{}", e),
15153                    })),
15154                "[system] skills relocation to shared workspace failed; continuing"
15155            );
15156        }
15157
15158        let config_path = zeroclaw_dir.join("config.toml");
15159
15160        // The install dir is the only directory `load_or_init` creates
15161        // unconditionally. Per-agent workspaces (`agents/<alias>/workspace/`)
15162        // are seeded lazily at agent-loop entry by
15163        // `Agent::from_config_with_session_cwd_and_mcp`, which runs
15164        // `ensure_bootstrap_files` for the agent it is starting. There
15165        // is no fresh-install "default" agent and therefore no
15166        // `agents/default/workspace/` synthesized at boot; the only
15167        // legitimate origin for that directory is the V1/V2→V3
15168        // legacy-workspace migration above, which fires only when a
15169        // pre-multi-agent install's `<install>/workspace/` is present
15170        // and needs to be moved into the new layout.
15171        //
15172        // `config.data_dir` resolves to `<install>/data/` — the shared
15173        // instance data directory holding databases (memory, sessions,
15174        // cost records) and hygiene/state files. Per-agent identity
15175        // and markdown (MEMORY.md, IDENTITY.md, SOUL.md) lives at
15176        // `Config::agent_workspace_dir(alias)` instead.
15177        let data_dir = zeroclaw_dir.join("data");
15178        fs::create_dir_all(&data_dir).await.with_context(|| {
15179            format!(
15180                "Failed to create data directory: {}",
15181                data_dir.display().to_string()
15182            )
15183        })?;
15184        // Legacy alias retained for clarity in the struct initializer
15185        // and existing field assignments below.
15186        let workspace_dir = data_dir;
15187
15188        // `<install>/shared/` — root workspace shared across every agent
15189        // on the host. Holds skills, skill bundles, and other content
15190        // not scoped to a single agent. Per-agent state still lives at
15191        // `<install>/agents/<alias>/workspace/`.
15192        let shared_dir = zeroclaw_dir.join("shared");
15193        fs::create_dir_all(&shared_dir).await.with_context(|| {
15194            format!(
15195                "Failed to create shared workspace directory: {}",
15196                shared_dir.display()
15197            )
15198        })?;
15199
15200        fs::create_dir_all(&zeroclaw_dir)
15201            .await
15202            .with_context(|| config_dir_creation_error(&zeroclaw_dir))?;
15203
15204        if config_path.exists() {
15205            // Warn if config file is world-readable (may contain API keys)
15206            #[cfg(unix)]
15207            {
15208                use std::os::unix::fs::PermissionsExt;
15209                if let Ok(meta) = fs::metadata(&config_path).await
15210                    && meta.permissions().mode() & 0o004 != 0
15211                {
15212                    ::zeroclaw_log::record!(
15213                        WARN,
15214                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15215                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
15216                        &format!(
15217                            "Config file {:?} is world-readable (mode {:o}). \
15218                             Consider restricting with: chmod 600 {:?}",
15219                            config_path,
15220                            meta.permissions().mode() & 0o777,
15221                            config_path
15222                        )
15223                    );
15224                }
15225            }
15226
15227            let contents = fs::read_to_string(&config_path)
15228                .await
15229                .context("Failed to read config file")?;
15230
15231            // Deserialize the config with the standard TOML parser.
15232            //
15233            // Previously this used `serde_ignored::deserialize` for both
15234            // deserialization and unknown-key detection.  However,
15235            // `serde_ignored` silently drops field values inside nested
15236            // structs that carry `#[serde(default)]` (e.g. the entire
15237            // `[autonomy]` table), causing user-supplied values to be
15238            // replaced by defaults.
15239            //
15240            // We now deserialize with `toml::from_str` (which is correct)
15241            // and run `serde_ignored` separately just for diagnostics.
15242            //
15243            // `migrate_to_current` parses the TOML, detects the schema
15244            // version, runs the typed V1→V2→V3 chain via `V1Config::migrate`
15245            // / `V2Config::migrate`, and deserializes the result into the
15246            // current `Config` shape.
15247            //
15248            // Detect the on-disk version up-front so we can emit one WARN
15249            // line when the daemon auto-migrates an older config in memory:
15250            // the disk file is left untouched and the user is advised to lock
15251            // the migration in with `zeroclaw config migrate`.
15252            let stale_version = toml::from_str::<toml::Value>(&contents)
15253                .ok()
15254                .as_ref()
15255                .and_then(|v| crate::migration::detect_version(v).ok())
15256                .filter(|n| *n != crate::migration::CURRENT_SCHEMA_VERSION);
15257            // Daemon load must never hard-fail on a malformed config — the
15258            // operator needs the process up to repair it. The resilient path
15259            // degrades (dropping invalid blocks to defaults); security-critical
15260            // drops are recorded on `degraded_security` for exposure gating.
15261            // Strict validation lives in `zeroclaw config migrate`.
15262            let salvage = crate::migration::migrate_to_current_salvaged(&contents);
15263            let mut config: Config = salvage.config;
15264            config.degraded_security = salvage.dropped_security;
15265            if let Some(from_version) = stale_version {
15266                ::zeroclaw_log::record!(
15267                    WARN,
15268                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15269                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
15270                    &format!(
15271                        "Config at {} is schema_version {from_version}; auto-migrated to {} in memory. \
15272                     Run `zeroclaw config migrate` to commit the migration to disk. \
15273                     V0.8.0 also replaced the env-var override grammar; see \
15274                     https://github.com/zeroclaw-labs/zeroclaw/blob/master/docs/book/src/reference/env-vars.md \
15275                     for the migration recipes.",
15276                        config_path.display().to_string(),
15277                        crate::migration::CURRENT_SCHEMA_VERSION
15278                    )
15279                );
15280            }
15281
15282            // Ensure the built-in default auto_approve entries are always
15283            // present on a `risk_profiles.default` entry that already
15284            // exists (typically post-V1/V2→V3 migration). When a user
15285            // specifies `auto_approve` in their TOML (e.g. to add a
15286            // custom tool), serde replaces the default list instead of
15287            // merging — this re-adds the framework defaults so safe
15288            // tools like `weather` and `calculator` keep their
15289            // auto-approve status.
15290            //
15291            // Users who want to require approval for a default tool can
15292            // add it to `always_ask`, which takes precedence over
15293            // `auto_approve` in the approval decision (see approval/mod.rs).
15294            //
15295            // Skipped when the loaded config has no `risk_profiles.default`
15296            // entry: we will not synthesize a `default` alias here.
15297            // `default` is a migration artifact (V1/V2→V3
15298            // single-instance bridge); a config that arrives without it
15299            // is a legitimate multi-aliased shape and must not have one
15300            // injected at load time.
15301            if let Some(default_profile) = config.risk_profiles.get_mut("default") {
15302                default_profile.ensure_default_auto_approve();
15303            }
15304
15305            // Detect unknown top-level config keys by comparing the raw
15306            // TOML table keys against what Config actually deserializes.
15307            // This replaces the previous serde_ignored-based approach which
15308            // had false-positive issues with #[serde(default)] nested structs.
15309            for key in Self::unknown_keys(&contents) {
15310                ::zeroclaw_log::record!(
15311                    WARN,
15312                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15313                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15314                        .with_attrs(::serde_json::json!({"key": key})),
15315                    "Unknown config key ignored: \"\". Check config.toml for typos or deprecated options."
15316                );
15317            }
15318            // Unknown provider families are dropped by serde, so an alias
15319            // created under a typo'd or unsupported family silently vanishes
15320            // on reload while agents.*.model_provider still references it.
15321            for entry in Self::unknown_provider_families(&contents) {
15322                let (kind, family) = entry.split_once('.').unwrap_or(("models", entry.as_str()));
15323                let reference = if kind == "models" {
15324                    "any agents.*.model_provider referencing them will fail to resolve; \
15325                     run `zeroclaw providers` for valid family names"
15326                } else {
15327                    "references to its aliases will fail to resolve"
15328                };
15329                ::zeroclaw_log::record!(
15330                    WARN,
15331                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15332                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15333                        .with_attrs(::serde_json::json!({"kind": kind, "family": family})),
15334                    &format!(
15335                        "[providers.{kind}.{family}] section dropped: not a known {kind} \
15336                         provider family. Its aliases will not load and {reference}."
15337                    )
15338                );
15339            }
15340            // Set computed paths that are skipped during serialization
15341            config.config_path = config_path.clone();
15342            config.data_dir = workspace_dir;
15343
15344            // Ensure each configured skill-bundle's resolved directory
15345            // exists on disk so the bundle has somewhere for skills to
15346            // land immediately. Idempotent.
15347            let install_root = config.install_root_dir();
15348            for alias in config.skill_bundles.keys().cloned().collect::<Vec<_>>() {
15349                if let Ok(dir) =
15350                    crate::skill_bundles::resolve_directory(&config, &install_root, &alias)
15351                    && let Err(e) = std::fs::create_dir_all(&dir)
15352                {
15353                    ::zeroclaw_log::record!(
15354                        WARN,
15355                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15356                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
15357                        &format!(
15358                            "skill-bundle '{alias}' directory creation failed at {}: {e}",
15359                            dir.display().to_string()
15360                        )
15361                    );
15362                }
15363            }
15364
15365            let store = crate::secrets::SecretStore::new(&zeroclaw_dir, config.secrets.encrypt);
15366            config.onepassword_reference_snapshots =
15367                collect_onepassword_reference_snapshots(&config);
15368            // Decrypt all #[secret]-annotated fields via Configurable derive
15369            config = tokio::task::spawn_blocking(move || {
15370                config.decrypt_secrets(&store)?;
15371                Ok::<_, anyhow::Error>(config)
15372            })
15373            .await
15374            .context("Config secret decryption task failed")??;
15375
15376            // Apply ZEROCLAW_<lowercase_path> env-var overrides. Hard-errors
15377            // on any unresolvable path — no silent ignores. Tracks overridden
15378            // paths and per-path pre-override snapshots so save() can mask
15379            // env-injected values back to the original on-disk state.
15380            let applied = crate::env_overrides::apply_env_overrides(&mut config)?;
15381            config.env_overridden_paths = applied.paths;
15382            config.pre_override_snapshots = applied.snapshots;
15383
15384            // Validation must NOT prevent the daemon from booting. If
15385            // it did, a single broken agent reference would lock the
15386            // operator out of `/config` — the only place they can fix
15387            // it. Demote to a startup warning; the gateway and dashboard
15388            // still come up so the user can navigate to the bad section
15389            // and repair it.
15390            if let Err(e) = config.validate() {
15391                ::zeroclaw_log::record!(
15392                    WARN,
15393                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15394                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15395                        .with_attrs(::serde_json::json!({"error": format!("{e:#}")})),
15396                    "[system] config has validation errors — booting anyway so you \
15397                     can fix them via /config or `zeroclaw config set`"
15398                );
15399            }
15400            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"path": config.config_path.display().to_string(), "workspace": config.data_dir.display().to_string(), "source": resolution_source.as_str(), "initialized": true})), "Config loaded");
15401            Ok(config)
15402        } else {
15403            let mut config = Config {
15404                config_path: config_path.clone(),
15405                data_dir: workspace_dir,
15406                ..Config::default()
15407            };
15408            // Save defaults FIRST so env-injected values never reach the
15409            // freshly-created config file. Env overrides apply post-save to
15410            // populate the in-memory Config for the running process.
15411            config.save().await?;
15412
15413            // Restrict permissions on newly created config file (may contain API keys)
15414            #[cfg(unix)]
15415            {
15416                use std::{fs::Permissions, os::unix::fs::PermissionsExt};
15417                let _ = fs::set_permissions(&config_path, Permissions::from_mode(0o600)).await;
15418            }
15419
15420            let applied = crate::env_overrides::apply_env_overrides(&mut config)?;
15421            config.env_overridden_paths = applied.paths;
15422            config.pre_override_snapshots = applied.snapshots;
15423
15424            // Same boot-resilience as the load-existing branch above:
15425            // a fresh-init config can't realistically fail validation,
15426            // but if it does we still want the daemon up.
15427            if let Err(e) = config.validate() {
15428                ::zeroclaw_log::record!(
15429                    WARN,
15430                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15431                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15432                        .with_attrs(::serde_json::json!({"error": format!("{e:#}")})),
15433                    "[system] freshly-initialized config has validation errors — \
15434                     booting anyway so you can fix them via /config"
15435                );
15436            }
15437            ::zeroclaw_log::record!(INFO, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"path": config.config_path.display().to_string(), "workspace": config.data_dir.display().to_string(), "source": resolution_source.as_str(), "initialized": true})), "Config loaded");
15438            Ok(config)
15439        }
15440    }
15441
15442    /// Collect non-fatal validation warnings — config that loads and
15443    /// validates successfully (`validate()` returns `Ok(())`) but will fail
15444    /// at runtime because of a logical inconsistency the schema cannot
15445    /// enforce structurally.
15446    ///
15447    /// Called by `validate()` (which emits each warning via `tracing::warn!`
15448    /// for log visibility) and by the gateway HTTP API (which returns the
15449    /// structured list in `PropResponse` / `PatchResponse` so dashboard
15450    /// callers see the same signal the CLI sees on stderr).
15451    ///
15452    /// Adding a new warning: append a check here, pick a stable `code`,
15453    /// and document the code in `validation_warnings.rs`.
15454    pub fn collect_warnings(&self) -> Vec<crate::validation_warnings::ValidationWarning> {
15455        let mut warnings = Vec::new();
15456        self.collect_fallback_warnings(&mut warnings);
15457        // `wire_api` is only honored by bring-your-own-endpoint families; on a
15458        // branded family with a fixed wire protocol it is silently ignored.
15459        // Surface that so an operator who sets it on, e.g., `mistral` learns it
15460        // has no effect instead of debugging unexpected runtime behavior.
15461        for (family, alias, entry) in self.providers.models.iter_entries() {
15462            if entry.wire_api.is_some() && !crate::provider_aliases::family_honors_wire_api(family)
15463            {
15464                warnings.push(crate::validation_warnings::ValidationWarning::new(
15465                    "wire_api_not_supported_for_family",
15466                    format!(
15467                        "wire_api is set on `{family}.{alias}` but the `{family}` family has a \
15468                         fixed wire protocol and ignores it. wire_api only takes effect on the \
15469                         openai, llamacpp, and custom (openai-compatible) families."
15470                    ),
15471                    format!("providers.models.{family}.{alias}.wire_api"),
15472                ));
15473            }
15474        }
15475        warnings
15476    }
15477
15478    /// Surface non-fatal issues in per-alias `fallback` chains: dangling refs
15479    /// (a fallback naming an alias that is not configured) and cycles (a
15480    /// fallback path that loops back onto itself). Both are warn-and-skip — the
15481    /// chain still loads and runs with the offending edge pruned at build time.
15482    fn collect_fallback_warnings(
15483        &self,
15484        warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
15485    ) {
15486        for (family, alias, cfg) in self.providers.models.iter_entries() {
15487            self.collect_fallback_model_warnings(family, alias, cfg, warnings);
15488            if cfg.fallback.is_empty() {
15489                continue;
15490            }
15491            let root = format!("{family}.{alias}");
15492            let mut visited: Vec<String> = vec![root.clone()];
15493            self.walk_fallback(&root, &cfg.fallback, &mut visited, 1, warnings);
15494        }
15495    }
15496
15497    /// Surface `fallback_models` entries the build path silently skips: blank
15498    /// entries and entries that duplicate the alias's primary `model`. The skip
15499    /// itself is safe, but without a warning an operator never learns that a
15500    /// listed fallback model is doing nothing.
15501    fn collect_fallback_model_warnings(
15502        &self,
15503        family: &str,
15504        alias: &str,
15505        cfg: &ModelProviderConfig,
15506        warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
15507    ) {
15508        let Some(primary) = cfg.model.as_deref() else {
15509            return;
15510        };
15511        for (i, model) in cfg.fallback_models.iter().enumerate() {
15512            let path = format!("providers.models.{family}.{alias}.fallback_models[{i}]");
15513            if model.trim().is_empty() {
15514                warnings.push(crate::validation_warnings::ValidationWarning::new(
15515                    "empty_fallback_model",
15516                    format!(
15517                        "fallback_models entry {i} on {family}.{alias} is empty; \
15518                         it is skipped at runtime"
15519                    ),
15520                    path,
15521                ));
15522            } else if model == primary {
15523                warnings.push(crate::validation_warnings::ValidationWarning::new(
15524                    "fallback_model_duplicates_primary",
15525                    format!(
15526                        "fallback_models entry {model:?} on {family}.{alias} duplicates the \
15527                         primary model; it is skipped at runtime"
15528                    ),
15529                    path,
15530                ));
15531            }
15532        }
15533    }
15534
15535    fn walk_fallback(
15536        &self,
15537        from: &str,
15538        refs: &[crate::providers::ModelProviderRef],
15539        visited: &mut Vec<String>,
15540        depth: usize,
15541        warnings: &mut Vec<crate::validation_warnings::ValidationWarning>,
15542    ) {
15543        if depth > crate::providers::MAX_FALLBACK_DEPTH {
15544            warnings.push(crate::validation_warnings::ValidationWarning::new(
15545                "max_fallback_depth_exceeded",
15546                format!(
15547                    "fallback chain from {from} exceeds the maximum depth of {}; \
15548                     deeper links are pruned at runtime",
15549                    crate::providers::MAX_FALLBACK_DEPTH
15550                ),
15551                format!("providers.models.{from}.fallback"),
15552            ));
15553            return;
15554        }
15555        for (i, fallback_ref) in refs.iter().enumerate() {
15556            let raw = fallback_ref.as_str().trim();
15557            if raw.is_empty() {
15558                continue;
15559            }
15560            let path = format!("providers.models.{from}.fallback[{i}]");
15561            let Some((family, alias, cfg)) = self.providers.models.find_by_name(raw) else {
15562                warnings.push(crate::validation_warnings::ValidationWarning::new(
15563                    "dangling_fallback_ref",
15564                    format!(
15565                        "fallback {raw:?} on {from} does not resolve to a configured \
15566                         providers.models entry; this fallback link is skipped at runtime"
15567                    ),
15568                    path,
15569                ));
15570                continue;
15571            };
15572            let resolved = format!("{family}.{alias}");
15573            if visited.iter().any(|v| v == &resolved) {
15574                warnings.push(crate::validation_warnings::ValidationWarning::new(
15575                    "fallback_cycle",
15576                    format!(
15577                        "fallback {raw:?} on {from} closes a cycle \
15578                         ({} -> {resolved}); the cycle edge is pruned at runtime",
15579                        visited.join(" -> ")
15580                    ),
15581                    path,
15582                ));
15583                continue;
15584            }
15585            visited.push(resolved.clone());
15586            self.walk_fallback(&resolved, &cfg.fallback, visited, depth + 1, warnings);
15587            visited.pop();
15588        }
15589    }
15590
15591    /// Walk every channel HashMap that carries reply pacing fields and yield
15592    /// `(dotted-path-prefix, &dyn HasReplyPacing)` pairs. Used by validation
15593    /// and by anywhere that wants a single source of truth for which channel
15594    /// types participate in pacing.
15595    pub fn reply_pacing_entries(&self) -> Vec<(String, &dyn HasReplyPacing)> {
15596        let c = &self.channels;
15597        fn rows<'a, C: HasReplyPacing>(
15598            ch_type: &'static str,
15599            map: &'a std::collections::HashMap<String, C>,
15600        ) -> impl Iterator<Item = (String, &'a dyn HasReplyPacing)> + 'a {
15601            map.iter().map(move |(alias, cfg)| {
15602                (
15603                    format!("channels.{ch_type}.{alias}"),
15604                    cfg as &dyn HasReplyPacing,
15605                )
15606            })
15607        }
15608        rows("telegram", &c.telegram)
15609            .chain(rows("discord", &c.discord))
15610            .chain(rows("slack", &c.slack))
15611            .chain(rows("mattermost", &c.mattermost))
15612            .chain(rows("webhook", &c.webhook))
15613            .chain(rows("imessage", &c.imessage))
15614            .chain(rows("matrix", &c.matrix))
15615            .chain(rows("signal", &c.signal))
15616            .chain(rows("whatsapp", &c.whatsapp))
15617            .collect()
15618    }
15619
15620    /// Validate configuration values that would cause runtime failures.
15621    ///
15622    /// Called after TOML deserialization and env-override application to catch
15623    /// obviously invalid values early instead of failing at arbitrary runtime points.
15624    pub fn validate(&self) -> Result<()> {
15625        // Tunnel — OpenVPN
15626        if self.tunnel.tunnel_provider.trim() == "openvpn" {
15627            let openvpn = self.tunnel.openvpn.as_ref().ok_or_else(|| {
15628                ::zeroclaw_log::record!(
15629                    WARN,
15630                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
15631                        .with_outcome(::zeroclaw_log::EventOutcome::Failure),
15632                    "tunnel.tunnel_provider='openvpn' rejected: [tunnel.openvpn] block missing"
15633                );
15634                anyhow::Error::msg("tunnel.tunnel_provider='openvpn' requires [tunnel.openvpn]")
15635            })?;
15636
15637            if openvpn.config_file.trim().is_empty() {
15638                validation_bail!(
15639                    RequiredFieldEmpty,
15640                    "tunnel.openvpn.config_file",
15641                    "tunnel.openvpn.config_file must not be empty"
15642                );
15643            }
15644            if openvpn.connect_timeout_secs == 0 {
15645                validation_bail!(
15646                    InvalidNumericRange,
15647                    "tunnel.openvpn.connect_timeout_secs",
15648                    "tunnel.openvpn.connect_timeout_secs must be greater than 0"
15649                );
15650            }
15651        }
15652
15653        // Reply-pacing bounds — both `reply_min_interval_secs` and
15654        // `reply_queue_depth_max` walk through one entry list so adding
15655        // a new paced channel only requires extending `reply_pacing_entries`.
15656        for (path_prefix, cfg) in self.reply_pacing_entries() {
15657            let secs = cfg.reply_min_interval_secs();
15658            if secs > REPLY_MIN_INTERVAL_MAX_SECS {
15659                let path = format!("{path_prefix}.reply_min_interval_secs");
15660                validation_bail!(
15661                    InvalidNumericRange,
15662                    path,
15663                    "{path} = {secs} is out of range; must be 0..={REPLY_MIN_INTERVAL_MAX_SECS}"
15664                );
15665            }
15666            let depth = cfg.reply_queue_depth_max();
15667            if depth > REPLY_QUEUE_DEPTH_CEILING {
15668                let path = format!("{path_prefix}.reply_queue_depth_max");
15669                validation_bail!(
15670                    InvalidNumericRange,
15671                    path,
15672                    "{path} = {depth} is out of range; must be 0..={REPLY_QUEUE_DEPTH_CEILING}"
15673                );
15674            }
15675        }
15676
15677        for name in self.http_request.secrets.keys() {
15678            if name.is_empty()
15679                || name.len() > 64
15680                || !name
15681                    .chars()
15682                    .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
15683            {
15684                validation_bail!(
15685                    InvalidFormat,
15686                    format!("http_request.secrets.{name}"),
15687                    "http_request.secrets key {name:?} must contain 1..=64 ASCII letters, numbers, underscores, or hyphens"
15688                );
15689            }
15690        }
15691
15692        // Gateway
15693        if self.gateway.host.trim().is_empty() {
15694            validation_bail!(
15695                RequiredFieldEmpty,
15696                "gateway.host",
15697                "gateway.host must not be empty"
15698            );
15699        }
15700        if matches!(self.transcription.max_audio_bytes, Some(0)) {
15701            validation_bail!(
15702                InvalidNumericRange,
15703                "transcription.max_audio_bytes",
15704                "transcription.max_audio_bytes must be greater than zero"
15705            );
15706        }
15707        if self.channels.max_concurrent_per_channel == 0 {
15708            validation_bail!(
15709                InvalidNumericRange,
15710                "channels.max_concurrent_per_channel",
15711                "channels.max_concurrent_per_channel must be greater than 0"
15712            );
15713        }
15714        // Heartbeat agent: when heartbeat is enabled, the agent field
15715        // must name a configured agent.
15716        if self.heartbeat.enabled {
15717            let hb_agent = self.heartbeat.agent.trim();
15718            if hb_agent.is_empty() {
15719                validation_bail!(
15720                    RequiredFieldEmpty,
15721                    "heartbeat.agent",
15722                    "heartbeat.agent must reference a configured agent when heartbeat.enabled = true"
15723                );
15724            }
15725            if !self.agents.contains_key(hb_agent) {
15726                validation_bail!(
15727                    DanglingReference,
15728                    "heartbeat.agent",
15729                    "heartbeat.agent = {hb_agent:?} but no [agents.{hb_agent}] entry is configured"
15730                );
15731            }
15732        }
15733        if let Some(ref prefix) = self.gateway.path_prefix {
15734            // Validate the raw value — no silent trimming so the stored
15735            // value is exactly what was validated.
15736            if !prefix.is_empty() {
15737                if !prefix.starts_with('/') {
15738                    validation_bail!(
15739                        InvalidFormat,
15740                        "gateway.path_prefix",
15741                        "gateway.path_prefix must start with '/'"
15742                    );
15743                }
15744                if prefix.ends_with('/') {
15745                    validation_bail!(
15746                        InvalidFormat,
15747                        "gateway.path_prefix",
15748                        "gateway.path_prefix must not end with '/' (including bare '/')"
15749                    );
15750                }
15751                // Reject characters unsafe for URL paths or HTML/JS injection.
15752                // Whitespace is intentionally excluded from the allowed set.
15753                if let Some(bad) = prefix.chars().find(|c| {
15754                    !matches!(c, '/' | '-' | '_' | '.' | '~'
15755                        | 'a'..='z' | 'A'..='Z' | '0'..='9'
15756                        | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '='
15757                        | ':' | '@')
15758                }) {
15759                    anyhow::bail!(
15760                        "gateway.path_prefix contains invalid character '{bad}'; \
15761                         only unreserved and sub-delim URI characters are allowed"
15762                    );
15763                }
15764            }
15765        }
15766
15767        // Skill bundles — directories must stay inside `<install>/shared/`
15768        // and no two bundles may resolve to the same directory. Default
15769        // directory and the rules themselves live in
15770        // [`crate::skill_bundles`] so the runtime SkillsService and this
15771        // validator share one implementation.
15772        if !self.skill_bundles.is_empty() {
15773            let install_root = self.install_root_dir();
15774            for alias in self.skill_bundles.keys() {
15775                let dir = crate::skill_bundles::resolve_directory(self, &install_root, alias)
15776                    .map_err(|e| {
15777                        ::zeroclaw_log::record!(
15778                            WARN,
15779                            ::zeroclaw_log::Event::new(
15780                                module_path!(),
15781                                ::zeroclaw_log::Action::Reject
15782                            )
15783                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
15784                            .with_attrs(::serde_json::json!({
15785                                "skill_bundle": alias,
15786                                "error": format!("{}", e),
15787                            })),
15788                            "skill_bundles.<alias>.directory could not be resolved"
15789                        );
15790                        anyhow::Error::msg(e.to_string())
15791                    })?;
15792                if let Err(e) = crate::skill_bundles::validate_directory(&dir, &install_root) {
15793                    validation_bail!(
15794                        InvalidFormat,
15795                        format!("skill-bundles.{alias}.directory"),
15796                        "{e}"
15797                    );
15798                }
15799            }
15800            if let Err(e) = crate::skill_bundles::validate_uniqueness(self, &install_root) {
15801                validation_bail!(InvalidFormat, "skill_bundles", "{e}");
15802            }
15803        }
15804
15805        // Validate every configured risk profile. Each profile stands on
15806        // its own — there is no "active" or "default" risk profile concept;
15807        // an agent's `risk_profile` field names exactly which one applies.
15808        let mut profile_aliases: Vec<&String> = self.risk_profiles.keys().collect();
15809        profile_aliases.sort();
15810        for profile_alias in profile_aliases {
15811            let profile = &self.risk_profiles[profile_alias];
15812            for (i, env_name) in profile.shell_env_passthrough.iter().enumerate() {
15813                if !is_valid_env_var_name(env_name) {
15814                    anyhow::bail!(
15815                        "risk_profiles.{profile_alias}.shell_env_passthrough[{i}] is invalid ({env_name}); expected [A-Za-z_][A-Za-z0-9_]*"
15816                    );
15817                }
15818            }
15819        }
15820
15821        // Security OTP / estop
15822        if self.security.otp.challenge_max_attempts == 0 {
15823            validation_bail!(
15824                InvalidNumericRange,
15825                "security.otp.challenge_max_attempts",
15826                "security.otp.challenge_max_attempts must be greater than 0"
15827            );
15828        }
15829        if self.security.otp.token_ttl_secs == 0 {
15830            validation_bail!(
15831                InvalidNumericRange,
15832                "security.otp.token_ttl_secs",
15833                "security.otp.token_ttl_secs must be greater than 0"
15834            );
15835        }
15836        if self.security.otp.cache_valid_secs == 0 {
15837            validation_bail!(
15838                InvalidNumericRange,
15839                "security.otp.cache_valid_secs",
15840                "security.otp.cache_valid_secs must be greater than 0"
15841            );
15842        }
15843        if self.security.otp.cache_valid_secs < self.security.otp.token_ttl_secs {
15844            anyhow::bail!(
15845                "security.otp.cache_valid_secs must be greater than or equal to security.otp.token_ttl_secs"
15846            );
15847        }
15848        if self.security.otp.challenge_max_attempts == 0 {
15849            validation_bail!(
15850                InvalidNumericRange,
15851                "security.otp.challenge_max_attempts",
15852                "security.otp.challenge_max_attempts must be greater than 0"
15853            );
15854        }
15855        for (i, action) in self.security.otp.gated_actions.iter().enumerate() {
15856            let normalized = action.trim();
15857            if normalized.is_empty() {
15858                validation_bail!(
15859                    RequiredFieldEmpty,
15860                    format!("security.otp.gated_actions[{i}]"),
15861                    "security.otp.gated_actions[{i}] must not be empty"
15862                );
15863            }
15864            if !normalized
15865                .chars()
15866                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
15867            {
15868                anyhow::bail!(
15869                    "security.otp.gated_actions[{i}] contains invalid characters: {normalized}"
15870                );
15871            }
15872            if !default_otp_gated_actions()
15873                .iter()
15874                .any(|known| known == normalized)
15875            {
15876                ::zeroclaw_log::record!(
15877                    WARN,
15878                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
15879                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
15880                        .with_attrs(::serde_json::json!({
15881                            "action": normalized,
15882                            "known_actions": default_otp_gated_actions(),
15883                        })),
15884                    "security.otp.gated_actions entry does not match a known gated action and will not be enforced: "
15885                );
15886            }
15887        }
15888        DomainMatcher::new(
15889            &self.security.otp.gated_domains,
15890            &self.security.otp.gated_domain_categories,
15891        )
15892        .with_context(
15893            || "Invalid security.otp.gated_domains or security.otp.gated_domain_categories",
15894        )?;
15895        if self.security.estop.state_file.trim().is_empty() {
15896            validation_bail!(
15897                RequiredFieldEmpty,
15898                "security.estop.state_file",
15899                "security.estop.state_file must not be empty"
15900            );
15901        }
15902
15903        // Scheduler
15904        if self.scheduler.max_concurrent == 0 {
15905            validation_bail!(
15906                InvalidNumericRange,
15907                "scheduler.max_concurrent",
15908                "scheduler.max_concurrent must be greater than 0"
15909            );
15910        }
15911        if self.scheduler.max_tasks == 0 {
15912            validation_bail!(
15913                InvalidNumericRange,
15914                "scheduler.max_tasks",
15915                "scheduler.max_tasks must be greater than 0"
15916            );
15917        }
15918
15919        // Model routes
15920        for (i, route) in self.model_routes.iter().enumerate() {
15921            if route.hint.trim().is_empty() {
15922                validation_bail!(
15923                    RequiredFieldEmpty,
15924                    format!("model_routes[{i}].hint"),
15925                    "model_routes[{i}].hint must not be empty"
15926                );
15927            }
15928            let mp = route.model_provider.trim();
15929            if mp.is_empty() {
15930                validation_bail!(
15931                    RequiredFieldEmpty,
15932                    format!("model_routes[{i}].model_provider"),
15933                    "model_routes[{i}].model_provider must not be empty"
15934                );
15935            }
15936            // Route refs are dotted `<type>.<alias>` and must resolve to a
15937            // configured `[providers.models.<type>.<alias>]` entry. Unresolved
15938            // routes are dropped at runtime construction; rejecting them here
15939            // keeps that drift visible at config-load time.
15940            match mp.split_once('.') {
15941                Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
15942                    if self.providers.models.find(ty, inner).is_none() {
15943                        validation_bail!(
15944                            DanglingReference,
15945                            format!("model_routes[{i}].model_provider"),
15946                            "model_routes[{i}].model_provider = {mp:?} but providers.models.{ty}.{inner} is not configured",
15947                        );
15948                    }
15949                }
15950                _ => validation_bail!(
15951                    InvalidFormat,
15952                    format!("model_routes[{i}].model_provider"),
15953                    "model_routes[{i}].model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
15954                ),
15955            }
15956            if route.model.trim().is_empty() {
15957                validation_bail!(
15958                    RequiredFieldEmpty,
15959                    format!("model_routes[{i}].model"),
15960                    "model_routes[{i}].model must not be empty"
15961                );
15962            }
15963        }
15964
15965        // Embedding routes
15966        for (i, route) in self.embedding_routes.iter().enumerate() {
15967            if route.hint.trim().is_empty() {
15968                validation_bail!(
15969                    RequiredFieldEmpty,
15970                    format!("embedding_routes[{i}].hint"),
15971                    "embedding_routes[{i}].hint must not be empty"
15972                );
15973            }
15974            let mp = route.model_provider.trim();
15975            if mp.is_empty() {
15976                validation_bail!(
15977                    RequiredFieldEmpty,
15978                    format!("embedding_routes[{i}].model_provider"),
15979                    "embedding_routes[{i}].model_provider must not be empty"
15980                );
15981            }
15982            // Embedding routes resolve against the same model-provider map;
15983            // there is no separate `providers.embeddings` typed section.
15984            match mp.split_once('.') {
15985                Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
15986                    if self.providers.models.find(ty, inner).is_none() {
15987                        validation_bail!(
15988                            DanglingReference,
15989                            format!("embedding_routes[{i}].model_provider"),
15990                            "embedding_routes[{i}].model_provider = {mp:?} but providers.models.{ty}.{inner} is not configured",
15991                        );
15992                    }
15993                }
15994                _ => validation_bail!(
15995                    InvalidFormat,
15996                    format!("embedding_routes[{i}].model_provider"),
15997                    "embedding_routes[{i}].model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
15998                ),
15999            }
16000            if route.model.trim().is_empty() {
16001                validation_bail!(
16002                    RequiredFieldEmpty,
16003                    format!("embedding_routes[{i}].model"),
16004                    "embedding_routes[{i}].model must not be empty"
16005                );
16006            }
16007        }
16008
16009        for (type_key, alias_key, profile) in self.providers.models.iter_entries() {
16010            let profile_name = format!("{type_key}.{alias_key}");
16011
16012            let has_uri = profile
16013                .uri
16014                .as_deref()
16015                .map(str::trim)
16016                .is_some_and(|value| !value.is_empty());
16017
16018            // Entries created by migration from top-level fields use the
16019            // model_provider type+alias as the map key and may not have
16020            // explicit `uri` (the model_provider factory resolves the
16021            // family's default endpoint via `ModelEndpoint`). An entry
16022            // with no identifying information at all is almost always an
16023            // in-progress quickstart state — the user picked the model
16024            // provider but hasn't filled anything in yet. Warn but don't
16025            // bail; the runtime falls back to family-default endpoint at
16026            // use time, and a chat against the unconfigured model
16027            // provider fails with a clear error then.
16028            let has_api_key = profile
16029                .api_key
16030                .as_deref()
16031                .is_some_and(|v| !v.trim().is_empty());
16032            let has_model = profile
16033                .model
16034                .as_deref()
16035                .is_some_and(|v| !v.trim().is_empty());
16036            if !has_uri && !has_api_key && !has_model {
16037                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"model_provider": profile_name, "profile_name": profile_name})), "providers.models. is empty (no uri / api_key / model). \
16038                     Skipping at runtime; run `zeroclaw quickstart` (or use the dashboard) \
16039                     to make this model_provider usable.");
16040                continue;
16041            }
16042
16043            if let Some(uri) = profile.uri.as_deref().map(str::trim)
16044                && !uri.is_empty()
16045            {
16046                let parsed = reqwest::Url::parse(uri).with_context(|| {
16047                    format!("providers.models.{profile_name}.uri is not a valid URL")
16048                })?;
16049                if !matches!(parsed.scheme(), "http" | "https") {
16050                    anyhow::bail!("providers.models.{profile_name}.uri must use http/https");
16051                }
16052            }
16053
16054            if let Some(temp) = profile.temperature {
16055                validate_temperature(temp).map_err(|e| {
16056                    ::zeroclaw_log::record!(
16057                        WARN,
16058                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
16059                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
16060                            .with_attrs(::serde_json::json!({
16061                                "profile": profile_name,
16062                                "temperature": temp,
16063                                "error": format!("{}", e),
16064                            })),
16065                        "providers.models.<alias>.temperature rejected"
16066                    );
16067                    anyhow::Error::msg(format!("providers.models.{profile_name}.temperature: {e}"))
16068                })?;
16069            }
16070
16071            for (key, value) in &profile.pricing {
16072                if value.is_nan() {
16073                    anyhow::bail!(
16074                        "providers.models.{profile_name}.pricing.{key}: value must not be NaN"
16075                    );
16076                }
16077                if *value < 0.0 {
16078                    anyhow::bail!(
16079                        "providers.models.{profile_name}.pricing.{key}: value must be >= 0.0 (got {value})"
16080                    );
16081                }
16082            }
16083        }
16084
16085        // Non-fatal validation warnings: surfaced both via tracing (CLI sees
16086        // on stderr) and via Config::collect_warnings (gateway HTTP returns
16087        // structured to dashboard callers). Single source of truth lives in
16088        // collect_warnings; emit each one to tracing here so the existing
16089        // log behavior is preserved.
16090        for w in self.collect_warnings() {
16091            ::zeroclaw_log::record!(
16092                WARN,
16093                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
16094                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
16095                    .with_attrs(::serde_json::json!({"path": w.path, "code": w.code})),
16096                &format!("{}", w.message)
16097            );
16098        }
16099
16100        // Ollama cloud-routing safety checks
16101        for (alias, cfg) in &self.providers.models.ollama {
16102            let entry = &cfg.base;
16103            if !entry
16104                .model
16105                .as_deref()
16106                .is_some_and(|model| model.trim().ends_with(":cloud"))
16107            {
16108                continue;
16109            }
16110
16111            if is_local_ollama_endpoint(entry.uri.as_deref()) {
16112                anyhow::bail!(
16113                    "providers.models.ollama.{alias}.model uses ':cloud', but uri is local or unset. Set uri to a remote Ollama endpoint (for example https://ollama.com)."
16114                );
16115            }
16116            if is_official_ollama_cloud_endpoint(entry.uri.as_deref())
16117                && !has_ollama_cloud_credential(entry.api_key.as_deref())
16118            {
16119                anyhow::bail!(
16120                    "providers.models.ollama.{alias}.model uses ':cloud', but no API key is configured. Set api_key on [providers.models.ollama.{alias}] (or via the schema-mirror grammar: ZEROCLAW_providers__models__ollama__{alias}__api_key=<value>)."
16121                );
16122            }
16123        }
16124
16125        // Microsoft 365
16126        if self.microsoft365.enabled {
16127            let tenant = self
16128                .microsoft365
16129                .tenant_id
16130                .as_deref()
16131                .map(str::trim)
16132                .filter(|s| !s.is_empty());
16133            if tenant.is_none() {
16134                anyhow::bail!(
16135                    "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
16136                );
16137            }
16138            let client = self
16139                .microsoft365
16140                .client_id
16141                .as_deref()
16142                .map(str::trim)
16143                .filter(|s| !s.is_empty());
16144            if client.is_none() {
16145                anyhow::bail!(
16146                    "microsoft365.client_id must not be empty when microsoft365 is enabled"
16147                );
16148            }
16149            let flow = self.microsoft365.auth_flow.trim();
16150            if flow != "client_credentials" && flow != "device_code" {
16151                anyhow::bail!(
16152                    "microsoft365.auth_flow must be 'client_credentials' or 'device_code'"
16153                );
16154            }
16155            if flow == "client_credentials"
16156                && self
16157                    .microsoft365
16158                    .client_secret
16159                    .as_deref()
16160                    .is_none_or(|s| s.trim().is_empty())
16161            {
16162                anyhow::bail!(
16163                    "microsoft365.client_secret must not be empty when auth_flow is 'client_credentials'"
16164                );
16165            }
16166        }
16167
16168        // Microsoft 365
16169        if self.microsoft365.enabled {
16170            let tenant = self
16171                .microsoft365
16172                .tenant_id
16173                .as_deref()
16174                .map(str::trim)
16175                .filter(|s| !s.is_empty());
16176            if tenant.is_none() {
16177                anyhow::bail!(
16178                    "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
16179                );
16180            }
16181            let client = self
16182                .microsoft365
16183                .client_id
16184                .as_deref()
16185                .map(str::trim)
16186                .filter(|s| !s.is_empty());
16187            if client.is_none() {
16188                anyhow::bail!(
16189                    "microsoft365.client_id must not be empty when microsoft365 is enabled"
16190                );
16191            }
16192            let flow = self.microsoft365.auth_flow.trim();
16193            if flow != "client_credentials" && flow != "device_code" {
16194                anyhow::bail!("microsoft365.auth_flow must be client_credentials or device_code");
16195            }
16196            if flow == "client_credentials"
16197                && self
16198                    .microsoft365
16199                    .client_secret
16200                    .as_deref()
16201                    .is_none_or(|s| s.trim().is_empty())
16202            {
16203                anyhow::bail!(
16204                    "microsoft365.client_secret must not be empty when auth_flow is client_credentials"
16205                );
16206            }
16207        }
16208
16209        // MCP
16210        if self.mcp.enabled {
16211            validate_mcp_config(&self.mcp)?;
16212        }
16213
16214        // Knowledge graph
16215        if self.knowledge.enabled {
16216            if self.knowledge.max_nodes == 0 {
16217                validation_bail!(
16218                    InvalidNumericRange,
16219                    "knowledge.max_nodes",
16220                    "knowledge.max_nodes must be greater than 0"
16221                );
16222            }
16223            if self.knowledge.db_path.trim().is_empty() {
16224                validation_bail!(
16225                    RequiredFieldEmpty,
16226                    "knowledge.db_path",
16227                    "knowledge.db_path must not be empty"
16228                );
16229            }
16230        }
16231
16232        // Google Workspace allowed_services validation
16233        let mut seen_gws_services = std::collections::HashSet::new();
16234        for (i, service) in self.google_workspace.allowed_services.iter().enumerate() {
16235            let normalized = service.trim();
16236            if normalized.is_empty() {
16237                validation_bail!(
16238                    RequiredFieldEmpty,
16239                    format!("google_workspace.allowed_services[{i}]"),
16240                    "google_workspace.allowed_services[{i}] must not be empty"
16241                );
16242            }
16243            if !normalized
16244                .chars()
16245                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
16246            {
16247                anyhow::bail!(
16248                    "google_workspace.allowed_services[{i}] contains invalid characters: {normalized}"
16249                );
16250            }
16251            if !seen_gws_services.insert(normalized.to_string()) {
16252                anyhow::bail!(
16253                    "google_workspace.allowed_services contains duplicate entry: {normalized}"
16254                );
16255            }
16256        }
16257
16258        // Build the effective allowed-services set for cross-validation.
16259        // When the operator leaves allowed_services empty the tool falls back to
16260        // DEFAULT_GWS_SERVICES; use the same constant here so validation is
16261        // consistent in both cases.
16262        let effective_services: std::collections::HashSet<&str> =
16263            if self.google_workspace.allowed_services.is_empty() {
16264                DEFAULT_GWS_SERVICES.iter().copied().collect()
16265            } else {
16266                self.google_workspace
16267                    .allowed_services
16268                    .iter()
16269                    .map(|s| s.trim())
16270                    .collect()
16271            };
16272
16273        let mut seen_gws_operations = std::collections::HashSet::new();
16274        for (i, operation) in self.google_workspace.allowed_operations.iter().enumerate() {
16275            let service = operation.service.trim();
16276            let resource = operation.resource.trim();
16277
16278            if service.is_empty() {
16279                validation_bail!(
16280                    RequiredFieldEmpty,
16281                    format!("google_workspace.allowed_operations[{i}].service"),
16282                    "google_workspace.allowed_operations[{i}].service must not be empty"
16283                );
16284            }
16285            if resource.is_empty() {
16286                anyhow::bail!(
16287                    "google_workspace.allowed_operations[{i}].resource must not be empty"
16288                );
16289            }
16290
16291            if !effective_services.contains(service) {
16292                anyhow::bail!(
16293                    "google_workspace.allowed_operations[{i}].service '{service}' is not in the \
16294                     effective allowed_services; this entry can never match at runtime"
16295                );
16296            }
16297            if !service
16298                .chars()
16299                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
16300            {
16301                anyhow::bail!(
16302                    "google_workspace.allowed_operations[{i}].service contains invalid characters: {service}"
16303                );
16304            }
16305            if !resource
16306                .chars()
16307                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
16308            {
16309                anyhow::bail!(
16310                    "google_workspace.allowed_operations[{i}].resource contains invalid characters: {resource}"
16311                );
16312            }
16313
16314            if let Some(ref sub_resource) = operation.sub_resource {
16315                let sub = sub_resource.trim();
16316                if sub.is_empty() {
16317                    anyhow::bail!(
16318                        "google_workspace.allowed_operations[{i}].sub_resource must not be empty when present"
16319                    );
16320                }
16321                if !sub
16322                    .chars()
16323                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
16324                {
16325                    anyhow::bail!(
16326                        "google_workspace.allowed_operations[{i}].sub_resource contains invalid characters: {sub}"
16327                    );
16328                }
16329            }
16330
16331            if operation.methods.is_empty() {
16332                validation_bail!(
16333                    RequiredFieldEmpty,
16334                    format!("google_workspace.allowed_operations[{i}].methods"),
16335                    "google_workspace.allowed_operations[{i}].methods must not be empty"
16336                );
16337            }
16338
16339            let mut seen_methods = std::collections::HashSet::new();
16340            for (j, method) in operation.methods.iter().enumerate() {
16341                let normalized = method.trim();
16342                if normalized.is_empty() {
16343                    anyhow::bail!(
16344                        "google_workspace.allowed_operations[{i}].methods[{j}] must not be empty"
16345                    );
16346                }
16347                if !normalized
16348                    .chars()
16349                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
16350                {
16351                    anyhow::bail!(
16352                        "google_workspace.allowed_operations[{i}].methods[{j}] contains invalid characters: {normalized}"
16353                    );
16354                }
16355                if !seen_methods.insert(normalized.to_string()) {
16356                    anyhow::bail!(
16357                        "google_workspace.allowed_operations[{i}].methods contains duplicate entry: {normalized}"
16358                    );
16359                }
16360            }
16361
16362            let sub_key = operation
16363                .sub_resource
16364                .as_deref()
16365                .map(str::trim)
16366                .unwrap_or("");
16367            let operation_key = format!("{service}:{resource}:{sub_key}");
16368            if !seen_gws_operations.insert(operation_key.clone()) {
16369                anyhow::bail!(
16370                    "google_workspace.allowed_operations contains duplicate service/resource/sub_resource entry: {operation_key}"
16371                );
16372            }
16373        }
16374
16375        // Project intelligence
16376        if self.project_intel.enabled {
16377            let lang = &self.project_intel.default_language;
16378            if !["en", "de", "fr", "it"].contains(&lang.as_str()) {
16379                anyhow::bail!(
16380                    "project_intel.default_language must be one of: en, de, fr, it (got '{lang}')"
16381                );
16382            }
16383            let sens = &self.project_intel.risk_sensitivity;
16384            if !["low", "medium", "high"].contains(&sens.as_str()) {
16385                anyhow::bail!(
16386                    "project_intel.risk_sensitivity must be one of: low, medium, high (got '{sens}')"
16387                );
16388            }
16389            if let Some(ref tpl_dir) = self.project_intel.templates_dir
16390                && !std::path::Path::new(tpl_dir).exists()
16391            {
16392                anyhow::bail!("project_intel.templates_dir path does not exist: {tpl_dir}");
16393            }
16394        }
16395
16396        // Proxy (delegate to existing validation)
16397        self.proxy.validate()?;
16398        self.cloud_ops.validate()?;
16399
16400        // Notion
16401        if self.notion.enabled {
16402            if self.notion.database_id.trim().is_empty() {
16403                anyhow::bail!("notion.database_id must not be empty when notion.enabled = true");
16404            }
16405            if self.notion.poll_interval_secs == 0 {
16406                validation_bail!(
16407                    InvalidNumericRange,
16408                    "notion.poll_interval_secs",
16409                    "notion.poll_interval_secs must be greater than 0"
16410                );
16411            }
16412            if self.notion.max_concurrent == 0 {
16413                validation_bail!(
16414                    InvalidNumericRange,
16415                    "notion.max_concurrent",
16416                    "notion.max_concurrent must be greater than 0"
16417                );
16418            }
16419            if self.notion.status_property.trim().is_empty() {
16420                validation_bail!(
16421                    RequiredFieldEmpty,
16422                    "notion.status_property",
16423                    "notion.status_property must not be empty"
16424                );
16425            }
16426            if self.notion.input_property.trim().is_empty() {
16427                validation_bail!(
16428                    RequiredFieldEmpty,
16429                    "notion.input_property",
16430                    "notion.input_property must not be empty"
16431                );
16432            }
16433            if self.notion.result_property.trim().is_empty() {
16434                validation_bail!(
16435                    RequiredFieldEmpty,
16436                    "notion.result_property",
16437                    "notion.result_property must not be empty"
16438                );
16439            }
16440        }
16441
16442        // Pinggy tunnel region — validate allowed values (case-insensitive, auto-lowercased at runtime).
16443        if let Some(ref pinggy) = self.tunnel.pinggy
16444            && let Some(ref region) = pinggy.region
16445        {
16446            let r = region.trim().to_ascii_lowercase();
16447            if !r.is_empty() && !matches!(r.as_str(), "us" | "eu" | "ap" | "br" | "au") {
16448                anyhow::bail!(
16449                    "tunnel.pinggy.region must be one of: us, eu, ap, br, au (or omitted for auto)"
16450                );
16451            }
16452        }
16453
16454        // Jira
16455        if self.jira.enabled {
16456            if self.jira.base_url.trim().is_empty() {
16457                anyhow::bail!("jira.base_url must not be empty when jira.enabled = true");
16458            }
16459            if self.jira.api_token.trim().is_empty()
16460                && std::env::var("JIRA_API_TOKEN")
16461                    .unwrap_or_default()
16462                    .trim()
16463                    .is_empty()
16464            {
16465                anyhow::bail!(
16466                    "jira.api_token must be set (or JIRA_API_TOKEN env var) when jira.enabled = true"
16467                );
16468            }
16469            let valid_actions = [
16470                "get_ticket",
16471                "search_tickets",
16472                "comment_ticket",
16473                "list_projects",
16474                "myself",
16475                "list_transitions",
16476                "transition_ticket",
16477                "create_ticket",
16478            ];
16479            for action in &self.jira.allowed_actions {
16480                if !valid_actions.contains(&action.as_str()) {
16481                    anyhow::bail!(
16482                        "jira.allowed_actions contains unknown action: '{}'. \
16483                         Valid: get_ticket, search_tickets, comment_ticket, list_projects, myself, list_transitions, transition_ticket, create_ticket",
16484                        action
16485                    );
16486                }
16487            }
16488        }
16489
16490        // Nevis IAM — delegate to NevisConfig::validate() for field-level checks
16491        if let Err(msg) = self.security.nevis.validate() {
16492            anyhow::bail!("security.nevis: {msg}");
16493        }
16494
16495        // Delegate tool global defaults
16496        if self.delegate.timeout_secs == 0 {
16497            validation_bail!(
16498                InvalidNumericRange,
16499                "delegate.timeout_secs",
16500                "delegate.timeout_secs must be greater than 0"
16501            );
16502        }
16503        if self.delegate.agentic_timeout_secs == 0 {
16504            validation_bail!(
16505                InvalidNumericRange,
16506                "delegate.agentic_timeout_secs",
16507                "delegate.agentic_timeout_secs must be greater than 0"
16508            );
16509        }
16510
16511        // Per-agent validation. Mandatory + alias-existence checks live
16512        // here so the gateway PATCH path returns structured per-field
16513        // errors and the frontend never owns this rule. Sorted iteration
16514        // keeps error ordering stable across runs.
16515        let mut agent_aliases: Vec<&String> = self.agents.keys().collect();
16516        agent_aliases.sort();
16517        for alias in agent_aliases {
16518            let agent = &self.agents[alias];
16519
16520            // model_provider: mandatory, dotted `<type>.<inner>` ref into
16521            // model_providers.<type>.<inner>.
16522            let mp = agent.model_provider.trim();
16523            if mp.is_empty() {
16524                validation_bail!(
16525                    RequiredFieldEmpty,
16526                    format!("agents.{alias}.model_provider"),
16527                    "agents.{alias}.model_provider must reference a configured model model_provider (e.g. \"anthropic.default\")",
16528                );
16529            }
16530            match mp.split_once('.') {
16531                Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
16532                    if !crate::providers::ModelProviders::slot_names().contains(&ty) {
16533                        validation_bail!(
16534                            DanglingReference,
16535                            format!("agents.{alias}.model_provider"),
16536                            "agents.{alias}.model_provider = {mp:?} but {ty:?} is not a known provider family; check [providers.models.<family>.<alias>] in config.toml (valid families: `zeroclaw providers`)",
16537                        );
16538                    }
16539                    let exists = self
16540                        .get_map_keys(&format!("providers.models.{ty}"))
16541                        .is_some_and(|keys| keys.iter().any(|k| k == inner));
16542                    if !exists {
16543                        validation_bail!(
16544                            DanglingReference,
16545                            format!("agents.{alias}.model_provider"),
16546                            "agents.{alias}.model_provider = {mp:?} but [providers.models.{ty}.{inner}] is not configured",
16547                        );
16548                    }
16549                }
16550                _ => validation_bail!(
16551                    InvalidFormat,
16552                    format!("agents.{alias}.model_provider"),
16553                    "agents.{alias}.model_provider must be dotted form `<type>.<alias>` (got {mp:?})",
16554                ),
16555            }
16556
16557            // channels: each entry is a dotted `<type>.<inner>` ref into
16558            // channels.<type>.<inner>. Empty list is valid (delegate-only agent).
16559            // Uses the schema-derived `get_map_keys` so new channel types
16560            // surface here automatically — no per-type match arm.
16561            for (i, ch) in agent.channels.iter().enumerate() {
16562                let trimmed = ch.trim();
16563                match trimmed.split_once('.') {
16564                    Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
16565                        // `get_map_keys` stores section names using the raw
16566                        // field ident (snake), the same dotted form the
16567                        // operator sees in TOML (`gmail_push`, `voice_call`,
16568                        // `nextcloud_talk`). Look up verbatim.
16569                        let exists = self
16570                            .get_map_keys(&format!("channels.{ty}"))
16571                            .is_some_and(|keys| keys.iter().any(|k| k == inner));
16572                        if !exists {
16573                            validation_bail!(
16574                                DanglingReference,
16575                                format!("agents.{alias}.channels[{i}]"),
16576                                "agents.{alias}.channels[{i}] = {trimmed:?} but channels.{ty}.{inner} is not configured",
16577                            );
16578                        }
16579                    }
16580                    _ => validation_bail!(
16581                        InvalidFormat,
16582                        format!("agents.{alias}.channels[{i}]"),
16583                        "agents.{alias}.channels[{i}] must be dotted form `<type>.<alias>` (got {trimmed:?})",
16584                    ),
16585                }
16586            }
16587
16588            // Per-agent provider refs that resolve into the typed provider
16589            // sections. Empty = no preference for that category (no TTS / no
16590            // STT for this agent), which is valid. Non-empty values must
16591            // match a configured `[providers.<category>.<type>.<alias>]`
16592            // entry, fail loud with the dangling ref otherwise.
16593            // there is no global default-X-provider concept — every consumer
16594            // either picks a configured alias or opts out entirely.
16595            let typed_provider_refs: &[(&str, &str, &str)] = &[
16596                ("providers.tts", "tts_provider", agent.tts_provider.trim()),
16597                (
16598                    "providers.transcription",
16599                    "transcription_provider",
16600                    agent.transcription_provider.trim(),
16601                ),
16602                // NEW in this PR (kanmars.req.20260522.001):
16603                (
16604                    "providers.models",
16605                    "classifier_provider",
16606                    agent.classifier_provider.trim(),
16607                ),
16608            ];
16609            for (section_prefix, field, value) in typed_provider_refs {
16610                if value.is_empty() {
16611                    continue;
16612                }
16613                match value.split_once('.') {
16614                    Some((ty, inner)) if !ty.is_empty() && !inner.is_empty() => {
16615                        let exists = self
16616                            .get_map_keys(&format!("{section_prefix}.{ty}"))
16617                            .is_some_and(|keys| keys.iter().any(|k| k == inner));
16618                        if !exists {
16619                            validation_bail!(
16620                                DanglingReference,
16621                                format!("agents.{alias}.{field}"),
16622                                "agents.{alias}.{field} = {value:?} but {section_prefix}.{ty}.{inner} is not configured",
16623                            );
16624                        }
16625                    }
16626                    _ => validation_bail!(
16627                        InvalidFormat,
16628                        format!("agents.{alias}.{field}"),
16629                        "agents.{alias}.{field} must be dotted form `<type>.<alias>` (got {value:?})",
16630                    ),
16631                }
16632            }
16633
16634            // Bare-alias bundle refs. Tuple is (kebab section path, kebab
16635            // agent field name, value list). Both names use the schema's
16636            // kebab form: section name matches what `get_map_keys` expects
16637            // (macro converts snake→kebab via `snake_to_kebab` per
16638            // crates/zeroclaw-macros/src/lib.rs:1056); field name matches
16639            // what `prop_fields()` emits, so DanglingReference paths bind
16640            // directly to the right inline error in the dashboard form.
16641            let bare_multi: &[(&str, &str, &[String])] = &[
16642                ("skill_bundles", "skill_bundles", &agent.skill_bundles),
16643                (
16644                    "knowledge_bundles",
16645                    "knowledge_bundles",
16646                    &agent.knowledge_bundles,
16647                ),
16648                ("mcp_bundles", "mcp_bundles", &agent.mcp_bundles),
16649            ];
16650            for (section, field, values) in bare_multi {
16651                for (i, key) in values.iter().enumerate() {
16652                    let trimmed = key.trim();
16653                    if trimmed.is_empty() {
16654                        continue;
16655                    }
16656                    let exists = self
16657                        .get_map_keys(section)
16658                        .is_some_and(|keys| keys.iter().any(|k| k == trimmed));
16659                    if !exists {
16660                        validation_bail!(
16661                            DanglingReference,
16662                            format!("agents.{alias}.{field}[{i}]"),
16663                            "agents.{alias}.{field}[{i}] = {trimmed:?} but {section}.{trimmed} is not configured",
16664                        );
16665                    }
16666                }
16667            }
16668            let bare_single: &[(&str, &str, &str)] = &[
16669                ("risk_profiles", "risk_profile", agent.risk_profile.as_str()),
16670                (
16671                    "runtime_profiles",
16672                    "runtime_profile",
16673                    agent.runtime_profile.as_str(),
16674                ),
16675            ];
16676            for (section, field, raw) in bare_single {
16677                let trimmed = raw.trim();
16678                if trimmed.is_empty() {
16679                    continue;
16680                }
16681                let exists = self
16682                    .get_map_keys(section)
16683                    .is_some_and(|keys| keys.iter().any(|k| k == trimmed));
16684                if !exists {
16685                    validation_bail!(
16686                        DanglingReference,
16687                        format!("agents.{alias}.{field}"),
16688                        "agents.{alias}.{field} = {trimmed:?} but {section}.{trimmed} is not configured",
16689                    );
16690                }
16691            }
16692
16693            // risk_profile is mandatory for enabled agents — there is no
16694            // global fallback, so an enabled agent with no profile can't
16695            // gate its actions. Run this check last so the more specific
16696            // dangling/format errors above surface first.
16697            if agent.enabled && agent.risk_profile.trim().is_empty() {
16698                validation_bail!(
16699                    RequiredFieldEmpty,
16700                    format!("agents.{alias}.risk_profile"),
16701                    "agents.{alias}.risk_profile must reference a configured [risk_profiles.<alias>] entry",
16702                );
16703            }
16704
16705            // workspace.access: keys must point at OTHER agents, never
16706            // self, and every target must be a configured agent.
16707            for (target, mode) in &agent.workspace.access {
16708                let target_str = target.as_str();
16709                if target_str == alias.as_str() {
16710                    validation_bail!(
16711                        InvalidFormat,
16712                        format!("agents.{alias}.workspace.access.{target_str}"),
16713                        "agents.{alias}.workspace.access.{target_str} = {mode:?} but {target_str} is this agent itself; an agent always has full access to its own workspace, so self-references in the cross-agent allowlist are not permitted",
16714                    );
16715                }
16716                if !self.agents.contains_key(target_str) {
16717                    validation_bail!(
16718                        DanglingReference,
16719                        format!("agents.{alias}.workspace.access.{target_str}"),
16720                        "agents.{alias}.workspace.access.{target_str} = {mode:?} but agents.{target_str} is not configured",
16721                    );
16722                }
16723            }
16724
16725            // workspace.read_memory_from: every alias must exist as a
16726            // configured agent and must use the same MemoryBackendKind
16727            // as the declaring agent. Mismatched backends fail at
16728            // config load rather than producing a runtime error when
16729            // the per-agent memory plumbing consumes the allowlist.
16730            let agent_backend = agent.memory.backend;
16731            for (i, target) in agent.workspace.read_memory_from.iter().enumerate() {
16732                let target_str = target.as_str();
16733                if target_str == alias.as_str() {
16734                    validation_bail!(
16735                        InvalidFormat,
16736                        format!("agents.{alias}.workspace.read_memory_from[{i}]"),
16737                        "agents.{alias}.workspace.read_memory_from[{i}] = {target_str:?} but {target_str} is this agent itself; an agent always sees its own memory rows, so self-references in the cross-agent allowlist are not permitted",
16738                    );
16739                }
16740                let Some(target_agent) = self.agents.get(target_str) else {
16741                    validation_bail!(
16742                        DanglingReference,
16743                        format!("agents.{alias}.workspace.read_memory_from[{i}]"),
16744                        "agents.{alias}.workspace.read_memory_from[{i}] = {target_str:?} but agents.{target_str} is not configured",
16745                    );
16746                };
16747                if target_agent.memory.backend != agent_backend {
16748                    let target_backend = target_agent.memory.backend;
16749                    validation_bail!(
16750                        InvalidFormat,
16751                        format!("agents.{alias}.workspace.read_memory_from[{i}]"),
16752                        "agents.{alias}.workspace.read_memory_from[{i}] points at agents.{target_str} which uses memory backend {target_backend:?}, but agents.{alias} uses {agent_backend:?}; the allowlist must point at same-backend siblings only",
16753                    );
16754                }
16755            }
16756        }
16757
16758        // Peer groups: every member alias must exist as a configured
16759        // agent, and the group's channel must be in each member's
16760        // channels list. Mutual opt-in resolution happens at runtime;
16761        // this cross-reference check keeps misconfigured group
16762        // members from looking like real peer relationships at load
16763        // time.
16764        let mut peer_group_names: Vec<&String> = self.peer_groups.keys().collect();
16765        peer_group_names.sort();
16766        for group_name in peer_group_names {
16767            let group = &self.peer_groups[group_name];
16768            let group_channel = group.channel.trim();
16769            if group_channel.is_empty() {
16770                validation_bail!(
16771                    RequiredFieldEmpty,
16772                    format!("peer_groups.{group_name}.channel"),
16773                    "peer_groups.{group_name}.channel must name a channel type (e.g. \"discord\") or dotted alias (e.g. \"discord.work\")",
16774                );
16775            }
16776            // `get_map_keys` stores section names using the raw field ident
16777            // (snake); look up the channel type verbatim.
16778            let (group_channel_type, group_channel_alias) = match group_channel.split_once('.') {
16779                Some((ty, al)) => (ty, Some(al)),
16780                None => (group_channel, None),
16781            };
16782            let channel_aliases = self.get_map_keys(&format!("channels.{group_channel_type}"));
16783            if channel_aliases.is_none() {
16784                validation_bail!(
16785                    DanglingReference,
16786                    format!("peer_groups.{group_name}.channel"),
16787                    "peer_groups.{group_name}.channel = {group_channel:?} but no [channels.{group_channel_type}.*] block is configured",
16788                );
16789            }
16790            if let Some(alias) = group_channel_alias {
16791                let exists = channel_aliases
16792                    .as_ref()
16793                    .is_some_and(|keys| keys.iter().any(|k| k == alias));
16794                if !exists {
16795                    validation_bail!(
16796                        DanglingReference,
16797                        format!("peer_groups.{group_name}.channel"),
16798                        "peer_groups.{group_name}.channel = {group_channel:?} but [channels.{group_channel_type}.{alias}] is not configured",
16799                    );
16800                }
16801            }
16802            for (i, member) in group.agents.iter().enumerate() {
16803                let member_str = member.as_str();
16804                let Some(member_agent) = self.agents.get(member_str) else {
16805                    validation_bail!(
16806                        DanglingReference,
16807                        format!("peer_groups.{group_name}.agents[{i}]"),
16808                        "peer_groups.{group_name}.agents[{i}] = {member_str:?} but agents.{member_str} is not configured",
16809                    );
16810                };
16811                let has_channel_match = member_agent.channels.iter().any(|ch| {
16812                    let ch_str = ch.as_str();
16813                    match group_channel_alias {
16814                        Some(alias) => ch_str == format!("{group_channel_type}.{alias}"),
16815                        None => ch_str.starts_with(&format!("{group_channel_type}.")),
16816                    }
16817                });
16818                if !has_channel_match {
16819                    let needs_msg = match group_channel_alias {
16820                        Some(alias) => format!("entry for {group_channel_type}.{alias}"),
16821                        None => format!("entry of type {group_channel_type:?}"),
16822                    };
16823                    validation_bail!(
16824                        InvalidFormat,
16825                        format!("peer_groups.{group_name}.agents[{i}]"),
16826                        "peer_groups.{group_name}.agents[{i}] = {member_str:?} but agents.{member_str}.channels has no {needs_msg}",
16827                    );
16828                }
16829            }
16830        }
16831
16832        Ok(())
16833    }
16834
16835    pub fn mark_dirty(&mut self, path: &str) {
16836        self.dirty_paths.insert(path.to_string());
16837    }
16838
16839    pub fn ensure_map_key_for_path(&mut self, path: &str) {
16840        use crate::traits::MapKeyKind;
16841        let mut best: Option<&'static str> = None;
16842        for s in Self::map_key_sections()
16843            .iter()
16844            .filter(|s| s.kind == MapKeyKind::Map)
16845        {
16846            let prefix = format!("{}.", s.path);
16847            if path.starts_with(&prefix)
16848                && path.len() > prefix.len()
16849                && best.is_none_or(|b| s.path.len() > b.len())
16850            {
16851                best = Some(s.path);
16852            }
16853        }
16854        let Some(section) = best else {
16855            return;
16856        };
16857        let rest = &path[section.len() + 1..];
16858        let Some(alias) = rest.split('.').next().filter(|a| !a.is_empty()) else {
16859            return;
16860        };
16861        if self
16862            .get_map_keys(section)
16863            .is_some_and(|keys| keys.iter().any(|k| k == alias))
16864        {
16865            return;
16866        }
16867        let _ = self.create_map_key(section, alias);
16868    }
16869
16870    pub fn clear_dirty(&mut self) {
16871        self.dirty_paths.clear();
16872    }
16873
16874    pub fn set_prop_persistent(&mut self, name: &str, value_str: &str) -> Result<()> {
16875        self.set_prop(name, value_str)?;
16876        self.mark_dirty(name);
16877        Ok(())
16878    }
16879
16880    pub fn set_secret_persistent(&mut self, name: &str, value: String) -> Result<()> {
16881        self.set_secret(name, value)?;
16882        self.mark_dirty(name);
16883        Ok(())
16884    }
16885
16886    async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
16887        if self
16888            .config_path
16889            .parent()
16890            .is_some_and(|parent| !parent.as_os_str().is_empty())
16891        {
16892            return Ok(self.config_path.clone());
16893        }
16894
16895        let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_data_dirs()?;
16896        let (zeroclaw_dir, _workspace_dir, source) =
16897            resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
16898        let file_name = self
16899            .config_path
16900            .file_name()
16901            .filter(|name| !name.is_empty())
16902            .unwrap_or_else(|| std::ffi::OsStr::new("config.toml"));
16903        let resolved = zeroclaw_dir.join(file_name);
16904        ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"path": self.config_path.display().to_string(), "resolved": resolved.display().to_string(), "source": source.as_str()})), "Config path missing parent directory; resolving from runtime environment");
16905        Ok(resolved)
16906    }
16907
16908    pub async fn save(&self) -> Result<()> {
16909        // Encrypt secrets before serialization
16910        let mut config_to_save = self.clone();
16911        // Stamp the current schema version on every write. The in-memory
16912        // config is always at `CURRENT_SCHEMA_VERSION` (load-time migration
16913        // brings it forward), but pin it explicitly so a full save can never
16914        // emit a body-newer-than-label file. See `save_dirty` and #7271.
16915        config_to_save.schema_version = crate::migration::CURRENT_SCHEMA_VERSION;
16916        let config_path = self.resolve_config_path_for_save().await?;
16917        let zeroclaw_dir = config_path
16918            .parent()
16919            .context("Config path must have a parent directory")?;
16920        let store = crate::secrets::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
16921
16922        // Restore env-overridden paths to their pre-override snapshots before
16923        // encryption, so values supplied via `ZEROCLAW_*` env vars never reach
16924        // disk. Snapshots were captured at apply time from the post-decrypt
16925        // in-memory state, so secrets carry the original plaintext that
16926        // `encrypt_secrets()` will re-encrypt to fresh ciphertext that
16927        // decrypts back to the same value.
16928        if !self.pre_override_snapshots.is_empty() {
16929            crate::env_overrides::mask_env_overrides_for_save(
16930                &mut config_to_save,
16931                &self.pre_override_snapshots,
16932            )?;
16933        }
16934        restore_onepassword_references_for_save(
16935            &mut config_to_save,
16936            &self.onepassword_reference_snapshots,
16937            &self.dirty_paths,
16938        )?;
16939
16940        // Encrypt all #[secret]-annotated fields via Configurable derive
16941        config_to_save.encrypt_secrets(&store)?;
16942
16943        // Serialize, then prune fields whose values match
16944        // `Config::default()` so the on-disk config carries only the
16945        // operator's actual choices (no hundreds of lines of struct
16946        // defaults the operator never touched). The schema's
16947        // `#[serde(default = "...")]` annotations re-supply the
16948        // defaults on load, so the pruned file round-trips identically.
16949        let mut new_table: toml::Table = toml::Value::try_from(&config_to_save)
16950            .context("Failed to serialize config to TOML value")?
16951            .try_into()
16952            .context("Serialized config is not a TOML table")?;
16953        let default_table: toml::Table = toml::Value::try_from(Config::default())
16954            .ok()
16955            .and_then(|v| v.try_into().ok())
16956            .unwrap_or_default();
16957        prune_default_values(&mut new_table, &default_table);
16958        let new_toml = ensure_blank_line_before_sections(
16959            &toml::to_string_pretty(&new_table).context("Failed to serialize pruned config")?,
16960        );
16961
16962        // If an existing config file is present, sync the new values onto it
16963        // to preserve comments and formatting. Otherwise, use the fresh serialization.
16964        let toml_str = if config_path.exists() {
16965            let existing = fs::read_to_string(&config_path).await.unwrap_or_default();
16966            if existing.is_empty() {
16967                new_toml
16968            } else {
16969                let mut doc: toml_edit::DocumentMut = existing
16970                    .parse()
16971                    .context("Failed to parse existing config for comment preservation")?;
16972                crate::migration::sync_table(doc.as_table_mut(), &new_table);
16973                // sync_table preserves existing decor verbatim, so newly
16974                // inserted sections lack the blank-line gap before their
16975                // header until the post-processor runs.
16976                ensure_blank_line_before_sections(&doc.to_string())
16977            }
16978        } else {
16979            new_toml
16980        };
16981
16982        write_config_atomically(&config_path, &toml_str).await
16983    }
16984
16985    /// Incremental save: only the paths in `self.dirty_paths` are written
16986    /// against the existing on-disk file. Non-dirty entries (including
16987    /// secret ciphertext) are left untouched; dirty paths whose value
16988    /// equals the schema default are removed from the doc instead of
16989    /// written. Falls back to a full `save()` when the file doesn't
16990    /// exist yet. Clears the dirty set on success.
16991    pub async fn save_dirty(&mut self) -> Result<()> {
16992        if self.dirty_paths.is_empty() {
16993            return Ok(());
16994        }
16995
16996        let config_path = self.resolve_config_path_for_save().await?;
16997        if !config_path.exists() {
16998            let result = self.save().await;
16999            if result.is_ok() {
17000                self.clear_dirty();
17001            }
17002            return result;
17003        }
17004
17005        let mut config_to_save = self.clone();
17006        let zeroclaw_dir = config_path
17007            .parent()
17008            .context("Config path must have a parent directory")?;
17009        let store = crate::secrets::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
17010
17011        if !self.pre_override_snapshots.is_empty() {
17012            crate::env_overrides::mask_env_overrides_for_save(
17013                &mut config_to_save,
17014                &self.pre_override_snapshots,
17015            )?;
17016        }
17017        restore_onepassword_references_for_save(
17018            &mut config_to_save,
17019            &self.onepassword_reference_snapshots,
17020            &self.dirty_paths,
17021        )?;
17022        config_to_save.encrypt_secrets(&store)?;
17023
17024        let full_table: toml::Table = toml::Value::try_from(&config_to_save)
17025            .context("Failed to serialize config to TOML value")?
17026            .try_into()
17027            .context("Serialized config is not a TOML table")?;
17028        let default_table: toml::Table = toml::Value::try_from(Config::default())
17029            .ok()
17030            .and_then(|v| v.try_into().ok())
17031            .unwrap_or_default();
17032
17033        let existing = fs::read_to_string(&config_path).await.with_context(|| {
17034            format!(
17035                "Failed to read existing config for incremental save: {}",
17036                config_path.display()
17037            )
17038        })?;
17039        let mut doc: toml_edit::DocumentMut = existing
17040            .parse()
17041            .context("Failed to parse existing config for incremental save")?;
17042
17043        for path in &self.dirty_paths {
17044            apply_dirty_path(doc.as_table_mut(), path, &full_table, &default_table);
17045        }
17046
17047        // Stamp the current schema version. An incremental save writes
17048        // current-schema-shaped sections (e.g. the dashboard saving a single
17049        // `agents.<name>.model_provider`) but `schema_version` is never a
17050        // dirty path, so without this it keeps whatever an older binary first
17051        // wrote on disk. The resulting body-newer-than-label file then crashes
17052        // older binaries with an opaque `missing field ...` serde error. See
17053        // #7271. `insert` updates the existing key in place (preserving its
17054        // position at the top of the file) or appends it when absent.
17055        doc.as_table_mut().insert(
17056            "schema_version",
17057            toml_edit::value(i64::from(crate::migration::CURRENT_SCHEMA_VERSION)),
17058        );
17059
17060        let toml_str = ensure_blank_line_before_sections(&doc.to_string());
17061
17062        write_config_atomically(&config_path, &toml_str).await?;
17063        self.clear_dirty();
17064        Ok(())
17065    }
17066}
17067
17068fn collect_onepassword_reference_snapshots(
17069    config: &Config,
17070) -> std::collections::HashMap<String, String> {
17071    config
17072        .prop_fields()
17073        .into_iter()
17074        .filter(|field| field.is_secret)
17075        .filter_map(|field| {
17076            let value = crate::env_overrides::raw_value_for_path(config, &field.name)?;
17077            crate::secrets::SecretStore::is_onepassword_ref(&value).then_some((field.name, value))
17078        })
17079        .collect()
17080}
17081
17082fn restore_onepassword_references_for_save(
17083    config_to_save: &mut Config,
17084    snapshots: &std::collections::HashMap<String, String>,
17085    dirty_paths: &std::collections::HashSet<String>,
17086) -> Result<()> {
17087    for (path, value) in snapshots {
17088        if dirty_paths.contains(path) {
17089            continue;
17090        }
17091        if let Err(err) = config_to_save.set_prop(path, value) {
17092            ::zeroclaw_log::record!(
17093                WARN,
17094                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
17095                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
17096                    .with_attrs(::serde_json::json!({"path": path, "error": format!("{}", err)})),
17097                "1Password reference save-restore failed; field retains resolved value"
17098            );
17099        }
17100    }
17101    Ok(())
17102}
17103
17104/// Atomic write shared by `save()` and `save_dirty()`.
17105async fn write_config_atomically(config_path: &Path, toml_str: &str) -> Result<()> {
17106    let parent_dir = config_path
17107        .parent()
17108        .context("Config path must have a parent directory")?;
17109
17110    fs::create_dir_all(parent_dir).await.with_context(|| {
17111        format!(
17112            "Failed to create config directory: {}",
17113            parent_dir.display()
17114        )
17115    })?;
17116
17117    let file_name = config_path
17118        .file_name()
17119        .and_then(|v| v.to_str())
17120        .unwrap_or("config.toml");
17121    let temp_path = parent_dir.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
17122    let backup_path = parent_dir.join(format!("{file_name}.bak"));
17123
17124    let mut temp_file = OpenOptions::new()
17125        .create_new(true)
17126        .write(true)
17127        .open(&temp_path)
17128        .await
17129        .with_context(|| {
17130            format!(
17131                "Failed to create temporary config file: {}",
17132                temp_path.display()
17133            )
17134        })?;
17135    temp_file
17136        .write_all(toml_str.as_bytes())
17137        .await
17138        .context("Failed to write temporary config contents")?;
17139    temp_file
17140        .sync_all()
17141        .await
17142        .context("Failed to fsync temporary config file")?;
17143    drop(temp_file);
17144
17145    let had_existing_config = config_path.exists();
17146    if had_existing_config {
17147        fs::copy(config_path, &backup_path).await.with_context(|| {
17148            format!(
17149                "Failed to create config backup before atomic replace: {}",
17150                backup_path.display()
17151            )
17152        })?;
17153    }
17154
17155    if let Err(e) = fs::rename(&temp_path, config_path).await {
17156        let _ = fs::remove_file(&temp_path).await;
17157        if had_existing_config && backup_path.exists() {
17158            fs::copy(&backup_path, config_path)
17159                .await
17160                .context("Failed to restore config backup")?;
17161        }
17162        anyhow::bail!("Failed to atomically replace config file: {e}");
17163    }
17164
17165    #[cfg(unix)]
17166    {
17167        use std::{fs::Permissions, os::unix::fs::PermissionsExt};
17168        if let Err(err) = fs::set_permissions(config_path, Permissions::from_mode(0o600)).await {
17169            ::zeroclaw_log::record!(
17170                WARN,
17171                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
17172                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
17173                &format!(
17174                    "Failed to harden config permissions to 0600 at {}: {}",
17175                    config_path.display().to_string(),
17176                    err
17177                )
17178            );
17179        }
17180    }
17181
17182    sync_directory(parent_dir).await?;
17183
17184    if had_existing_config {
17185        let _ = fs::remove_file(&backup_path).await;
17186    }
17187
17188    Ok(())
17189}
17190
17191/// Write the in-memory value at `dotted` into the doc, or delete the leaf
17192/// when the value is absent or equals the schema default. Segments are
17193/// kebab→snake-translated; alias keys never carry hyphens (alias rule).
17194fn apply_dirty_path(
17195    root: &mut toml_edit::Table,
17196    dotted: &str,
17197    full_table: &toml::Table,
17198    default_table: &toml::Table,
17199) {
17200    let raw: Vec<&str> = dotted.split('.').collect();
17201    if raw.is_empty() {
17202        return;
17203    }
17204    // Resolve each segment against the in-memory table: struct fields
17205    // serialize as snake_case (so `input-per-mtok` → `input_per_mtok`), but
17206    // HashMap keys are preserved verbatim and may legitimately carry hyphens
17207    // (`claude-opus-4-7`, `tts-1-hd`). Blind `s.replace('-', "_")` mangles
17208    // those keys and lookup returns None, which apply_dirty_path treats as
17209    // "delete this path" — silently dropping every cost.rates save.
17210    let segments: Vec<String> = resolve_dirty_segments(full_table, &raw);
17211    let segs: Vec<&str> = segments.iter().map(String::as_str).collect();
17212
17213    let mem_val = lookup_path_in_table(full_table, &segs);
17214    let default_val = lookup_path_in_table(default_table, &segs);
17215
17216    let should_delete = match (mem_val, default_val) {
17217        (None, _) => true,
17218        (Some(m), Some(d)) if m == d => true,
17219        _ => false,
17220    };
17221
17222    if should_delete {
17223        delete_path_in_doc(root, &segs);
17224    } else if let Some(value) = mem_val {
17225        let mut pruned = value.clone();
17226        prune_empty_leaves(&mut pruned);
17227        set_path_in_doc(root, &segs, &pruned);
17228    }
17229}
17230
17231/// Drop empty arrays / tables / strings from a value before writing it
17232/// to the doc. HashMap entries serialize every default field (no
17233/// `skip_serializing_if` on individual `Vec<String>` fields), so without
17234/// this pass an `mcp_bundles.<alias>` write produces `servers = []`,
17235/// `exclude = []`, etc. The pruned form round-trips identically because
17236/// each dropped field's serde default IS the dropped value.
17237fn prune_empty_leaves(value: &mut toml::Value) {
17238    match value {
17239        toml::Value::Table(t) => {
17240            let keys: Vec<String> = t.keys().cloned().collect();
17241            for key in keys {
17242                if let Some(inner) = t.get_mut(&key) {
17243                    prune_empty_leaves(inner);
17244                }
17245                let drop = match t.get(&key) {
17246                    Some(toml::Value::Array(arr)) => arr.is_empty(),
17247                    Some(toml::Value::Table(inner)) => inner.is_empty(),
17248                    Some(toml::Value::String(s)) => s.is_empty(),
17249                    _ => false,
17250                };
17251                if drop {
17252                    t.remove(&key);
17253                }
17254            }
17255        }
17256        toml::Value::Array(arr) => {
17257            for item in arr.iter_mut() {
17258                prune_empty_leaves(item);
17259            }
17260        }
17261        _ => {}
17262    }
17263}
17264
17265fn resolve_dirty_segments(root: &toml::Table, raw: &[&str]) -> Vec<String> {
17266    let mut out: Vec<String> = Vec::with_capacity(raw.len());
17267    let mut current: Option<&toml::Value> = None;
17268    for seg in raw {
17269        let table_opt: Option<&toml::Table> = if out.is_empty() {
17270            Some(root)
17271        } else {
17272            current.and_then(|v| v.as_table())
17273        };
17274        let resolved = match table_opt {
17275            Some(t) if t.contains_key(*seg) => (*seg).to_string(),
17276            Some(t) => {
17277                let snake = seg.replace('-', "_");
17278                if t.contains_key(&snake) {
17279                    snake
17280                } else {
17281                    (*seg).to_string()
17282                }
17283            }
17284            None => (*seg).to_string(),
17285        };
17286        current = table_opt.and_then(|t| t.get(&resolved));
17287        out.push(resolved);
17288    }
17289    out
17290}
17291
17292fn lookup_path_in_table<'a>(root: &'a toml::Table, segs: &[&str]) -> Option<&'a toml::Value> {
17293    let mut current: Option<&toml::Value> = None;
17294    for (i, seg) in segs.iter().enumerate() {
17295        let table = if i == 0 { root } else { current?.as_table()? };
17296        current = table.get(*seg);
17297    }
17298    current
17299}
17300
17301fn delete_path_in_doc(root: &mut toml_edit::Table, segs: &[&str]) {
17302    let Some((last, parents)) = segs.split_last() else {
17303        return;
17304    };
17305    let mut cursor: &mut toml_edit::Table = root;
17306    for seg in parents {
17307        cursor = match cursor.get_mut(seg).and_then(|i| i.as_table_mut()) {
17308            Some(t) => t,
17309            None => return,
17310        };
17311    }
17312    cursor.remove(last);
17313}
17314
17315fn set_path_in_doc(root: &mut toml_edit::Table, segs: &[&str], value: &toml::Value) {
17316    let Some((last, parents)) = segs.split_last() else {
17317        return;
17318    };
17319    let mut cursor: &mut toml_edit::Table = root;
17320    for seg in parents {
17321        if !cursor.contains_key(seg) {
17322            cursor.insert(seg, toml_edit::Item::Table(toml_edit::Table::new()));
17323        }
17324        cursor = match cursor.get_mut(seg).and_then(|i| i.as_table_mut()) {
17325            Some(t) => t,
17326            None => return,
17327        };
17328    }
17329    let new_item = crate::migration::toml_value_to_edit_item(value);
17330    cursor.insert(last, new_item);
17331}
17332
17333#[allow(clippy::unused_async)] // async needed on unix for tokio File I/O; no-op on other platforms
17334async fn sync_directory(path: &Path) -> Result<()> {
17335    #[cfg(unix)]
17336    {
17337        let dir = File::open(path).await.with_context(|| {
17338            format!(
17339                "Failed to open directory for fsync: {}",
17340                path.display().to_string()
17341            )
17342        })?;
17343        dir.sync_all().await.with_context(|| {
17344            format!(
17345                "Failed to fsync directory metadata: {}",
17346                path.display().to_string()
17347            )
17348        })?;
17349        Ok(())
17350    }
17351
17352    #[cfg(windows)]
17353    {
17354        use std::os::windows::fs::OpenOptionsExt;
17355        const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x02000000;
17356        let dir = std::fs::OpenOptions::new()
17357            .read(true)
17358            .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
17359            .open(path)
17360            .with_context(|| {
17361                format!(
17362                    "Failed to open directory for fsync: {}",
17363                    path.display().to_string()
17364                )
17365            })?;
17366        // FlushFileBuffers on directory handles returns ERROR_ACCESS_DENIED on
17367        // Windows (OS Error 5). This is expected — NTFS does not support
17368        // flushing directory metadata the same way Unix does. The individual
17369        // files have already been synced, so it is safe to ignore this error.
17370        if let Err(e) = dir.sync_all() {
17371            if e.raw_os_error() == Some(5) {
17372                ::zeroclaw_log::record!(
17373                    TRACE,
17374                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
17375                    &format!(
17376                        "Ignoring expected ACCESS_DENIED when fsyncing directory on Windows: {}",
17377                        path.display().to_string()
17378                    )
17379                );
17380            } else {
17381                return Err(e).with_context(|| {
17382                    format!(
17383                        "Failed to fsync directory metadata: {}",
17384                        path.display().to_string()
17385                    )
17386                });
17387            }
17388        }
17389        Ok(())
17390    }
17391
17392    #[cfg(not(any(unix, windows)))]
17393    {
17394        let _ = path;
17395        Ok(())
17396    }
17397}
17398
17399// ── SOP engine configuration ───────────────────────────────────
17400
17401/// Standard Operating Procedures engine configuration (`[sop]`).
17402///
17403/// The `default_execution_mode` field uses the `SopExecutionMode` type from
17404/// `sop::types` (re-exported via `sop::SopExecutionMode`). To avoid circular
17405/// module references, config stores it using the same enum definition.
17406#[derive(Debug, Clone, Serialize, Deserialize, Configurable)]
17407#[cfg_attr(feature = "schema-export", derive(schemars::JsonSchema))]
17408#[prefix = "sop"]
17409pub struct SopConfig {
17410    /// Directory containing SOP definitions (subdirs with SOP.toml + SOP.md).
17411    /// Required to enable runtime SOP loading. When omitted, no SOPs are loaded
17412    /// at runtime; CLI commands (`sop list`, `sop validate`, `sop show`) still
17413    /// resolve the default `<workspace>/sops` for offline inspection.
17414    #[serde(default)]
17415    pub sops_dir: Option<String>,
17416
17417    /// Default execution mode for SOPs that omit `execution_mode`.
17418    /// Values: `auto`, `supervised` (default), `step_by_step`,
17419    /// `priority_based`, `deterministic`.
17420    #[serde(default = "default_sop_execution_mode")]
17421    pub default_execution_mode: String,
17422
17423    /// Maximum total concurrent SOP runs across all SOPs.
17424    #[serde(default = "default_sop_max_concurrent_total")]
17425    pub max_concurrent_total: usize,
17426
17427    /// Approval timeout in seconds. When a run waits for approval longer than
17428    /// this, Critical/High-priority SOPs auto-approve; others stay waiting.
17429    /// Set to 0 to disable timeout.
17430    #[serde(default = "default_sop_approval_timeout_secs")]
17431    pub approval_timeout_secs: u64,
17432
17433    /// Maximum number of finished runs kept in memory for status queries.
17434    /// Oldest runs are evicted when over capacity. 0 = unlimited.
17435    #[serde(default = "default_sop_max_finished_runs")]
17436    pub max_finished_runs: usize,
17437}
17438
17439fn default_sop_execution_mode() -> String {
17440    "supervised".to_string()
17441}
17442
17443fn default_sop_max_concurrent_total() -> usize {
17444    4
17445}
17446
17447fn default_sop_approval_timeout_secs() -> u64 {
17448    300
17449}
17450
17451fn default_sop_max_finished_runs() -> usize {
17452    100
17453}
17454
17455impl Default for SopConfig {
17456    fn default() -> Self {
17457        Self {
17458            sops_dir: None,
17459            default_execution_mode: default_sop_execution_mode(),
17460            max_concurrent_total: default_sop_max_concurrent_total(),
17461            approval_timeout_secs: default_sop_approval_timeout_secs(),
17462            max_finished_runs: default_sop_max_finished_runs(),
17463        }
17464    }
17465}
17466
17467// ── HasPropKind impls for config enums ──
17468// Scalars (bool, String, integers, floats) are covered by impl_prop_kind! in traits.rs.
17469// Config enums serialize as TOML strings and are classified as PropKind::Enum.
17470macro_rules! impl_enum_prop_kind {
17471    ($($ty:ty),+ $(,)?) => {
17472        $(impl HasPropKind for $ty { const PROP_KIND: PropKind = PropKind::Enum; })+
17473    };
17474}
17475impl_enum_prop_kind!(
17476    WireApi,
17477    HardwareTransport,
17478    McpTransport,
17479    ToolFilterGroupMode,
17480    SkillsPromptInjectionMode,
17481    FirecrawlMode,
17482    ProxyScope,
17483    SearchMode,
17484    CronScheduleDecl,
17485    StreamMode,
17486    WhatsAppWebMode,
17487    WhatsAppChatPolicy,
17488    LineDmPolicy,
17489    LineGroupPolicy,
17490    LarkReceiveMode,
17491    OtpMethod,
17492    SandboxBackend,
17493    AutonomyLevel,
17494    DelegationPolicy,
17495    AuthMode,
17496    OpenAIEndpoint,
17497    AzureEndpoint,
17498    AnthropicEndpoint,
17499    MoonshotEndpoint,
17500    QwenEndpoint,
17501    BedrockEndpoint,
17502    OpenRouterEndpoint,
17503    OllamaEndpoint,
17504    TogetherEndpoint,
17505    FireworksEndpoint,
17506    GroqEndpoint,
17507    MistralEndpoint,
17508    DeepseekEndpoint,
17509    CohereEndpoint,
17510    PerplexityEndpoint,
17511    XaiEndpoint,
17512    CerebrasEndpoint,
17513    SambanovaEndpoint,
17514    HyperbolicEndpoint,
17515    DeepinfraEndpoint,
17516    HuggingfaceEndpoint,
17517    Ai21Endpoint,
17518    RekaEndpoint,
17519    BasetenEndpoint,
17520    NscaleEndpoint,
17521    AnyscaleEndpoint,
17522    NebiusEndpoint,
17523    FriendliEndpoint,
17524    StepfunEndpoint,
17525    AihubmixEndpoint,
17526    SiliconflowEndpoint,
17527    AstraiEndpoint,
17528    AvianEndpoint,
17529    DeepmystEndpoint,
17530    VeniceEndpoint,
17531    NovitaEndpoint,
17532    NvidiaEndpoint,
17533    TelnyxEndpoint,
17534    VercelEndpoint,
17535    CloudflareEndpoint,
17536    OvhEndpoint,
17537    CopilotEndpoint,
17538    OpenAITtsEndpoint,
17539    ElevenLabsTtsEndpoint,
17540    GoogleTtsEndpoint,
17541    EdgeTtsEndpoint,
17542    PiperTtsEndpoint,
17543    GlmEndpoint,
17544    MinimaxEndpoint,
17545    ZaiEndpoint,
17546    DoubaoEndpoint,
17547    YiEndpoint,
17548    HunyuanEndpoint,
17549    QianfanEndpoint,
17550    BaichuanEndpoint,
17551    GeminiEndpoint,
17552    GeminiCliEndpoint,
17553    LmstudioEndpoint,
17554    LlamacppEndpoint,
17555    SglangEndpoint,
17556    VllmEndpoint,
17557    OsaurusEndpoint,
17558    LitellmEndpoint,
17559    LeptonEndpoint,
17560    MorphEndpoint,
17561    GithubModelsEndpoint,
17562    UpstageEndpoint,
17563    FeatherlessEndpoint,
17564    ArceeEndpoint,
17565    LambdaAiEndpoint,
17566    InceptionEndpoint,
17567    SyntheticEndpoint,
17568    OpencodeEndpoint,
17569    KiloCliEndpoint,
17570    KiloEndpoint,
17571    CustomEndpoint,
17572);
17573
17574impl HasPropKind for serde_json::Value {
17575    // `serde_json::Value` is an arbitrary JSON document, not an enum.
17576    // Classifying it as `Enum` previously made `enum_variants_for::<Value>()`
17577    // hand back the literal placeholder `"(unknown variants)"`, and the
17578    // dashboard form rendered fields like `model_providers.<key>.provider_extra`
17579    // as a single-option dropdown. `String` is the closest scalar kind —
17580    // the form renders a text input where the user pastes raw JSON.
17581    // Round-trip via `set_prop` stays correct: serde deserializes the TOML
17582    // string back into `Value::String(...)`. Power users editing complex
17583    // objects still use `zeroclaw config set --json` or hand-edit the
17584    // `config.toml`.
17585    const PROP_KIND: PropKind = PropKind::String;
17586}
17587
17588#[cfg(test)]
17589mod tests {
17590
17591    #[test]
17592    async fn amqp_validate_requires_paired_client_cert_and_key() {
17593        let base = AmqpConfig {
17594            enabled: true,
17595            amqp_url: "amqps://broker.example.org:5671/%2Fpublic".into(),
17596            exchange: "amq.topic".into(),
17597            routing_keys: vec!["org.example.release".into()],
17598            ca_cert: Some(std::path::PathBuf::from("/etc/ssl/ca.pem")),
17599            ..AmqpConfig::default()
17600        };
17601
17602        // Both absent: server-auth only, valid.
17603        assert!(base.validate().is_ok());
17604
17605        // Cert without key: invalid.
17606        let cert_only = AmqpConfig {
17607            client_cert: Some(std::path::PathBuf::from("/etc/ssl/client.pem")),
17608            ..base.clone()
17609        };
17610        assert!(cert_only.validate().is_err());
17611
17612        // Key without cert: invalid.
17613        let key_only = AmqpConfig {
17614            client_key: Some(std::path::PathBuf::from("/etc/ssl/client.key")),
17615            ..base.clone()
17616        };
17617        assert!(key_only.validate().is_err());
17618
17619        // Both present: valid.
17620        let both = AmqpConfig {
17621            client_cert: Some(std::path::PathBuf::from("/etc/ssl/client.pem")),
17622            client_key: Some(std::path::PathBuf::from("/etc/ssl/client.key")),
17623            ..base
17624        };
17625        assert!(both.validate().is_ok());
17626    }
17627    use super::*;
17628    use std::ffi::OsString;
17629    #[cfg(unix)]
17630    use std::os::unix::fs::PermissionsExt;
17631    #[cfg(unix)]
17632    use std::path::Path;
17633    use std::path::PathBuf;
17634    use tempfile::TempDir;
17635    use tokio::sync::MutexGuard;
17636    use tokio::test;
17637
17638    struct EnvValueGuard {
17639        key: &'static str,
17640        previous: Option<OsString>,
17641    }
17642
17643    impl EnvValueGuard {
17644        fn set(key: &'static str, value: impl AsRef<std::ffi::OsStr>) -> Self {
17645            let previous = std::env::var_os(key);
17646            // SAFETY: tests that mutate env vars serialize on env_override_lock().
17647            unsafe { std::env::set_var(key, value) };
17648            Self { key, previous }
17649        }
17650
17651        fn remove(key: &'static str) -> Self {
17652            let previous = std::env::var_os(key);
17653            // SAFETY: tests that mutate env vars serialize on env_override_lock().
17654            unsafe { std::env::remove_var(key) };
17655            Self { key, previous }
17656        }
17657    }
17658
17659    impl Drop for EnvValueGuard {
17660        fn drop(&mut self) {
17661            // SAFETY: tests that mutate env vars serialize on env_override_lock().
17662            unsafe {
17663                if let Some(previous) = &self.previous {
17664                    std::env::set_var(self.key, previous);
17665                } else {
17666                    std::env::remove_var(self.key);
17667                }
17668            }
17669        }
17670    }
17671
17672    #[cfg(unix)]
17673    fn write_fake_op(bin_dir: &Path, script: &str) -> PathBuf {
17674        let op_path = bin_dir.join("op");
17675        std::fs::write(&op_path, script).expect("write fake op");
17676        let mut perms = std::fs::metadata(&op_path).unwrap().permissions();
17677        perms.set_mode(0o755);
17678        std::fs::set_permissions(&op_path, perms).unwrap();
17679        op_path
17680    }
17681
17682    #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
17683    #[prefix = "test.object_array.entries"]
17684    struct ObjectArraySecretEntry {
17685        pub name: String,
17686        #[secret]
17687        pub token: Option<String>,
17688        #[secret]
17689        pub headers: HashMap<String, String>,
17690    }
17691
17692    impl crate::config::HasPropKind for Vec<ObjectArraySecretEntry> {
17693        const PROP_KIND: crate::config::PropKind = crate::config::PropKind::ObjectArray;
17694
17695        fn display_secret_terminals() -> Vec<&'static str> {
17696            ObjectArraySecretEntry::secret_field_terminals()
17697        }
17698    }
17699
17700    #[derive(Debug, Clone, Default, Serialize, Deserialize, Configurable)]
17701    #[prefix = "test.object_array"]
17702    struct ObjectArraySecretFixture {
17703        pub entries: Vec<ObjectArraySecretEntry>,
17704    }
17705
17706    // ── Tilde expansion ───────────────────────────────────────
17707
17708    #[test]
17709    async fn expand_tilde_path_handles_absolute_path() {
17710        let path = expand_tilde_path("/absolute/path");
17711        assert_eq!(path, PathBuf::from("/absolute/path"));
17712    }
17713
17714    #[test]
17715    async fn expand_tilde_path_handles_relative_path() {
17716        let path = expand_tilde_path("relative/path");
17717        assert_eq!(path, PathBuf::from("relative/path"));
17718    }
17719
17720    #[test]
17721    async fn expand_tilde_path_expands_tilde_when_home_set() {
17722        // This test verifies that tilde expansion works when HOME is set.
17723        // In normal environments, HOME is set, so ~ should expand.
17724        let path = expand_tilde_path("~/.zeroclaw");
17725        // The path should not literally start with '~' if HOME is set
17726        // (it should be expanded to the actual home directory)
17727        if std::env::var("HOME").is_ok() {
17728            assert!(
17729                !path.to_string_lossy().starts_with('~'),
17730                "Tilde should be expanded when HOME is set"
17731            );
17732        }
17733    }
17734
17735    // ── Defaults ─────────────────────────────────────────────
17736
17737    fn has_test_table(raw: &str, table: &str) -> bool {
17738        let exact = format!("[{table}]");
17739        let nested = format!("[{table}.");
17740        raw.lines()
17741            .map(str::trim)
17742            .any(|line| line == exact || line.starts_with(&nested))
17743    }
17744
17745    fn parse_test_config(raw: &str) -> Config {
17746        let mut merged = raw.trim().to_string();
17747        for table in [
17748            "data_retention",
17749            "cloud_ops",
17750            "conversational_ai",
17751            "security",
17752            "security_ops",
17753        ] {
17754            if has_test_table(&merged, table) {
17755                continue;
17756            }
17757            if !merged.is_empty() {
17758                merged.push_str("\n\n");
17759            }
17760            merged.push('[');
17761            merged.push_str(table);
17762            merged.push(']');
17763        }
17764        merged.push('\n');
17765        // Schema-deserialization helper: parses TOML directly into Config
17766        // WITHOUT running migration transforms. Tests that need migration
17767        // behavior should use `migrate_to_current` directly. This helper
17768        // exists so V2-shaped inputs (e.g. flat `[autonomy]` blocks) can
17769        // be exercised against the typed deserializer without losing
17770        // sections that V2→V3 strips.
17771        let mut config: Config = toml::from_str(&merged).unwrap();
17772        config
17773            .risk_profiles
17774            .entry("default".to_string())
17775            .or_default()
17776            .ensure_default_auto_approve();
17777        config
17778    }
17779
17780    #[test]
17781    async fn http_request_config_default_has_correct_values() {
17782        let cfg = HttpRequestConfig::default();
17783        assert_eq!(cfg.timeout_secs, 30);
17784        assert_eq!(cfg.max_response_size, 1_000_000);
17785        assert!(cfg.enabled);
17786        assert_eq!(cfg.allowed_domains, vec!["*".to_string()]);
17787        assert!(!cfg.allow_private_hosts);
17788        assert!(cfg.allowed_private_hosts.is_empty());
17789        assert!(cfg.secrets.is_empty());
17790    }
17791
17792    #[test]
17793    async fn http_request_config_deserializes_allowed_private_hosts() {
17794        let c = parse_test_config(
17795            r#"
17796[http_request]
17797allowed_domains = ["example.com"]
17798allowed_private_hosts = ["localhost", "10.0.0.1"]
17799"#,
17800        );
17801
17802        assert_eq!(
17803            c.http_request.allowed_private_hosts,
17804            vec!["localhost".to_string(), "10.0.0.1".to_string()]
17805        );
17806    }
17807
17808    #[test]
17809    async fn http_request_config_deserializes_auth_secrets() {
17810        let c = parse_test_config(
17811            r#"
17812[http_request.secrets]
17813api_token = "Bearer test-token"
17814"#,
17815        );
17816
17817        assert_eq!(
17818            c.http_request.secrets.get("api_token").map(String::as_str),
17819            Some("Bearer test-token")
17820        );
17821    }
17822
17823    #[test]
17824    async fn http_request_auth_secret_names_are_validated() {
17825        let mut config = Config::default();
17826        config
17827            .http_request
17828            .secrets
17829            .insert("bad.name".to_string(), "Bearer test-token".to_string());
17830
17831        let err = config.validate().expect_err("invalid secret name");
17832        assert!(
17833            err.to_string().contains("http_request.secrets.bad.name"),
17834            "validation error must name the bad auth secret path: {err}"
17835        );
17836    }
17837
17838    #[test]
17839    async fn config_default_has_sane_values() {
17840        let c = Config::default();
17841        // No model_provider configured by default — set during Quickstart.
17842        assert!(c.providers.models.is_empty());
17843        assert!(c.providers.models.iter_entries().next().is_none());
17844        assert!(!c.skills.open_skills_enabled);
17845        assert!(!c.skills.allow_scripts);
17846        assert!(!c.skills.install_suggestions.enabled);
17847        assert_eq!(
17848            c.skills.prompt_injection_mode,
17849            SkillsPromptInjectionMode::Full
17850        );
17851        assert!(c.data_dir.to_string_lossy().contains("data"));
17852        assert!(c.config_path.to_string_lossy().contains("config.toml"));
17853    }
17854
17855    #[test]
17856    async fn skills_install_suggestions_config_deserializes_enabled() {
17857        let c = parse_test_config(
17858            r#"
17859[skills.install_suggestions]
17860enabled = true
17861"#,
17862        );
17863
17864        assert!(c.skills.install_suggestions.enabled);
17865    }
17866
17867    #[test]
17868    async fn skills_install_suggestions_config_accepts_hyphen_alias() {
17869        let c = parse_test_config(
17870            r#"
17871[skills.install-suggestions]
17872enabled = true
17873"#,
17874        );
17875
17876        assert!(c.skills.install_suggestions.enabled);
17877    }
17878
17879    fn capture_log_events() -> tokio::sync::broadcast::Receiver<serde_json::Value> {
17880        ::zeroclaw_log::try_install_capture_subscriber();
17881        ::zeroclaw_log::subscribe_or_install()
17882    }
17883
17884    fn drain_captured(rx: &mut tokio::sync::broadcast::Receiver<serde_json::Value>) -> String {
17885        let mut buf = String::new();
17886        while let Ok(value) = rx.try_recv() {
17887            buf.push_str(&serde_json::to_string(&value).unwrap_or_default());
17888            buf.push('\n');
17889        }
17890        buf
17891    }
17892
17893    #[test]
17894    async fn config_dir_creation_error_mentions_openrc_and_path() {
17895        let msg = config_dir_creation_error(Path::new("/etc/zeroclaw"));
17896        assert!(msg.contains("/etc/zeroclaw"));
17897        assert!(msg.contains("OpenRC"));
17898        assert!(msg.contains("zeroclaw"));
17899    }
17900
17901    #[test]
17902    async fn config_schema_export_contains_expected_contract_shape() {
17903        #[cfg(feature = "schema-export")]
17904        let schema = schemars::schema_for!(Config);
17905        let schema_json = serde_json::to_value(&schema).expect("schema should serialize to json");
17906
17907        assert_eq!(
17908            schema_json
17909                .get("$schema")
17910                .and_then(serde_json::Value::as_str),
17911            Some("https://json-schema.org/draft/2020-12/schema")
17912        );
17913
17914        let properties = schema_json
17915            .get("properties")
17916            .and_then(serde_json::Value::as_object)
17917            .expect("schema should expose top-level properties");
17918
17919        assert!(properties.contains_key("providers"));
17920        assert!(properties.contains_key("skills"));
17921        assert!(properties.contains_key("gateway"));
17922        assert!(properties.contains_key("channels"));
17923        assert!(!properties.contains_key("workspace_dir"));
17924        assert!(!properties.contains_key("config_path"));
17925        assert!(!properties.contains_key("model_providers"));
17926        assert!(!properties.contains_key("tts_providers"));
17927        assert!(!properties.contains_key("transcription_providers"));
17928        // These fields are now #[serde(skip)] cache fields, not in schema.
17929        assert!(!properties.contains_key("default_model_provider"));
17930        assert!(!properties.contains_key("api_key"));
17931        assert!(!properties.contains_key("default_model"));
17932
17933        assert!(
17934            schema_json
17935                .get("$defs")
17936                .and_then(serde_json::Value::as_object)
17937                .is_some(),
17938            "schema should include reusable type definitions"
17939        );
17940    }
17941
17942    #[cfg(unix)]
17943    #[test]
17944    async fn save_sets_config_permissions_on_new_file() {
17945        let temp = TempDir::new().expect("temp dir");
17946        let config_path = temp.path().join("config.toml");
17947        let workspace_dir = temp.path().join("workspace");
17948
17949        let config = Config {
17950            config_path: config_path.clone(),
17951            data_dir: workspace_dir,
17952            ..Default::default()
17953        };
17954
17955        config.save().await.expect("save config");
17956
17957        let mode = std::fs::metadata(&config_path)
17958            .expect("config metadata")
17959            .permissions()
17960            .mode()
17961            & 0o777;
17962        assert_eq!(mode, 0o600);
17963    }
17964
17965    #[test]
17966    async fn validate_rejects_reply_min_interval_above_upper_bound() {
17967        let mut config = Config::default();
17968        let mut tg = TelegramConfig {
17969            bot_token: "tok".into(),
17970            ..Default::default()
17971        };
17972        tg.reply_min_interval_secs = REPLY_MIN_INTERVAL_MAX_SECS + 1;
17973        config.channels.telegram.insert("default".to_string(), tg);
17974        let err = config.validate().expect_err("over-bound must be rejected");
17975        let msg = err.to_string();
17976        assert!(
17977            msg.contains("channels.telegram.default.reply_min_interval_secs"),
17978            "error must name the offending path; got: {msg}"
17979        );
17980    }
17981
17982    #[test]
17983    async fn validate_accepts_reply_min_interval_at_upper_bound() {
17984        let mut config = Config::default();
17985        let mut tg = TelegramConfig {
17986            bot_token: "tok".into(),
17987            ..Default::default()
17988        };
17989        tg.reply_min_interval_secs = REPLY_MIN_INTERVAL_MAX_SECS;
17990        config.channels.telegram.insert("default".to_string(), tg);
17991        config.validate().expect("documented upper bound must pass");
17992    }
17993
17994    #[test]
17995    async fn validate_rejects_reply_queue_depth_above_ceiling() {
17996        let mut config = Config::default();
17997        let mut tg = TelegramConfig {
17998            bot_token: "tok".into(),
17999            ..Default::default()
18000        };
18001        tg.reply_min_interval_secs = 1;
18002        tg.reply_queue_depth_max = REPLY_QUEUE_DEPTH_CEILING + 1;
18003        config.channels.telegram.insert("default".to_string(), tg);
18004        let err = config
18005            .validate()
18006            .expect_err("over-ceiling depth must be rejected");
18007        let msg = err.to_string();
18008        assert!(
18009            msg.contains("channels.telegram.default.reply_queue_depth_max"),
18010            "error must name the offending path; got: {msg}"
18011        );
18012    }
18013
18014    #[test]
18015    async fn validate_accepts_reply_queue_depth_at_ceiling() {
18016        let mut config = Config::default();
18017        let mut tg = TelegramConfig {
18018            bot_token: "tok".into(),
18019            ..Default::default()
18020        };
18021        tg.reply_min_interval_secs = 1;
18022        tg.reply_queue_depth_max = REPLY_QUEUE_DEPTH_CEILING;
18023        config.channels.telegram.insert("default".to_string(), tg);
18024        config.validate().expect("documented ceiling must pass");
18025    }
18026
18027    #[test]
18028    async fn validate_accepts_reply_queue_depth_zero_meaning_default() {
18029        // depth=0 means "fall back to DEFAULT_REPLY_QUEUE_DEPTH at the
18030        // pacing-wrapper construction site." Validator must accept it.
18031        let mut config = Config::default();
18032        let mut tg = TelegramConfig {
18033            bot_token: "tok".into(),
18034            ..Default::default()
18035        };
18036        tg.reply_min_interval_secs = 1;
18037        tg.reply_queue_depth_max = 0;
18038        config.channels.telegram.insert("default".to_string(), tg);
18039        config
18040            .validate()
18041            .expect("zero depth means default; must pass");
18042    }
18043
18044    #[test]
18045    async fn observability_config_default() {
18046        let o = ObservabilityConfig::default();
18047        assert_eq!(o.backend, "none");
18048        assert_eq!(o.log_persistence, "rolling");
18049        assert_eq!(o.log_persistence_path, "state/runtime-trace.jsonl");
18050        assert_eq!(o.log_persistence_max_entries, 200);
18051        assert_eq!(o.log_tool_io, "redacted");
18052        assert_eq!(o.log_tool_io_truncate_bytes, 40960);
18053        assert!(o.log_tool_io_denylist.is_empty());
18054    }
18055
18056    #[test]
18057    async fn risk_profile_default_mirrors_v2_autonomy_safety_defaults() {
18058        let a = RiskProfileConfig::default();
18059        assert_eq!(a.level, AutonomyLevel::Supervised);
18060        assert!(a.workspace_only);
18061        assert!(a.allowed_commands.contains(&"git".to_string()));
18062        assert!(a.allowed_commands.contains(&"cargo".to_string()));
18063        assert!(
18064            !a.forbidden_paths.is_empty(),
18065            "default forbidden_paths must not be empty"
18066        );
18067        #[cfg(not(target_os = "windows"))]
18068        assert!(
18069            a.forbidden_paths.iter().any(|p| p == "/etc"),
18070            "Default forbidden_paths must include /etc on Unix"
18071        );
18072        #[cfg(target_os = "windows")]
18073        assert!(
18074            a.forbidden_paths.iter().any(|p| p == "C:\\Windows"),
18075            "Default forbidden_paths must include C:\\Windows on Windows"
18076        );
18077        assert!(
18078            a.forbidden_paths.contains(&"~/.ssh".to_string()),
18079            "Default forbidden_paths must include ~/.ssh"
18080        );
18081        assert!(a.require_approval_for_medium_risk);
18082        assert!(a.block_high_risk_commands);
18083        assert!(a.shell_env_passthrough.is_empty());
18084        assert!(a.allowed_tools.is_empty());
18085    }
18086
18087    #[test]
18088    async fn runtime_config_default() {
18089        let r = RuntimeConfig::default();
18090        assert_eq!(r.kind, "native");
18091        assert_eq!(r.docker.image, "alpine:3.20");
18092        assert_eq!(r.docker.network, "none");
18093        assert_eq!(r.docker.memory_limit_mb, Some(512));
18094        assert_eq!(r.docker.cpu_limit, Some(1.0));
18095        assert!(r.docker.read_only_rootfs);
18096        assert!(r.docker.mount_workspace);
18097    }
18098
18099    #[test]
18100    async fn heartbeat_config_default() {
18101        let h = HeartbeatConfig::default();
18102        // Heartbeat defaults to disabled. Enabling requires the user to
18103        // also bind it to a configured agent — there is no default agent
18104        // for heartbeat to fall through to.
18105        assert!(!h.enabled);
18106        assert!(h.agent.is_empty());
18107        assert_eq!(h.interval_minutes, 30);
18108        assert!(h.message.is_none());
18109        assert!(h.target.is_none());
18110        assert!(h.to.is_none());
18111    }
18112
18113    #[test]
18114    async fn heartbeat_config_parses_delivery_aliases() {
18115        let raw = r#"
18116enabled = true
18117interval_minutes = 10
18118message = "Ping"
18119channel = "telegram"
18120recipient = "42"
18121"#;
18122        let parsed: HeartbeatConfig = toml::from_str(raw).unwrap();
18123        assert!(parsed.enabled);
18124        assert_eq!(parsed.interval_minutes, 10);
18125        assert_eq!(parsed.message.as_deref(), Some("Ping"));
18126        assert_eq!(parsed.target.as_deref(), Some("telegram"));
18127        assert_eq!(parsed.to.as_deref(), Some("42"));
18128    }
18129
18130    #[test]
18131    async fn scheduler_config_default() {
18132        let s = SchedulerConfig::default();
18133        assert!(s.enabled);
18134        assert!(s.catch_up_on_startup);
18135        assert_eq!(s.max_run_history, 50);
18136    }
18137
18138    #[test]
18139    async fn scheduler_config_serde_roundtrip() {
18140        let s = SchedulerConfig {
18141            enabled: false,
18142            max_tasks: 16,
18143            max_concurrent: 2,
18144            catch_up_on_startup: false,
18145            max_run_history: 100,
18146        };
18147        let json = serde_json::to_string(&s).unwrap();
18148        let parsed: SchedulerConfig = serde_json::from_str(&json).unwrap();
18149        assert!(!parsed.enabled);
18150        assert!(!parsed.catch_up_on_startup);
18151        assert_eq!(parsed.max_run_history, 100);
18152    }
18153
18154    #[test]
18155    async fn config_defaults_scheduler_when_section_missing() {
18156        let toml_str = r#"
18157workspace_dir = "/tmp/workspace"
18158config_path = "/tmp/config.toml"
18159default_temperature = 0.7
18160"#;
18161
18162        let parsed = parse_test_config(toml_str);
18163        assert!(parsed.scheduler.enabled);
18164        assert!(parsed.scheduler.catch_up_on_startup);
18165        assert_eq!(parsed.scheduler.max_run_history, 50);
18166        assert!(parsed.cron.is_empty());
18167    }
18168
18169    #[test]
18170    async fn memory_config_default_hygiene_settings() {
18171        let m = MemoryConfig::default();
18172        assert_eq!(m.backend, "sqlite");
18173        assert!(m.auto_save);
18174        assert!(m.hygiene_enabled);
18175        assert_eq!(m.archive_after_days, 7);
18176        assert_eq!(m.purge_after_days, 30);
18177        assert_eq!(m.conversation_retention_days, 30);
18178        assert_eq!(m.search_mode, SearchMode::Hybrid);
18179    }
18180
18181    #[test]
18182    async fn search_mode_config_deserialization() {
18183        let toml_str = r#"
18184workspace_dir = "/tmp/workspace"
18185config_path = "/tmp/config.toml"
18186default_temperature = 0.7
18187
18188[memory]
18189backend = "sqlite"
18190auto_save = true
18191search_mode = "bm25"
18192"#;
18193        let parsed = parse_test_config(toml_str);
18194        assert_eq!(parsed.memory.search_mode, SearchMode::Bm25);
18195
18196        let toml_str_embedding = r#"
18197workspace_dir = "/tmp/workspace"
18198config_path = "/tmp/config.toml"
18199default_temperature = 0.7
18200
18201[memory]
18202backend = "sqlite"
18203auto_save = true
18204search_mode = "embedding"
18205"#;
18206        let parsed = parse_test_config(toml_str_embedding);
18207        assert_eq!(parsed.memory.search_mode, SearchMode::Embedding);
18208
18209        let toml_str_hybrid = r#"
18210workspace_dir = "/tmp/workspace"
18211config_path = "/tmp/config.toml"
18212default_temperature = 0.7
18213
18214[memory]
18215backend = "sqlite"
18216auto_save = true
18217search_mode = "hybrid"
18218"#;
18219        let parsed = parse_test_config(toml_str_hybrid);
18220        assert_eq!(parsed.memory.search_mode, SearchMode::Hybrid);
18221    }
18222
18223    #[test]
18224    async fn search_mode_defaults_to_hybrid_when_omitted() {
18225        let toml_str = r#"
18226workspace_dir = "/tmp/workspace"
18227config_path = "/tmp/config.toml"
18228default_temperature = 0.7
18229
18230[memory]
18231backend = "sqlite"
18232auto_save = true
18233"#;
18234        let parsed = parse_test_config(toml_str);
18235        assert_eq!(parsed.memory.search_mode, SearchMode::Hybrid);
18236    }
18237
18238    #[test]
18239    async fn search_mode_serde_roundtrip() {
18240        let json_bm25 = serde_json::to_string(&SearchMode::Bm25).unwrap();
18241        assert_eq!(json_bm25, "\"bm25\"");
18242        let parsed: SearchMode = serde_json::from_str(&json_bm25).unwrap();
18243        assert_eq!(parsed, SearchMode::Bm25);
18244
18245        let json_embedding = serde_json::to_string(&SearchMode::Embedding).unwrap();
18246        assert_eq!(json_embedding, "\"embedding\"");
18247        let parsed: SearchMode = serde_json::from_str(&json_embedding).unwrap();
18248        assert_eq!(parsed, SearchMode::Embedding);
18249
18250        let json_hybrid = serde_json::to_string(&SearchMode::Hybrid).unwrap();
18251        assert_eq!(json_hybrid, "\"hybrid\"");
18252        let parsed: SearchMode = serde_json::from_str(&json_hybrid).unwrap();
18253        assert_eq!(parsed, SearchMode::Hybrid);
18254    }
18255
18256    #[test]
18257    async fn storage_two_tier_defaults_empty() {
18258        let storage = StorageConfig::default();
18259        assert!(storage.sqlite.is_empty());
18260        assert!(storage.postgres.is_empty());
18261        assert!(storage.qdrant.is_empty());
18262        assert!(storage.markdown.is_empty());
18263        assert!(storage.lucid.is_empty());
18264    }
18265
18266    #[test]
18267    async fn storage_postgres_alias_pgvector_roundtrip() {
18268        let toml = r#"
18269            [postgres.default]
18270            db_url = "postgres://user:pw@host/db"
18271            vector_enabled = true
18272            vector_dimensions = 768
18273        "#;
18274        let parsed: StorageConfig = toml::from_str(toml).unwrap();
18275        let pg = parsed.postgres.get("default").expect("alias present");
18276        assert_eq!(pg.db_url.as_deref(), Some("postgres://user:pw@host/db"));
18277        assert!(pg.vector_enabled);
18278        assert_eq!(pg.vector_dimensions, 768);
18279    }
18280
18281    #[test]
18282    async fn storage_postgres_pgvector_defaults_when_omitted() {
18283        let toml = r#"
18284            [postgres.default]
18285        "#;
18286        let parsed: StorageConfig = toml::from_str(toml).unwrap();
18287        let pg = parsed.postgres.get("default").expect("alias present");
18288        assert!(!pg.vector_enabled);
18289        assert_eq!(pg.vector_dimensions, 1536);
18290        assert_eq!(pg.schema, "public");
18291        assert_eq!(pg.table, "memories");
18292    }
18293
18294    #[test]
18295    async fn ollama_alias_tuning_fields_roundtrip() {
18296        // Ollama-specific tuning lives on `OllamaModelProviderConfig`,
18297        // not on the generic `ModelProviderConfig` base. These knobs
18298        // ride alongside the flattened `base` so a TOML alias like
18299        // `[providers.models.ollama.local]` accepts them at the same
18300        // level as `model`, `api_key`, etc.
18301        let toml = r#"
18302            num_ctx = 16384
18303            num_predict = 4096
18304            temperature_override = 0.5
18305        "#;
18306        let parsed: OllamaModelProviderConfig = toml::from_str(toml).unwrap();
18307        assert_eq!(parsed.num_ctx, Some(16384));
18308        assert_eq!(parsed.num_predict, Some(4096));
18309        assert_eq!(parsed.temperature_override, Some(0.5));
18310
18311        let serialized = toml::to_string(&parsed).unwrap();
18312        let reparsed: OllamaModelProviderConfig = toml::from_str(&serialized).unwrap();
18313        assert_eq!(reparsed.num_ctx, Some(16384));
18314        assert_eq!(reparsed.num_predict, Some(4096));
18315        assert_eq!(reparsed.temperature_override, Some(0.5));
18316    }
18317
18318    #[test]
18319    async fn ollama_alias_tuning_fields_default_to_none() {
18320        let toml = r#"
18321            api_key = "sk-test"
18322        "#;
18323        let parsed: OllamaModelProviderConfig = toml::from_str(toml).unwrap();
18324        assert!(parsed.num_ctx.is_none());
18325        assert!(parsed.num_predict.is_none());
18326        assert!(parsed.temperature_override.is_none());
18327    }
18328
18329    #[test]
18330    async fn channels_default() {
18331        let c = ChannelsConfig::default();
18332        assert!(c.cli);
18333        assert!(c.telegram.is_empty());
18334        assert!(c.discord.is_empty());
18335        assert!(c.wecom_ws.is_empty());
18336        assert!(!c.show_tool_calls);
18337        assert_eq!(
18338            c.max_concurrent_per_channel,
18339            default_channel_max_concurrent_per_channel()
18340        );
18341    }
18342
18343    #[test]
18344    async fn channels_max_concurrent_per_channel_defaults_and_round_trips() {
18345        let parsed: ChannelsConfig = toml::from_str("cli = true").unwrap();
18346        assert_eq!(
18347            parsed.max_concurrent_per_channel,
18348            default_channel_max_concurrent_per_channel()
18349        );
18350
18351        let parsed: ChannelsConfig =
18352            toml::from_str("cli = true\nmax_concurrent_per_channel = 2").unwrap();
18353        assert_eq!(parsed.max_concurrent_per_channel, 2);
18354
18355        let toml_str = toml::to_string_pretty(&parsed).unwrap();
18356        let reparsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
18357        assert_eq!(reparsed.max_concurrent_per_channel, 2);
18358    }
18359
18360    #[test]
18361    async fn validate_rejects_zero_channel_max_concurrent_per_channel() {
18362        let mut config = Config::default();
18363        config.channels.max_concurrent_per_channel = 0;
18364
18365        let err = config
18366            .validate()
18367            .expect_err("zero channel concurrency budget must fail validate");
18368        assert!(
18369            err.to_string()
18370                .contains("channels.max_concurrent_per_channel must be greater than 0"),
18371            "got: {err}"
18372        );
18373    }
18374
18375    #[test]
18376    async fn wecom_ws_config_serde_defaults_and_secret_metadata() {
18377        let toml = r#"
18378            enabled = true
18379            bot_id = "bot-123"
18380            secret = "sk-test"
18381            allowed_users = ["zeroclaw_user"]
18382            allowed_groups = ["zeroclaw_group"]
18383            bot_name = "danya"
18384            proxy_url = "http://127.0.0.1:7890"
18385        "#;
18386        let parsed: WeComWsConfig = toml::from_str(toml).unwrap();
18387
18388        assert!(parsed.enabled);
18389        assert_eq!(parsed.bot_id, "bot-123");
18390        assert_eq!(parsed.secret, "sk-test");
18391        assert_eq!(parsed.allowed_users, vec!["zeroclaw_user"]);
18392        assert_eq!(parsed.allowed_groups, vec!["zeroclaw_group"]);
18393        assert_eq!(parsed.bot_name.as_deref(), Some("danya"));
18394        assert_eq!(parsed.file_retention_days, 7);
18395        assert_eq!(parsed.max_file_size_mb, 20);
18396        assert_eq!(parsed.stream_mode, StreamMode::Partial);
18397        assert_eq!(parsed.proxy_url.as_deref(), Some("http://127.0.0.1:7890"));
18398        assert!(parsed.excluded_tools.is_empty());
18399        assert_eq!(WeComWsConfig::default().file_retention_days, 7);
18400        assert_eq!(WeComWsConfig::default().max_file_size_mb, 20);
18401        assert_eq!(WeComWsConfig::default().stream_mode, StreamMode::Partial);
18402        assert!(WeComWsConfig::default().bot_name.is_none());
18403        assert!(WeComWsConfig::default().proxy_url.is_none());
18404        assert!(WeComWsConfig::prop_is_secret("channels.wecom_ws.secret"));
18405    }
18406
18407    #[test]
18408    async fn config_parses_wecom_ws_separate_from_wecom_webhook() {
18409        let toml = r#"
18410            [channels.wecom.default]
18411            enabled = true
18412            webhook_key = "webhook-key"
18413
18414            [channels.wecom_ws.default]
18415            enabled = true
18416            bot_id = "bot-123"
18417            secret = "sk-test"
18418            allowed_users = ["zeroclaw_user"]
18419        "#;
18420        let parsed: Config = toml::from_str(toml).unwrap();
18421
18422        assert_eq!(
18423            parsed.channels.wecom.get("default").unwrap().webhook_key,
18424            "webhook-key"
18425        );
18426        let ws = parsed.channels.wecom_ws.get("default").unwrap();
18427        assert_eq!(ws.bot_id, "bot-123");
18428        assert_eq!(ws.allowed_users, vec!["zeroclaw_user"]);
18429        assert_eq!(ws.stream_mode, StreamMode::Partial);
18430    }
18431
18432    // ── Serde round-trip ─────────────────────────────────────
18433
18434    #[test]
18435    async fn config_toml_roundtrip() {
18436        let config = Config {
18437            degraded_security: Vec::new(),
18438            schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
18439            providers: {
18440                let mut p = crate::providers::Providers::default();
18441                p.models.openrouter.insert(
18442                    "default".to_string(),
18443                    OpenRouterModelProviderConfig {
18444                        base: ModelProviderConfig {
18445                            api_key: Some("sk-test-key".into()),
18446                            model: Some("gpt-4o".into()),
18447                            temperature: Some(0.5),
18448                            timeout_secs: Some(120),
18449                            ..Default::default()
18450                        },
18451                    },
18452                );
18453                p
18454            },
18455            model_routes: Vec::new(),
18456            embedding_routes: Vec::new(),
18457            data_dir: PathBuf::from("/tmp/test/workspace"),
18458            config_path: PathBuf::from("/tmp/test/config.toml"),
18459            observability: ObservabilityConfig {
18460                backend: "log".into(),
18461                ..ObservabilityConfig::default()
18462            },
18463            risk_profiles: {
18464                let mut m = HashMap::new();
18465                m.insert(
18466                    "default".into(),
18467                    RiskProfileConfig {
18468                        level: AutonomyLevel::Full,
18469                        workspace_only: false,
18470                        allowed_commands: vec!["docker".into()],
18471                        forbidden_paths: vec!["/secret".into()],
18472                        require_approval_for_medium_risk: false,
18473                        block_high_risk_commands: true,
18474                        shell_env_passthrough: vec!["DATABASE_URL".into()],
18475                        auto_approve: vec!["file_read".into()],
18476                        always_ask: vec![],
18477                        allowed_roots: vec![],
18478                        allowed_tools: vec![],
18479                        excluded_tools: vec![],
18480                        ..RiskProfileConfig::default()
18481                    },
18482                );
18483                m
18484            },
18485            trust: crate::scattered_types::TrustConfig::default(),
18486            backup: BackupConfig::default(),
18487            data_retention: DataRetentionConfig::default(),
18488            cloud_ops: CloudOpsConfig::default(),
18489            conversational_ai: ConversationalAiConfig::default(),
18490            security: SecurityConfig::default(),
18491            security_ops: SecurityOpsConfig::default(),
18492            runtime: RuntimeConfig {
18493                kind: "docker".into(),
18494                ..RuntimeConfig::default()
18495            },
18496            reliability: ReliabilityConfig::default(),
18497            scheduler: SchedulerConfig::default(),
18498            skills: SkillsConfig::default(),
18499            pipeline: PipelineConfig::default(),
18500            query_classification: QueryClassificationConfig::default(),
18501            heartbeat: HeartbeatConfig {
18502                enabled: true,
18503                interval_minutes: 15,
18504                two_phase: true,
18505                message: Some("Check London time".into()),
18506                target: Some("telegram".into()),
18507                to: Some("123456".into()),
18508                ..HeartbeatConfig::default()
18509            },
18510            cron: HashMap::new(),
18511            acp: AcpConfig::default(),
18512            channels: ChannelsConfig {
18513                cli: true,
18514                telegram: HashMap::from([(
18515                    "default".to_string(),
18516                    TelegramConfig {
18517                        enabled: true,
18518                        bot_token: "123:ABC".into(),
18519                        stream_mode: StreamMode::default(),
18520                        draft_update_interval_ms: default_draft_update_interval_ms(),
18521                        interrupt_on_new_message: false,
18522                        mention_only: false,
18523                        ack_reactions: None,
18524                        proxy_url: None,
18525                        approval_timeout_secs: default_telegram_approval_timeout_secs(),
18526                        excluded_tools: vec![],
18527                        reply_min_interval_secs: 0,
18528                        reply_queue_depth_max: 0,
18529                    },
18530                )]),
18531                discord: HashMap::new(),
18532                slack: HashMap::new(),
18533                mattermost: HashMap::new(),
18534                webhook: HashMap::new(),
18535                imessage: HashMap::new(),
18536                matrix: HashMap::new(),
18537                signal: HashMap::new(),
18538                whatsapp: HashMap::new(),
18539                linq: HashMap::new(),
18540                wati: HashMap::new(),
18541                nextcloud_talk: HashMap::new(),
18542                email: HashMap::new(),
18543                gmail_push: HashMap::new(),
18544                irc: HashMap::new(),
18545                twitch: HashMap::new(),
18546                lark: HashMap::new(),
18547                line: HashMap::new(),
18548                dingtalk: HashMap::new(),
18549                wecom: HashMap::new(),
18550                wecom_ws: HashMap::new(),
18551                wechat: HashMap::new(),
18552                qq: HashMap::new(),
18553                twitter: HashMap::new(),
18554                mochat: HashMap::new(),
18555                nostr: HashMap::new(),
18556                clawdtalk: HashMap::new(),
18557                reddit: HashMap::new(),
18558                bluesky: HashMap::new(),
18559                voice_call: HashMap::new(),
18560                voice_duplex: HashMap::new(),
18561                voice_wake: HashMap::new(),
18562                mqtt: HashMap::new(),
18563                amqp: HashMap::new(),
18564                message_timeout_secs: 300,
18565                max_concurrent_per_channel: default_channel_max_concurrent_per_channel(),
18566                ack_reactions: true,
18567                show_tool_calls: true,
18568                session_persistence: true,
18569                session_backend: default_session_backend(),
18570                session_ttl_hours: 0,
18571                debounce_ms: 0,
18572            },
18573            memory: MemoryConfig::default(),
18574            storage: StorageConfig::default(),
18575            tunnel: TunnelConfig::default(),
18576            gateway: GatewayConfig::default(),
18577            wss: WssConfig::default(),
18578            composio: ComposioConfig::default(),
18579            microsoft365: Microsoft365Config::default(),
18580            secrets: SecretsConfig::default(),
18581            browser: BrowserConfig::default(),
18582            browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
18583            http_request: HttpRequestConfig::default(),
18584            multimodal: MultimodalConfig::default(),
18585            media_pipeline: MediaPipelineConfig::default(),
18586            web_fetch: WebFetchConfig::default(),
18587            link_enricher: LinkEnricherConfig::default(),
18588            text_browser: TextBrowserConfig::default(),
18589            web_search: WebSearchConfig::default(),
18590            project_intel: ProjectIntelConfig::default(),
18591            google_workspace: GoogleWorkspaceConfig::default(),
18592            proxy: ProxyConfig::default(),
18593            pacing: PacingConfig::default(),
18594            cost: CostConfig::default(),
18595            peripherals: PeripheralsConfig::default(),
18596            delegate: DelegateToolConfig::default(),
18597            agents: HashMap::new(),
18598            runtime_profiles: HashMap::new(),
18599            skill_bundles: HashMap::new(),
18600            knowledge_bundles: HashMap::new(),
18601            mcp_bundles: HashMap::new(),
18602            peer_groups: HashMap::new(),
18603            hooks: HooksConfig::default(),
18604            hardware: HardwareConfig::default(),
18605            transcription: TranscriptionConfig::default(),
18606            tts: TtsConfig::default(),
18607            mcp: McpConfig::default(),
18608            nodes: NodesConfig::default(),
18609            onboard_state: OnboardStateConfig::default(),
18610            notion: NotionConfig::default(),
18611            jira: JiraConfig::default(),
18612            node_transport: NodeTransportConfig::default(),
18613            knowledge: KnowledgeConfig::default(),
18614            linkedin: LinkedInConfig::default(),
18615            image_gen: ImageGenConfig::default(),
18616            file_upload: FileUploadConfig::default(),
18617            file_upload_bundle: FileUploadBundleConfig::default(),
18618            file_download: FileDownloadConfig::default(),
18619            plugins: PluginsConfig::default(),
18620            locale: None,
18621            verifiable_intent: VerifiableIntentConfig::default(),
18622            claude_code: ClaudeCodeConfig::default(),
18623            claude_code_runner: ClaudeCodeRunnerConfig::default(),
18624            codex_cli: CodexCliConfig::default(),
18625            gemini_cli: GeminiCliConfig::default(),
18626            opencode_cli: OpenCodeCliConfig::default(),
18627            sop: SopConfig::default(),
18628            shell_tool: ShellToolConfig::default(),
18629            escalation: EscalationConfig::default(),
18630            env_overridden_paths: std::collections::HashSet::new(),
18631            pre_override_snapshots: std::collections::HashMap::new(),
18632            onepassword_reference_snapshots: std::collections::HashMap::new(),
18633            dirty_paths: std::collections::HashSet::new(),
18634        };
18635        // ModelProvider fields are now resolved directly — no cache needed.
18636
18637        let toml_str = toml::to_string_pretty(&config).unwrap();
18638        let parsed = parse_test_config(&toml_str);
18639
18640        assert_eq!(parsed.providers.models.len(), config.providers.models.len());
18641        assert_eq!(parsed.observability.backend, "log");
18642        assert_eq!(parsed.observability.log_persistence, "rolling");
18643        let default_profile = parsed.risk_profiles.get("default").unwrap();
18644        assert_eq!(default_profile.level, AutonomyLevel::Full);
18645        assert!(!default_profile.workspace_only);
18646        assert_eq!(parsed.runtime.kind, "docker");
18647        assert!(parsed.heartbeat.enabled);
18648        assert_eq!(parsed.heartbeat.interval_minutes, 15);
18649        assert_eq!(
18650            parsed.heartbeat.message.as_deref(),
18651            Some("Check London time")
18652        );
18653        assert_eq!(parsed.heartbeat.target.as_deref(), Some("telegram"));
18654        assert_eq!(parsed.heartbeat.to.as_deref(), Some("123456"));
18655        assert!(!parsed.channels.telegram.is_empty());
18656        assert_eq!(
18657            parsed.channels.telegram.get("default").unwrap().bot_token,
18658            "123:ABC"
18659        );
18660    }
18661
18662    #[test]
18663    async fn config_minimal_toml_uses_defaults() {
18664        let minimal = r#"
18665workspace_dir = "/tmp/ws"
18666config_path = "/tmp/config.toml"
18667default_temperature = 0.7
18668"#;
18669        let parsed = parse_test_config(minimal);
18670        assert!(
18671            parsed
18672                .providers
18673                .models
18674                .iter_entries()
18675                .next()
18676                .map(|(_, _, e)| e)
18677                .and_then(|e| e.api_key.as_deref())
18678                .is_none()
18679        );
18680        assert_eq!(parsed.observability.backend, "none");
18681        assert_eq!(parsed.observability.log_persistence, "rolling");
18682        // Migration synthesizes risk_profiles.default from the legacy
18683        // [autonomy] block; assert against the named entry rather than a
18684        // global "active" profile (no such concept exists).
18685        assert_eq!(
18686            parsed
18687                .risk_profiles
18688                .get("default")
18689                .expect("migration synthesized risk_profiles.default")
18690                .level,
18691            AutonomyLevel::Supervised
18692        );
18693        assert_eq!(parsed.runtime.kind, "native");
18694        // Heartbeat defaults to disabled.
18695        assert!(!parsed.heartbeat.enabled);
18696        assert!(parsed.channels.cli);
18697        assert!(parsed.memory.hygiene_enabled);
18698        assert_eq!(parsed.memory.archive_after_days, 7);
18699        assert_eq!(parsed.memory.purge_after_days, 30);
18700        assert_eq!(parsed.memory.conversation_retention_days, 30);
18701        // Temperature migrated onto the primary model_provider entry
18702        assert!(
18703            (parsed
18704                .providers
18705                .models
18706                .iter_entries()
18707                .next()
18708                .map(|(_, _, e)| e)
18709                .and_then(|e| e.temperature)
18710                .unwrap_or(0.7)
18711                - 0.7)
18712                .abs()
18713                < f64::EPSILON
18714        );
18715        assert_eq!(
18716            parsed
18717                .providers
18718                .models
18719                .iter_entries()
18720                .next()
18721                .map(|(_, _, e)| e)
18722                .and_then(|e| e.timeout_secs)
18723                .unwrap_or(120),
18724            DEFAULT_DELEGATE_TIMEOUT_SECS
18725        );
18726    }
18727
18728    /// `[autonomy]` migrates onto `[risk_profiles.default]` via the V2→V3
18729    /// migration. The fields must round-trip without being silently dropped.
18730    #[test]
18731    async fn v2_autonomy_section_migrates_onto_risk_profiles_default() {
18732        let raw = r#"
18733schema_version = 2
18734default_temperature = 0.7
18735
18736[autonomy]
18737level = "full"
18738max_actions_per_hour = 99
18739auto_approve = ["file_read", "memory_recall", "http_request"]
18740"#;
18741        let parsed = crate::migration::migrate_to_current(raw).unwrap();
18742        let profile = parsed
18743            .risk_profiles
18744            .get("default")
18745            .expect("default profile");
18746        assert_eq!(profile.level, AutonomyLevel::Full);
18747        assert!(profile.auto_approve.contains(&"http_request".to_string()));
18748        let runtime = parsed
18749            .runtime_profiles
18750            .get("default")
18751            .expect("default runtime profile");
18752        assert_eq!(runtime.max_actions_per_hour, 99);
18753    }
18754
18755    /// Regression test for #4247: when a user provides a custom auto_approve
18756    /// list, the built-in defaults must still be present.
18757    #[test]
18758    async fn auto_approve_merges_user_entries_with_defaults() {
18759        let raw = r#"
18760default_temperature = 0.7
18761
18762[risk_profiles.default]
18763auto_approve = ["my_custom_tool", "another_tool"]
18764"#;
18765        let parsed = parse_test_config(raw);
18766        let profile = parsed.risk_profiles.get("default").unwrap();
18767        assert!(profile.auto_approve.contains(&"my_custom_tool".to_string()));
18768        assert!(profile.auto_approve.contains(&"another_tool".to_string()));
18769        for default_tool in &[
18770            "file_read",
18771            "memory_recall",
18772            "weather",
18773            "calculator",
18774            "web_fetch",
18775        ] {
18776            assert!(
18777                profile.auto_approve.contains(&String::from(*default_tool)),
18778                "default tool '{default_tool}' must be present"
18779            );
18780        }
18781    }
18782
18783    #[test]
18784    async fn default_auto_approve_includes_tool_search() {
18785        let defaults = default_auto_approve();
18786        assert!(defaults.contains(&"tool_search".to_string()));
18787    }
18788
18789    /// Regression test: empty auto_approve still gets defaults merged.
18790    #[test]
18791    async fn auto_approve_empty_list_gets_defaults() {
18792        let raw = r#"
18793default_temperature = 0.7
18794
18795[risk_profiles.default]
18796auto_approve = []
18797"#;
18798        let parsed = parse_test_config(raw);
18799        let profile = parsed.risk_profiles.get("default").unwrap();
18800        for tool in &default_auto_approve() {
18801            assert!(
18802                profile.auto_approve.contains(tool),
18803                "default tool '{tool}' must be present"
18804            );
18805        }
18806    }
18807
18808    /// When no risk_profiles section is provided, defaults are applied to the
18809    /// synthesized "default" profile.
18810    #[test]
18811    async fn auto_approve_defaults_when_no_risk_profile_section() {
18812        let raw = r#"
18813default_temperature = 0.7
18814"#;
18815        let parsed = parse_test_config(raw);
18816        let profile = parsed.risk_profiles.get("default").unwrap();
18817        for tool in &default_auto_approve() {
18818            assert!(
18819                profile.auto_approve.contains(tool),
18820                "default tool '{tool}' must be present"
18821            );
18822        }
18823    }
18824
18825    /// Duplicates are not introduced when ensure_default_auto_approve runs
18826    /// on a list that already contains the defaults.
18827    #[test]
18828    async fn auto_approve_no_duplicates() {
18829        let raw = r#"
18830default_temperature = 0.7
18831
18832[risk_profiles.default]
18833auto_approve = ["weather", "file_read"]
18834"#;
18835        let parsed = parse_test_config(raw);
18836        let profile = parsed.risk_profiles.get("default").unwrap();
18837        assert_eq!(
18838            profile
18839                .auto_approve
18840                .iter()
18841                .filter(|t| *t == "weather")
18842                .count(),
18843            1
18844        );
18845        assert_eq!(
18846            profile
18847                .auto_approve
18848                .iter()
18849                .filter(|t| *t == "file_read")
18850                .count(),
18851            1
18852        );
18853    }
18854
18855    #[test]
18856    async fn provider_timeout_secs_parses_from_toml() {
18857        // V1 top-level `provider_timeout_secs` is folded into the
18858        // synthesized model_provider entry's `timeout_secs`.
18859        let raw = r#"
18860default_temperature = 0.7
18861provider_timeout_secs = 300
18862"#;
18863        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
18864        assert_eq!(
18865            parsed
18866                .providers
18867                .models
18868                .find("openrouter", "default")
18869                .and_then(|e| e.timeout_secs)
18870                .unwrap_or(120),
18871            300
18872        );
18873    }
18874
18875    #[test]
18876    async fn extra_headers_parses_from_toml() {
18877        // V1 top-level `[extra_headers]` is folded into the synthesized
18878        // default model_provider entry's `extra_headers` map.
18879        let raw = r#"
18880default_temperature = 0.7
18881
18882[extra_headers]
18883User-Agent = "MyApp/1.0"
18884X-Title = "zeroclaw"
18885"#;
18886        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
18887        let headers = &parsed
18888            .providers
18889            .models
18890            .find("openrouter", "default")
18891            .expect("synthesized openrouter.default model_provider")
18892            .extra_headers;
18893        assert_eq!(headers.len(), 2);
18894        assert_eq!(headers.get("User-Agent").unwrap(), "MyApp/1.0");
18895        assert_eq!(headers.get("X-Title").unwrap(), "zeroclaw");
18896    }
18897
18898    #[test]
18899    async fn extra_headers_defaults_to_empty() {
18900        let raw = r#"
18901default_temperature = 0.7
18902"#;
18903        let parsed = parse_test_config(raw);
18904        assert!(
18905            parsed
18906                .providers
18907                .models
18908                .iter_entries()
18909                .next()
18910                .map(|(_, _, e)| e.extra_headers.is_empty())
18911                .unwrap_or(true)
18912        );
18913    }
18914
18915    #[test]
18916    async fn storage_postgres_dburl_alias_deserializes() {
18917        let raw = r#"
18918default_temperature = 0.7
18919
18920[storage.postgres.default]
18921dbURL = "postgres://user:pw@host/db"
18922schema = "public"
18923table = "memories"
18924connect_timeout_secs = 12
18925"#;
18926
18927        let parsed = parse_test_config(raw);
18928        let pg = parsed
18929            .storage
18930            .postgres
18931            .get("default")
18932            .expect("postgres.default present");
18933        assert_eq!(pg.db_url.as_deref(), Some("postgres://user:pw@host/db"));
18934        assert_eq!(pg.schema, "public");
18935        assert_eq!(pg.table, "memories");
18936        assert_eq!(pg.connect_timeout_secs, Some(12));
18937    }
18938
18939    #[test]
18940    async fn runtime_reasoning_enabled_deserializes() {
18941        let raw = r#"
18942default_temperature = 0.7
18943
18944[runtime]
18945reasoning_enabled = false
18946"#;
18947
18948        let parsed = parse_test_config(raw);
18949        assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
18950    }
18951
18952    #[test]
18953    async fn runtime_reasoning_effort_deserializes() {
18954        let raw = r#"
18955default_temperature = 0.7
18956
18957[runtime]
18958reasoning_effort = "HIGH"
18959"#;
18960
18961        let parsed: Config = toml::from_str(raw).unwrap();
18962        assert_eq!(parsed.runtime.reasoning_effort.as_deref(), Some("high"));
18963    }
18964
18965    #[test]
18966    async fn runtime_reasoning_effort_rejects_invalid_values() {
18967        let raw = r#"
18968default_temperature = 0.7
18969
18970[runtime]
18971reasoning_effort = "turbo"
18972"#;
18973
18974        let error = toml::from_str::<Config>(raw).expect_err("invalid value should fail");
18975        assert!(error.to_string().contains("reasoning_effort"));
18976    }
18977
18978    #[test]
18979    async fn agent_config_defaults() {
18980        let cfg = AliasedAgentConfig::default();
18981        assert!(cfg.resolved.compact_context);
18982        assert_eq!(cfg.resolved.max_tool_iterations, 10);
18983        assert_eq!(cfg.resolved.max_history_messages, 50);
18984        assert!(!cfg.resolved.parallel_tools);
18985        assert_eq!(cfg.resolved.tool_dispatcher, "auto");
18986        assert!(!cfg.resolved.strict_tool_parsing);
18987    }
18988
18989    #[test]
18990    async fn agent_level_tunable_keys_are_inert() {
18991        let raw = r#"
18992default_temperature = 0.7
18993[agents.default]
18994compact_context = true
18995max_tool_iterations = 20
18996max_history_messages = 80
18997parallel_tools = true
18998tool_dispatcher = "xml"
18999strict_tool_parsing = true
19000"#;
19001        let parsed = parse_test_config(raw);
19002        let agent = parsed
19003            .agents
19004            .get("default")
19005            .expect("[agents.default] parses into agents map");
19006        assert_eq!(agent.resolved.max_tool_iterations, 10);
19007        assert_eq!(agent.resolved.tool_dispatcher, "auto");
19008        assert!(!agent.resolved.strict_tool_parsing);
19009    }
19010
19011    #[test]
19012    async fn runtime_profile_max_tool_iterations_is_honored() {
19013        // #6877: `[runtime_profiles.*].max_tool_iterations` must actually take
19014        // effect. It previously had no effect (the value had to be set on
19015        // `[agents.*]`); now agent-inline is inert and the profile is the
19016        // authoritative surface, so this guards the resolved value.
19017        let raw = r#"
19018[runtime_profiles.fast]
19019max_tool_iterations = 25
19020
19021[agents.default]
19022runtime_profile = "fast"
19023"#;
19024        let parsed = parse_test_config(raw);
19025        assert_eq!(parsed.effective_max_tool_iterations("default"), 25);
19026    }
19027
19028    #[test]
19029    async fn runtime_profile_unset_max_tool_iterations_uses_default() {
19030        // A profile that does not set max_tool_iterations (sentinel 0) falls
19031        // back to the global default rather than 0.
19032        let raw = r#"
19033[runtime_profiles.fast]
19034max_history_messages = 80
19035
19036[agents.default]
19037runtime_profile = "fast"
19038"#;
19039        let parsed = parse_test_config(raw);
19040        assert_eq!(parsed.effective_max_tool_iterations("default"), 10);
19041    }
19042
19043    #[test]
19044    async fn pacing_config_defaults_are_all_none_or_empty() {
19045        let cfg = PacingConfig::default();
19046        assert!(cfg.step_timeout_secs.is_none());
19047        assert!(cfg.loop_detection_min_elapsed_secs.is_none());
19048        assert!(cfg.loop_ignore_tools.is_empty());
19049        assert!(cfg.message_timeout_scale_max.is_none());
19050    }
19051
19052    #[test]
19053    async fn pacing_config_deserializes_from_toml() {
19054        let raw = r#"
19055default_temperature = 0.7
19056[pacing]
19057step_timeout_secs = 120
19058loop_detection_min_elapsed_secs = 60
19059loop_ignore_tools = ["browser_screenshot", "browser_navigate"]
19060message_timeout_scale_max = 8
19061"#;
19062        let parsed: Config = toml::from_str(raw).unwrap();
19063        assert_eq!(parsed.pacing.step_timeout_secs, Some(120));
19064        assert_eq!(parsed.pacing.loop_detection_min_elapsed_secs, Some(60));
19065        assert_eq!(
19066            parsed.pacing.loop_ignore_tools,
19067            vec!["browser_screenshot", "browser_navigate"]
19068        );
19069        assert_eq!(parsed.pacing.message_timeout_scale_max, Some(8));
19070    }
19071
19072    #[test]
19073    async fn pacing_config_absent_preserves_defaults() {
19074        let raw = r#"
19075default_temperature = 0.7
19076"#;
19077        let parsed: Config = toml::from_str(raw).unwrap();
19078        assert!(parsed.pacing.step_timeout_secs.is_none());
19079        assert!(parsed.pacing.loop_detection_min_elapsed_secs.is_none());
19080        assert!(parsed.pacing.loop_ignore_tools.is_empty());
19081        assert!(parsed.pacing.message_timeout_scale_max.is_none());
19082    }
19083
19084    #[tokio::test]
19085    async fn sync_directory_handles_existing_directory() {
19086        let dir = std::env::temp_dir().join(format!(
19087            "zeroclaw_test_sync_directory_{}",
19088            uuid::Uuid::new_v4()
19089        ));
19090        fs::create_dir_all(&dir).await.unwrap();
19091
19092        sync_directory(&dir).await.unwrap();
19093
19094        let _ = fs::remove_dir_all(&dir).await;
19095    }
19096
19097    #[tokio::test]
19098    async fn config_save_prunes_unchanged_default_blocks() {
19099        // Fresh-init config without any operator edits should write a
19100        // tiny config.toml — only `schema_version` and any operator-
19101        // touched fields. The hundreds of all-default blocks
19102        // (LinkedIn, memory, observability, etc.) must not appear.
19103        let dir =
19104            std::env::temp_dir().join(format!("zeroclaw_save_prune_test_{}", uuid::Uuid::new_v4()));
19105        fs::create_dir_all(&dir).await.unwrap();
19106        let config = Config {
19107            config_path: dir.join("config.toml"),
19108            data_dir: dir.join("data"),
19109            ..Default::default()
19110        };
19111        config.save().await.unwrap();
19112        let raw = fs::read_to_string(&config.config_path).await.unwrap();
19113
19114        // schema_version must always survive (migration detector
19115        // anchor); without it a re-load would mis-detect as V1.
19116        assert!(
19117            raw.contains("schema_version"),
19118            "schema_version must survive pruning"
19119        );
19120
19121        // Defaulted nested struct blocks must NOT appear in a fresh
19122        // save. Pick representative samples from across the schema:
19123        for block in [
19124            "[memory]",
19125            "[linkedin",
19126            "[observability]",
19127            "[gateway]",
19128            "[cost]",
19129        ] {
19130            assert!(
19131                !raw.contains(block),
19132                "pruned config.toml must not emit defaulted block {block}; got:\n{raw}",
19133            );
19134        }
19135
19136        // Round-trip: load the pruned config and verify it still
19137        // deserializes to a `Config` (schema defaults fill the gaps).
19138        let _reloaded: Config = toml::from_str(&raw).expect("pruned config round-trips");
19139
19140        let _ = fs::remove_dir_all(&dir).await;
19141    }
19142
19143    #[tokio::test]
19144    async fn config_save_keeps_operator_set_non_default_fields() {
19145        let dir =
19146            std::env::temp_dir().join(format!("zeroclaw_save_keep_test_{}", uuid::Uuid::new_v4()));
19147        fs::create_dir_all(&dir).await.unwrap();
19148        let mut config = Config {
19149            config_path: dir.join("config.toml"),
19150            data_dir: dir.join("data"),
19151            ..Default::default()
19152        };
19153        // Operator picked a non-default locale + provider entry.
19154        config.locale = Some("ja-JP".into());
19155        config.providers.models.anthropic.insert(
19156            "claude_default".into(),
19157            AnthropicModelProviderConfig {
19158                base: ModelProviderConfig {
19159                    model: Some("claude-sonnet-4".into()),
19160                    ..Default::default()
19161                },
19162            },
19163        );
19164        config.save().await.unwrap();
19165        let raw = fs::read_to_string(&config.config_path).await.unwrap();
19166
19167        assert!(
19168            raw.contains("ja-JP"),
19169            "operator-set locale must survive pruning; got:\n{raw}",
19170        );
19171        assert!(
19172            raw.contains("claude_default"),
19173            "operator-added provider alias must survive pruning; got:\n{raw}",
19174        );
19175        assert!(
19176            raw.contains("claude-sonnet-4"),
19177            "operator-set model must survive pruning; got:\n{raw}",
19178        );
19179
19180        let _ = fs::remove_dir_all(&dir).await;
19181    }
19182
19183    #[tokio::test]
19184    async fn config_save_and_load_tmpdir() {
19185        let dir = std::env::temp_dir().join("zeroclaw_test_config");
19186        let _ = fs::remove_dir_all(&dir).await;
19187        fs::create_dir_all(&dir).await.unwrap();
19188
19189        let config_path = dir.join("config.toml");
19190        let mut providers = crate::providers::Providers::default();
19191        providers.models.openrouter.insert(
19192            "default".to_string(),
19193            OpenRouterModelProviderConfig {
19194                base: ModelProviderConfig {
19195                    api_key: Some("sk-roundtrip".into()),
19196                    model: Some("test-model".into()),
19197                    temperature: Some(0.9),
19198                    timeout_secs: Some(120),
19199                    ..Default::default()
19200                },
19201            },
19202        );
19203        let config = Config {
19204            degraded_security: Vec::new(),
19205            schema_version: crate::migration::CURRENT_SCHEMA_VERSION,
19206            providers,
19207            model_routes: Vec::new(),
19208            embedding_routes: Vec::new(),
19209            data_dir: dir.join("workspace"),
19210            config_path: config_path.clone(),
19211            observability: ObservabilityConfig::default(),
19212            trust: crate::scattered_types::TrustConfig::default(),
19213            backup: BackupConfig::default(),
19214            data_retention: DataRetentionConfig::default(),
19215            cloud_ops: CloudOpsConfig::default(),
19216            conversational_ai: ConversationalAiConfig::default(),
19217            security: SecurityConfig::default(),
19218            security_ops: SecurityOpsConfig::default(),
19219            runtime: RuntimeConfig::default(),
19220            reliability: ReliabilityConfig::default(),
19221            scheduler: SchedulerConfig::default(),
19222            skills: SkillsConfig::default(),
19223            pipeline: PipelineConfig::default(),
19224            query_classification: QueryClassificationConfig::default(),
19225            heartbeat: HeartbeatConfig::default(),
19226            cron: HashMap::new(),
19227            acp: AcpConfig::default(),
19228            channels: ChannelsConfig::default(),
19229            memory: MemoryConfig::default(),
19230            storage: StorageConfig::default(),
19231            tunnel: TunnelConfig::default(),
19232            gateway: GatewayConfig::default(),
19233            wss: WssConfig::default(),
19234            composio: ComposioConfig::default(),
19235            microsoft365: Microsoft365Config::default(),
19236            secrets: SecretsConfig::default(),
19237            browser: BrowserConfig::default(),
19238            browser_delegate: crate::scattered_types::BrowserDelegateConfig::default(),
19239            http_request: HttpRequestConfig::default(),
19240            multimodal: MultimodalConfig::default(),
19241            media_pipeline: MediaPipelineConfig::default(),
19242            web_fetch: WebFetchConfig::default(),
19243            link_enricher: LinkEnricherConfig::default(),
19244            text_browser: TextBrowserConfig::default(),
19245            web_search: WebSearchConfig::default(),
19246            project_intel: ProjectIntelConfig::default(),
19247            google_workspace: GoogleWorkspaceConfig::default(),
19248            proxy: ProxyConfig::default(),
19249            pacing: PacingConfig::default(),
19250            cost: CostConfig::default(),
19251            peripherals: PeripheralsConfig::default(),
19252            delegate: DelegateToolConfig::default(),
19253            agents: HashMap::new(),
19254            risk_profiles: HashMap::new(),
19255            runtime_profiles: HashMap::new(),
19256            skill_bundles: HashMap::new(),
19257            knowledge_bundles: HashMap::new(),
19258            mcp_bundles: HashMap::new(),
19259            peer_groups: HashMap::new(),
19260            hooks: HooksConfig::default(),
19261            hardware: HardwareConfig::default(),
19262            transcription: TranscriptionConfig::default(),
19263            tts: TtsConfig::default(),
19264            mcp: McpConfig::default(),
19265            nodes: NodesConfig::default(),
19266            onboard_state: OnboardStateConfig::default(),
19267            notion: NotionConfig::default(),
19268            jira: JiraConfig::default(),
19269            node_transport: NodeTransportConfig::default(),
19270            knowledge: KnowledgeConfig::default(),
19271            linkedin: LinkedInConfig::default(),
19272            image_gen: ImageGenConfig::default(),
19273            file_upload: FileUploadConfig::default(),
19274            file_upload_bundle: FileUploadBundleConfig::default(),
19275            file_download: FileDownloadConfig::default(),
19276            plugins: PluginsConfig::default(),
19277            locale: None,
19278            verifiable_intent: VerifiableIntentConfig::default(),
19279            claude_code: ClaudeCodeConfig::default(),
19280            claude_code_runner: ClaudeCodeRunnerConfig::default(),
19281            codex_cli: CodexCliConfig::default(),
19282            gemini_cli: GeminiCliConfig::default(),
19283            opencode_cli: OpenCodeCliConfig::default(),
19284            sop: SopConfig::default(),
19285            shell_tool: ShellToolConfig::default(),
19286            escalation: EscalationConfig::default(),
19287            env_overridden_paths: std::collections::HashSet::new(),
19288            pre_override_snapshots: std::collections::HashMap::new(),
19289            onepassword_reference_snapshots: std::collections::HashMap::new(),
19290            dirty_paths: std::collections::HashSet::new(),
19291        };
19292
19293        // ModelProvider fields are now resolved directly — no cache needed.
19294        config.save().await.unwrap();
19295        assert!(config_path.exists());
19296
19297        let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
19298        let loaded = crate::migration::migrate_to_current(&contents).unwrap();
19299        let entry = &loaded
19300            .providers
19301            .models
19302            .find("openrouter", "default")
19303            .expect("entry exists");
19304        assert!(
19305            entry
19306                .api_key
19307                .as_deref()
19308                .is_some_and(crate::secrets::SecretStore::is_encrypted)
19309        );
19310        let store = crate::secrets::SecretStore::new(&dir, true);
19311        let decrypted = store.decrypt(entry.api_key.as_deref().unwrap()).unwrap();
19312        assert_eq!(decrypted, "sk-roundtrip");
19313        assert_eq!(entry.model.as_deref(), Some("test-model"));
19314        assert!(
19315            entry
19316                .temperature
19317                .is_some_and(|t| (t - 0.9).abs() < f64::EPSILON)
19318        );
19319
19320        let _ = fs::remove_dir_all(&dir).await;
19321    }
19322
19323    #[tokio::test]
19324    async fn config_save_encrypts_nested_credentials() {
19325        let dir = std::env::temp_dir().join(format!(
19326            "zeroclaw_test_nested_credentials_{}",
19327            uuid::Uuid::new_v4()
19328        ));
19329        fs::create_dir_all(&dir).await.unwrap();
19330
19331        let mut config = Config {
19332            data_dir: dir.join("workspace"),
19333            config_path: dir.join("config.toml"),
19334            ..Default::default()
19335        };
19336        config.providers.models.anthropic.insert(
19337            "default".to_string(),
19338            AnthropicModelProviderConfig {
19339                base: ModelProviderConfig {
19340                    api_key: Some("root-credential".into()),
19341                    extra_headers: HashMap::from([(
19342                        "Authorization".to_string(),
19343                        "Bearer provider-header-credential".to_string(),
19344                    )]),
19345                    ..Default::default()
19346                },
19347            },
19348        );
19349        // ModelProvider fields are now resolved directly — no cache needed.
19350        config.composio.api_key = Some("composio-credential".into());
19351        config.browser.computer_use.api_key = Some("browser-credential".into());
19352        config.web_search.brave_api_key = Some("brave-credential".into());
19353        config.web_search.tavily_api_key = Some("tavily-credential".into());
19354        config.storage.postgres.insert(
19355            "default".to_string(),
19356            PostgresStorageConfig {
19357                db_url: Some("postgres://user:pw@host/db".into()),
19358                ..PostgresStorageConfig::default()
19359            },
19360        );
19361        config.storage.qdrant.insert(
19362            "default".to_string(),
19363            QdrantStorageConfig {
19364                api_key: Some("qdrant-credential".into()),
19365                ..QdrantStorageConfig::default()
19366            },
19367        );
19368        config.reliability.api_keys = vec![
19369            "rotation-credential-a".into(),
19370            "rotation-credential-b".into(),
19371        ];
19372        config.node_transport.shared_secret = "node-shared-credential".into();
19373        config.nodes.auth_token = Some("nodes-auth-credential".into());
19374        config.observability.backend = "otel".into();
19375        config.observability.otel_headers = Some(HashMap::from([(
19376            "Authorization".to_string(),
19377            "Bearer otel-credential".to_string(),
19378        )]));
19379        config.file_upload.headers = HashMap::from([(
19380            "Authorization".to_string(),
19381            "Bearer upload-credential".to_string(),
19382        )]);
19383        config.http_request.secrets = HashMap::from([(
19384            "api_token".to_string(),
19385            "Bearer http-request-credential".to_string(),
19386        )]);
19387        config.channels.lark.insert(
19388            "feishu".to_string(),
19389            LarkConfig {
19390                enabled: true,
19391                app_id: "cli_feishu_123".into(),
19392                app_secret: "feishu-secret".into(),
19393                encrypt_key: Some("feishu-encrypt".into()),
19394                verification_token: Some("feishu-verify".into()),
19395                mention_only: false,
19396                use_feishu: true,
19397                receive_mode: LarkReceiveMode::Websocket,
19398                port: None,
19399                proxy_url: None,
19400                excluded_tools: vec![],
19401                approval_timeout_secs: 300,
19402                per_user_session: false,
19403                stream_mode: StreamMode::default(),
19404                draft_update_interval_ms: default_draft_update_interval_ms(),
19405            },
19406        );
19407
19408        config.providers.models.openrouter.insert(
19409            "worker".into(),
19410            crate::schema::OpenRouterModelProviderConfig {
19411                base: ModelProviderConfig {
19412                    api_key: Some("agent-credential".into()),
19413                    model: Some("model-test".into()),
19414                    ..Default::default()
19415                },
19416            },
19417        );
19418        config.agents.insert(
19419            "worker".into(),
19420            AliasedAgentConfig {
19421                model_provider: "openrouter.worker".into(),
19422                ..Default::default()
19423            },
19424        );
19425
19426        // Webhook channel: auth_header carries a Bearer token; must be
19427        // encrypted alongside the existing webhook `secret` field.
19428        config.channels.webhook.insert(
19429            "primary".into(),
19430            WebhookConfig {
19431                enabled: true,
19432                port: 8080,
19433                auth_header: Some("Bearer webhook-cred".into()),
19434                secret: Some("webhook-shared-secret".into()),
19435                ..Default::default()
19436            },
19437        );
19438
19439        // MCP server: HTTP headers map carries an Authorization Bearer
19440        // token; the new `#[secret]` on `HashMap<String, String>` must
19441        // encrypt every value (and only every value — keys stay plain).
19442        config.mcp.servers.push(McpServerConfig {
19443            name: "primary".into(),
19444            transport: McpTransport::Sse,
19445            url: Some("https://mcp.example.invalid/sse".into()),
19446            env: HashMap::from([("MCP_API_KEY".to_string(), "mcp-env-credential".to_string())]),
19447            headers: HashMap::from([
19448                ("Authorization".to_string(), "Bearer mcp-cred".to_string()),
19449                ("X-Tenant".to_string(), "tenant-42".to_string()),
19450            ]),
19451            ..Default::default()
19452        });
19453
19454        config.save().await.unwrap();
19455
19456        let contents = tokio::fs::read_to_string(config.config_path.clone())
19457            .await
19458            .unwrap();
19459        for plaintext in [
19460            "root-credential",
19461            "Bearer provider-header-credential",
19462            "composio-credential",
19463            "browser-credential",
19464            "brave-credential",
19465            "tavily-credential",
19466            "postgres://user:pw@host/db",
19467            "qdrant-credential",
19468            "rotation-credential-a",
19469            "rotation-credential-b",
19470            "node-shared-credential",
19471            "nodes-auth-credential",
19472            "Bearer otel-credential",
19473            "Bearer upload-credential",
19474            "Bearer http-request-credential",
19475            "mcp-env-credential",
19476            "Bearer mcp-cred",
19477            "tenant-42",
19478        ] {
19479            assert!(
19480                !contents.contains(plaintext),
19481                "saved TOML must not contain plaintext credential `{plaintext}`"
19482            );
19483        }
19484        let stored: Config = crate::migration::migrate_to_current(&contents).unwrap();
19485        let store = crate::secrets::SecretStore::new(&dir, true);
19486
19487        let root_encrypted = stored
19488            .providers
19489            .models
19490            .find("anthropic", "default")
19491            .and_then(|e| e.api_key.as_deref())
19492            .unwrap();
19493        assert!(crate::secrets::SecretStore::is_encrypted(root_encrypted));
19494        assert_eq!(store.decrypt(root_encrypted).unwrap(), "root-credential");
19495
19496        let provider_header = stored
19497            .providers
19498            .models
19499            .find("anthropic", "default")
19500            .and_then(|e| e.extra_headers.get("Authorization"))
19501            .unwrap();
19502        assert!(crate::secrets::SecretStore::is_encrypted(provider_header));
19503        assert_eq!(
19504            store.decrypt(provider_header).unwrap(),
19505            "Bearer provider-header-credential"
19506        );
19507
19508        let composio_encrypted = stored.composio.api_key.as_deref().unwrap();
19509        assert!(crate::secrets::SecretStore::is_encrypted(
19510            composio_encrypted
19511        ));
19512        assert_eq!(
19513            store.decrypt(composio_encrypted).unwrap(),
19514            "composio-credential"
19515        );
19516
19517        let browser_encrypted = stored.browser.computer_use.api_key.as_deref().unwrap();
19518        assert!(crate::secrets::SecretStore::is_encrypted(browser_encrypted));
19519        assert_eq!(
19520            store.decrypt(browser_encrypted).unwrap(),
19521            "browser-credential"
19522        );
19523
19524        let web_search_encrypted = stored.web_search.brave_api_key.as_deref().unwrap();
19525        assert!(crate::secrets::SecretStore::is_encrypted(
19526            web_search_encrypted
19527        ));
19528        assert_eq!(
19529            store.decrypt(web_search_encrypted).unwrap(),
19530            "brave-credential"
19531        );
19532
19533        let tavily_encrypted = stored.web_search.tavily_api_key.as_deref().unwrap();
19534        assert!(crate::secrets::SecretStore::is_encrypted(tavily_encrypted));
19535        assert_eq!(
19536            store.decrypt(tavily_encrypted).unwrap(),
19537            "tavily-credential"
19538        );
19539
19540        let worker_provider = stored
19541            .providers
19542            .models
19543            .find("openrouter", "worker")
19544            .unwrap();
19545        let worker_encrypted = worker_provider.api_key.as_deref().unwrap();
19546        assert!(crate::secrets::SecretStore::is_encrypted(worker_encrypted));
19547        assert_eq!(store.decrypt(worker_encrypted).unwrap(), "agent-credential");
19548
19549        let storage_db_url = stored
19550            .storage
19551            .postgres
19552            .get("default")
19553            .and_then(|p| p.db_url.as_deref())
19554            .unwrap();
19555        assert!(crate::secrets::SecretStore::is_encrypted(storage_db_url));
19556        assert_eq!(
19557            store.decrypt(storage_db_url).unwrap(),
19558            "postgres://user:pw@host/db"
19559        );
19560
19561        let qdrant_key = stored
19562            .storage
19563            .qdrant
19564            .get("default")
19565            .and_then(|q| q.api_key.as_deref())
19566            .unwrap();
19567        assert!(crate::secrets::SecretStore::is_encrypted(qdrant_key));
19568        assert_eq!(store.decrypt(qdrant_key).unwrap(), "qdrant-credential");
19569
19570        for key in &stored.reliability.api_keys {
19571            assert!(crate::secrets::SecretStore::is_encrypted(key));
19572        }
19573        assert_eq!(
19574            store.decrypt(&stored.reliability.api_keys[0]).unwrap(),
19575            "rotation-credential-a"
19576        );
19577        assert_eq!(
19578            store.decrypt(&stored.reliability.api_keys[1]).unwrap(),
19579            "rotation-credential-b"
19580        );
19581
19582        assert!(crate::secrets::SecretStore::is_encrypted(
19583            &stored.node_transport.shared_secret
19584        ));
19585        assert_eq!(
19586            store.decrypt(&stored.node_transport.shared_secret).unwrap(),
19587            "node-shared-credential"
19588        );
19589
19590        let nodes_auth = stored.nodes.auth_token.as_deref().unwrap();
19591        assert!(crate::secrets::SecretStore::is_encrypted(nodes_auth));
19592        assert_eq!(store.decrypt(nodes_auth).unwrap(), "nodes-auth-credential");
19593
19594        let otel_auth = stored
19595            .observability
19596            .otel_headers
19597            .as_ref()
19598            .and_then(|h| h.get("Authorization"))
19599            .unwrap();
19600        assert!(crate::secrets::SecretStore::is_encrypted(otel_auth));
19601        assert_eq!(store.decrypt(otel_auth).unwrap(), "Bearer otel-credential");
19602
19603        let upload_auth = stored.file_upload.headers.get("Authorization").unwrap();
19604        assert!(crate::secrets::SecretStore::is_encrypted(upload_auth));
19605        assert_eq!(
19606            store.decrypt(upload_auth).unwrap(),
19607            "Bearer upload-credential"
19608        );
19609
19610        let http_request_auth = stored.http_request.secrets.get("api_token").unwrap();
19611        assert!(crate::secrets::SecretStore::is_encrypted(http_request_auth));
19612        assert_eq!(
19613            store.decrypt(http_request_auth).unwrap(),
19614            "Bearer http-request-credential"
19615        );
19616
19617        let feishu = stored.channels.lark.get("feishu").unwrap();
19618        assert!(crate::secrets::SecretStore::is_encrypted(
19619            &feishu.app_secret
19620        ));
19621        assert_eq!(store.decrypt(&feishu.app_secret).unwrap(), "feishu-secret");
19622        assert!(
19623            feishu
19624                .encrypt_key
19625                .as_deref()
19626                .is_some_and(crate::secrets::SecretStore::is_encrypted)
19627        );
19628        assert_eq!(
19629            store
19630                .decrypt(feishu.encrypt_key.as_deref().unwrap())
19631                .unwrap(),
19632            "feishu-encrypt"
19633        );
19634        assert!(
19635            feishu
19636                .verification_token
19637                .as_deref()
19638                .is_some_and(crate::secrets::SecretStore::is_encrypted)
19639        );
19640        assert_eq!(
19641            store
19642                .decrypt(feishu.verification_token.as_deref().unwrap())
19643                .unwrap(),
19644            "feishu-verify"
19645        );
19646
19647        // Webhook auth_header — newly tagged `#[secret]`.
19648        let webhook = stored.channels.webhook.get("primary").unwrap();
19649        let webhook_auth = webhook.auth_header.as_deref().unwrap();
19650        assert!(
19651            crate::secrets::SecretStore::is_encrypted(webhook_auth),
19652            "webhook auth_header must be encrypted on save"
19653        );
19654        assert_eq!(store.decrypt(webhook_auth).unwrap(), "Bearer webhook-cred");
19655        // The pre-existing webhook `secret` field stays encrypted too —
19656        // sanity check that the refactor didn't regress it.
19657        let webhook_secret = webhook.secret.as_deref().unwrap();
19658        assert!(crate::secrets::SecretStore::is_encrypted(webhook_secret));
19659        assert_eq!(
19660            store.decrypt(webhook_secret).unwrap(),
19661            "webhook-shared-secret"
19662        );
19663
19664        // MCP server headers — every value must be encrypted; the keys
19665        // stay plaintext (TOML table headers are not secret).
19666        let mcp_server = stored
19667            .mcp
19668            .servers
19669            .iter()
19670            .find(|s| s.name == "primary")
19671            .expect("mcp server `primary` round-trips through save");
19672        for (key, value) in &mcp_server.headers {
19673            assert!(
19674                crate::secrets::SecretStore::is_encrypted(value),
19675                "mcp.servers.primary.headers.{key} must be encrypted on save"
19676            );
19677        }
19678        let mcp_env = mcp_server.env.get("MCP_API_KEY").unwrap();
19679        assert!(
19680            crate::secrets::SecretStore::is_encrypted(mcp_env),
19681            "mcp.servers.primary.env.MCP_API_KEY must be encrypted on save"
19682        );
19683        let auth = mcp_server.headers.get("Authorization").unwrap();
19684        let tenant = mcp_server.headers.get("X-Tenant").unwrap();
19685        assert_eq!(store.decrypt(mcp_env).unwrap(), "mcp-env-credential");
19686        assert_eq!(store.decrypt(auth).unwrap(), "Bearer mcp-cred");
19687        assert_eq!(store.decrypt(tenant).unwrap(), "tenant-42");
19688
19689        let _ = fs::remove_dir_all(&dir).await;
19690    }
19691
19692    #[tokio::test]
19693    async fn config_save_atomic_cleanup() {
19694        let dir =
19695            std::env::temp_dir().join(format!("zeroclaw_test_config_{}", uuid::Uuid::new_v4()));
19696        fs::create_dir_all(&dir).await.unwrap();
19697
19698        let config_path = dir.join("config.toml");
19699        let mut config = Config {
19700            data_dir: dir.join("workspace"),
19701            config_path: config_path.clone(),
19702            ..Default::default()
19703        };
19704        config.providers.models.openrouter.insert(
19705            "default".to_string(),
19706            OpenRouterModelProviderConfig {
19707                base: ModelProviderConfig {
19708                    model: Some("model-a".into()),
19709                    ..Default::default()
19710                },
19711            },
19712        );
19713        config.save().await.unwrap();
19714        assert!(config_path.exists());
19715
19716        config
19717            .providers
19718            .models
19719            .ensure("openrouter", "default")
19720            .unwrap()
19721            .model = Some("model-b".into());
19722        config.save().await.unwrap();
19723
19724        let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
19725        assert!(contents.contains("model-b"));
19726
19727        let mut names: Vec<String> = Vec::new();
19728        let mut read_dir = fs::read_dir(&dir).await.unwrap();
19729        while let Some(entry) = read_dir.next_entry().await.unwrap() {
19730            names.push(entry.file_name().to_string_lossy().to_string());
19731        }
19732        assert!(!names.iter().any(|name| name.contains(".tmp-")));
19733        assert!(!names.iter().any(|name| name.ends_with(".bak")));
19734
19735        let _ = fs::remove_dir_all(&dir).await;
19736    }
19737
19738    // ── Telegram / Discord config ────────────────────────────
19739
19740    #[test]
19741    async fn telegram_config_serde() {
19742        let tc = TelegramConfig {
19743            enabled: true,
19744            bot_token: "123:XYZ".into(),
19745            stream_mode: StreamMode::Partial,
19746            draft_update_interval_ms: 500,
19747            interrupt_on_new_message: true,
19748            mention_only: false,
19749            ack_reactions: None,
19750            proxy_url: None,
19751            approval_timeout_secs: 120,
19752            excluded_tools: vec![],
19753            reply_min_interval_secs: 0,
19754            reply_queue_depth_max: 0,
19755        };
19756        let json = serde_json::to_string(&tc).unwrap();
19757        let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
19758        assert_eq!(parsed.bot_token, "123:XYZ");
19759        assert_eq!(parsed.stream_mode, StreamMode::Partial);
19760        assert_eq!(parsed.draft_update_interval_ms, 500);
19761        assert!(parsed.interrupt_on_new_message);
19762    }
19763
19764    #[test]
19765    async fn telegram_config_defaults_stream_off() {
19766        let json = r#"{"bot_token":"tok","allowed_users":[]}"#;
19767        let parsed: TelegramConfig = serde_json::from_str(json).unwrap();
19768        assert_eq!(parsed.stream_mode, StreamMode::Off);
19769        assert_eq!(parsed.draft_update_interval_ms, 1000);
19770        assert!(!parsed.interrupt_on_new_message);
19771    }
19772
19773    #[test]
19774    async fn discord_config_serde() {
19775        let dc = DiscordConfig {
19776            enabled: true,
19777            bot_token: "discord-token".into(),
19778            guild_ids: vec!["12345".into()],
19779            channel_ids: vec![],
19780            archive: false,
19781            listen_to_bots: false,
19782            interrupt_on_new_message: false,
19783            mention_only: false,
19784            proxy_url: None,
19785            stream_mode: StreamMode::default(),
19786            draft_update_interval_ms: 1000,
19787            multi_message_delay_ms: 800,
19788            stall_timeout_secs: 0,
19789            approval_timeout_secs: 300,
19790            excluded_tools: vec![],
19791            reply_min_interval_secs: 0,
19792            reply_queue_depth_max: 0,
19793        };
19794        let json = serde_json::to_string(&dc).unwrap();
19795        let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
19796        assert_eq!(parsed.bot_token, "discord-token");
19797        assert_eq!(parsed.guild_ids, vec!["12345".to_string()]);
19798    }
19799
19800    #[test]
19801    async fn discord_config_empty_guild_ids() {
19802        let dc = DiscordConfig {
19803            enabled: true,
19804            bot_token: "tok".into(),
19805            guild_ids: Vec::new(),
19806            channel_ids: vec![],
19807            archive: false,
19808            listen_to_bots: false,
19809            interrupt_on_new_message: false,
19810            mention_only: false,
19811            proxy_url: None,
19812            stream_mode: StreamMode::default(),
19813            draft_update_interval_ms: 1000,
19814            multi_message_delay_ms: 800,
19815            stall_timeout_secs: 0,
19816            approval_timeout_secs: 300,
19817            excluded_tools: vec![],
19818            reply_min_interval_secs: 0,
19819            reply_queue_depth_max: 0,
19820        };
19821        let json = serde_json::to_string(&dc).unwrap();
19822        let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
19823        assert!(parsed.guild_ids.is_empty());
19824    }
19825
19826    // ── iMessage / Matrix config ────────────────────────────
19827
19828    // iMessage `allowed_contacts` was lifted out of `IMessageConfig` in V3;
19829    // inbound peer authorization lives in `Config::peer_groups`. The
19830    // round-trip of contact-list values from a V2 TOML is exercised by
19831    // `imessage_v2_allowed_contacts_fold_into_peer_groups` below; per-field
19832    // struct serde for `allowed_contacts` no longer applies.
19833
19834    #[test]
19835    async fn imessage_v2_allowed_contacts_fold_into_peer_groups() {
19836        // V2 TOML with `allowed_contacts` on the channel must be folded
19837        // into a synthesized `peer_groups.imessage_default` group with
19838        // each contact as an external peer.
19839        let raw = r#"
19840schema_version = 2
19841
19842[channels.imessage]
19843enabled = true
19844allowed_contacts = ["+1234567890", "user@icloud.com"]
19845"#;
19846        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
19847        let group = parsed
19848            .peer_groups
19849            .get("imessage_default")
19850            .expect("V2 imessage.allowed_contacts must fold into peer_groups.imessage_default");
19851        assert_eq!(group.channel, "imessage");
19852        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
19853        assert_eq!(usernames, vec!["+1234567890", "user@icloud.com"]);
19854    }
19855
19856    #[test]
19857    async fn matrix_config_serde() {
19858        let mc = MatrixConfig {
19859            enabled: true,
19860            homeserver: "https://matrix.org".into(),
19861            access_token: Some("syt_token_abc".into()),
19862            user_id: Some("@bot:matrix.org".into()),
19863            device_id: Some("DEVICE123".into()),
19864            allowed_rooms: vec!["!room123:matrix.org".into()],
19865            interrupt_on_new_message: false,
19866            stream_mode: StreamMode::default(),
19867            draft_update_interval_ms: 1500,
19868            multi_message_delay_ms: 800,
19869            recovery_key: None,
19870            mention_only: false,
19871            password: None,
19872            approval_timeout_secs: 300,
19873            reply_in_thread: true,
19874            ack_reactions: Some(true),
19875            excluded_tools: vec![],
19876            reply_min_interval_secs: 0,
19877            reply_queue_depth_max: 0,
19878        };
19879        let json = serde_json::to_string(&mc).unwrap();
19880        let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
19881        assert_eq!(parsed.homeserver, "https://matrix.org");
19882        assert_eq!(parsed.access_token.as_deref(), Some("syt_token_abc"));
19883        assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org"));
19884        assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123"));
19885        assert_eq!(
19886            parsed.allowed_rooms.first().map(|s| s.as_str()),
19887            Some("!room123:matrix.org")
19888        );
19889    }
19890
19891    #[test]
19892    async fn matrix_config_toml_roundtrip() {
19893        let mc = MatrixConfig {
19894            enabled: true,
19895            homeserver: "https://synapse.local:8448".into(),
19896            access_token: Some("tok".into()),
19897            user_id: None,
19898            device_id: None,
19899            allowed_rooms: vec!["!abc:synapse.local".into()],
19900            interrupt_on_new_message: false,
19901            stream_mode: StreamMode::default(),
19902            draft_update_interval_ms: 1500,
19903            multi_message_delay_ms: 800,
19904            recovery_key: None,
19905            mention_only: false,
19906            password: None,
19907            approval_timeout_secs: 300,
19908            reply_in_thread: true,
19909            ack_reactions: Some(true),
19910            excluded_tools: vec![],
19911            reply_min_interval_secs: 0,
19912            reply_queue_depth_max: 0,
19913        };
19914        let toml_str = toml::to_string(&mc).unwrap();
19915        let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
19916        assert_eq!(parsed.homeserver, "https://synapse.local:8448");
19917        assert_eq!(parsed.allowed_rooms.len(), 1);
19918    }
19919
19920    #[test]
19921    async fn matrix_config_backward_compatible_without_session_hints() {
19922        // room_id in TOML is now migrated by prepare_table at the top level;
19923        // a bare MatrixConfig parse just ignores unknown keys.
19924        let toml = r#"
19925homeserver = "https://matrix.org"
19926access_token = "tok"
19927allowed_users = ["@ops:matrix.org"]
19928allowed_rooms = ["!ops:matrix.org"]
19929"#;
19930
19931        let parsed: MatrixConfig = toml::from_str(toml).unwrap();
19932        assert_eq!(parsed.homeserver, "https://matrix.org");
19933        assert!(parsed.user_id.is_none());
19934        assert!(parsed.device_id.is_none());
19935        assert_eq!(parsed.allowed_rooms, vec!["!ops:matrix.org"]);
19936    }
19937
19938    #[test]
19939    async fn matrix_config_reply_in_thread_defaults_to_true() {
19940        let toml = r#"
19941homeserver = "https://matrix.org"
19942access_token = "tok"
19943allowed_users = ["@u:matrix.org"]
19944"#;
19945        let parsed: MatrixConfig = toml::from_str(toml).unwrap();
19946        assert!(parsed.reply_in_thread);
19947    }
19948
19949    #[test]
19950    async fn signal_config_serde() {
19951        let sc = SignalConfig {
19952            enabled: true,
19953            http_url: "http://127.0.0.1:8686".into(),
19954            account: "+1234567890".into(),
19955            group_ids: vec!["group123".into()],
19956            dm_only: false,
19957            ignore_attachments: true,
19958            ignore_stories: false,
19959            proxy_url: None,
19960            approval_timeout_secs: 300,
19961            excluded_tools: vec![],
19962            reply_min_interval_secs: 0,
19963            reply_queue_depth_max: 0,
19964        };
19965        let json = serde_json::to_string(&sc).unwrap();
19966        let parsed: SignalConfig = serde_json::from_str(&json).unwrap();
19967        assert_eq!(parsed.http_url, "http://127.0.0.1:8686");
19968        assert_eq!(parsed.account, "+1234567890");
19969        assert_eq!(parsed.group_ids, vec!["group123".to_string()]);
19970        assert!(!parsed.dm_only);
19971        assert!(parsed.ignore_attachments);
19972        assert!(!parsed.ignore_stories);
19973    }
19974
19975    #[test]
19976    async fn signal_config_toml_roundtrip() {
19977        let sc = SignalConfig {
19978            enabled: true,
19979            http_url: "http://localhost:8080".into(),
19980            account: "+9876543210".into(),
19981            group_ids: Vec::new(),
19982            dm_only: true,
19983            ignore_attachments: false,
19984            ignore_stories: true,
19985            proxy_url: None,
19986            approval_timeout_secs: 300,
19987            excluded_tools: vec![],
19988            reply_min_interval_secs: 0,
19989            reply_queue_depth_max: 0,
19990        };
19991        let toml_str = toml::to_string(&sc).unwrap();
19992        let parsed: SignalConfig = toml::from_str(&toml_str).unwrap();
19993        assert_eq!(parsed.http_url, "http://localhost:8080");
19994        assert_eq!(parsed.account, "+9876543210");
19995        assert!(parsed.group_ids.is_empty());
19996        assert!(parsed.dm_only);
19997        assert!(parsed.ignore_stories);
19998    }
19999
20000    #[test]
20001    async fn signal_config_defaults() {
20002        let json = r#"{"http_url":"http://127.0.0.1:8686","account":"+1234567890"}"#;
20003        let parsed: SignalConfig = serde_json::from_str(json).unwrap();
20004        assert!(parsed.group_ids.is_empty());
20005        assert!(!parsed.dm_only);
20006        assert!(!parsed.ignore_attachments);
20007        assert!(!parsed.ignore_stories);
20008    }
20009
20010    #[test]
20011    async fn channels_with_imessage_and_matrix() {
20012        let c = ChannelsConfig {
20013            cli: true,
20014            telegram: HashMap::new(),
20015            discord: HashMap::new(),
20016            slack: HashMap::new(),
20017            mattermost: HashMap::new(),
20018            webhook: HashMap::new(),
20019            imessage: HashMap::from([(
20020                "default".to_string(),
20021                IMessageConfig {
20022                    enabled: true,
20023                    excluded_tools: vec![],
20024                    reply_min_interval_secs: 0,
20025                    reply_queue_depth_max: 0,
20026                },
20027            )]),
20028            matrix: HashMap::from([(
20029                "default".to_string(),
20030                MatrixConfig {
20031                    enabled: true,
20032                    homeserver: "https://m.org".into(),
20033                    access_token: Some("tok".into()),
20034                    user_id: None,
20035                    device_id: None,
20036                    allowed_rooms: vec!["!r:m".into()],
20037                    interrupt_on_new_message: false,
20038                    stream_mode: StreamMode::default(),
20039                    draft_update_interval_ms: 1500,
20040                    multi_message_delay_ms: 800,
20041                    recovery_key: None,
20042                    mention_only: false,
20043                    password: None,
20044                    approval_timeout_secs: 300,
20045                    reply_in_thread: true,
20046                    ack_reactions: Some(true),
20047                    excluded_tools: vec![],
20048                    reply_min_interval_secs: 0,
20049                    reply_queue_depth_max: 0,
20050                },
20051            )]),
20052            signal: HashMap::new(),
20053            whatsapp: HashMap::new(),
20054            linq: HashMap::new(),
20055            wati: HashMap::new(),
20056            nextcloud_talk: HashMap::new(),
20057            email: HashMap::new(),
20058            gmail_push: HashMap::new(),
20059            irc: HashMap::new(),
20060            twitch: HashMap::new(),
20061            lark: HashMap::new(),
20062            line: HashMap::new(),
20063            dingtalk: HashMap::new(),
20064            wecom: HashMap::new(),
20065            wecom_ws: HashMap::new(),
20066            wechat: HashMap::new(),
20067            qq: HashMap::new(),
20068            twitter: HashMap::new(),
20069            mochat: HashMap::new(),
20070            nostr: HashMap::new(),
20071            clawdtalk: HashMap::new(),
20072            reddit: HashMap::new(),
20073            bluesky: HashMap::new(),
20074            voice_call: HashMap::new(),
20075            voice_duplex: HashMap::new(),
20076            voice_wake: HashMap::new(),
20077            mqtt: HashMap::new(),
20078            amqp: HashMap::new(),
20079            message_timeout_secs: 300,
20080            max_concurrent_per_channel: default_channel_max_concurrent_per_channel(),
20081            ack_reactions: true,
20082            show_tool_calls: true,
20083            session_persistence: true,
20084            session_backend: default_session_backend(),
20085            session_ttl_hours: 0,
20086            debounce_ms: 0,
20087        };
20088        let toml_str = toml::to_string_pretty(&c).unwrap();
20089        let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
20090        assert!(!parsed.imessage.is_empty());
20091        assert!(!parsed.matrix.is_empty());
20092        assert_eq!(
20093            parsed.matrix.get("default").unwrap().homeserver,
20094            "https://m.org"
20095        );
20096    }
20097
20098    #[test]
20099    async fn channels_default_has_no_imessage_matrix() {
20100        let c = ChannelsConfig::default();
20101        assert!(c.imessage.is_empty());
20102        assert!(c.matrix.is_empty());
20103    }
20104
20105    // ── Edge cases: serde(default) for non-secret optional fields ─────
20106    // The legacy `allowed_users` field is no longer carried on channel
20107    // configs (V3 moved inbound peer authorization into
20108    // `Config::peer_groups`); V2 TOMLs with `allowed_users` are folded
20109    // by `migrate_to_current` into `[peer_groups.<type>_<alias>]`. See
20110    // `discord_v2_allowed_users_fold_into_peer_groups` below.
20111
20112    #[test]
20113    async fn discord_v2_allowed_users_fold_into_peer_groups() {
20114        let raw = r#"
20115schema_version = 2
20116
20117[channels.discord]
20118enabled = true
20119bot_token = "tok"
20120guild_id = "123"
20121allowed_users = ["111", "222"]
20122"#;
20123        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
20124        let group = parsed
20125            .peer_groups
20126            .get("discord_default")
20127            .expect("V2 discord.allowed_users must fold into peer_groups.discord_default");
20128        assert_eq!(group.channel, "discord");
20129        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
20130        assert_eq!(usernames, vec!["111", "222"]);
20131    }
20132
20133    #[test]
20134    async fn slack_v2_allowed_users_fold_into_peer_groups() {
20135        let raw = r#"
20136schema_version = 2
20137
20138[channels.slack]
20139enabled = true
20140bot_token = "xoxb-tok"
20141allowed_users = ["U111"]
20142"#;
20143        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
20144        let group = parsed
20145            .peer_groups
20146            .get("slack_default")
20147            .expect("V2 slack.allowed_users must fold into peer_groups.slack_default");
20148        assert_eq!(group.channel, "slack");
20149        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
20150        assert_eq!(usernames, vec!["U111"]);
20151    }
20152
20153    #[test]
20154    async fn slack_config_deserializes_with_channel_ids() {
20155        let json = r#"{"bot_token":"xoxb-tok","channel_ids":["C111","D222"]}"#;
20156        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
20157        assert_eq!(parsed.channel_ids, vec!["C111", "D222"]);
20158        assert!(!parsed.interrupt_on_new_message);
20159        assert_eq!(parsed.thread_replies, None);
20160        assert!(!parsed.mention_only);
20161    }
20162
20163    #[test]
20164    async fn slack_config_deserializes_with_mention_only() {
20165        let json = r#"{"bot_token":"xoxb-tok","mention_only":true}"#;
20166        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
20167        assert!(parsed.mention_only);
20168        assert!(!parsed.interrupt_on_new_message);
20169        assert_eq!(parsed.thread_replies, None);
20170    }
20171
20172    #[test]
20173    async fn slack_config_deserializes_interrupt_on_new_message() {
20174        let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
20175        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
20176        assert!(parsed.interrupt_on_new_message);
20177        assert_eq!(parsed.thread_replies, None);
20178        assert!(!parsed.mention_only);
20179    }
20180
20181    #[test]
20182    async fn slack_config_deserializes_thread_replies() {
20183        let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#;
20184        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
20185        assert_eq!(parsed.thread_replies, Some(false));
20186        assert!(!parsed.interrupt_on_new_message);
20187        assert!(!parsed.mention_only);
20188    }
20189
20190    #[test]
20191    async fn discord_config_default_interrupt_on_new_message_is_false() {
20192        let json = r#"{"bot_token":"tok"}"#;
20193        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
20194        assert!(!parsed.interrupt_on_new_message);
20195    }
20196
20197    #[test]
20198    async fn discord_config_deserializes_interrupt_on_new_message_true() {
20199        let json = r#"{"bot_token":"tok","interrupt_on_new_message":true}"#;
20200        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
20201        assert!(parsed.interrupt_on_new_message);
20202    }
20203
20204    #[test]
20205    async fn discord_config_toml_backward_compat() {
20206        let toml_str = r#"
20207bot_token = "tok"
20208guild_id = "123"
20209"#;
20210        let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
20211        assert_eq!(parsed.bot_token, "tok");
20212    }
20213
20214    #[test]
20215    async fn slack_config_toml_with_channel_ids() {
20216        let toml_str = r#"
20217bot_token = "xoxb-tok"
20218channel_ids = ["C123", "D456"]
20219"#;
20220        let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
20221        assert_eq!(parsed.channel_ids, vec!["C123", "D456"]);
20222        assert!(!parsed.interrupt_on_new_message);
20223        assert_eq!(parsed.thread_replies, None);
20224        assert!(!parsed.mention_only);
20225    }
20226
20227    #[test]
20228    async fn slack_config_toml_without_channel_ids_defaults_empty() {
20229        let toml_str = r#"
20230bot_token = "xoxb-tok"
20231"#;
20232        let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
20233        assert!(parsed.channel_ids.is_empty());
20234    }
20235
20236    #[test]
20237    async fn mattermost_config_default_interrupt_on_new_message_is_false() {
20238        let json = r#"{"url":"https://mm.example.com","bot_token":"tok"}"#;
20239        let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
20240        assert!(!parsed.interrupt_on_new_message);
20241    }
20242
20243    #[test]
20244    async fn mattermost_config_deserializes_interrupt_on_new_message_true() {
20245        let json =
20246            r#"{"url":"https://mm.example.com","bot_token":"tok","interrupt_on_new_message":true}"#;
20247        let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
20248        assert!(parsed.interrupt_on_new_message);
20249    }
20250
20251    #[test]
20252    async fn whatsapp_config_default_interrupt_on_new_message_is_false() {
20253        let json = r#"{"session_path":"/tmp/zeroclaw-whatsapp-session.db"}"#;
20254        let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
20255        assert!(!parsed.interrupt_on_new_message);
20256    }
20257
20258    #[test]
20259    async fn whatsapp_config_deserializes_interrupt_on_new_message_true() {
20260        let json = r#"{"session_path":"/tmp/zeroclaw-whatsapp-session.db","interrupt_on_new_message":true}"#;
20261        let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
20262        assert!(parsed.interrupt_on_new_message);
20263    }
20264
20265    #[test]
20266    async fn webhook_config_with_secret() {
20267        let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
20268        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
20269        assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
20270    }
20271
20272    #[test]
20273    async fn webhook_config_without_secret() {
20274        let json = r#"{"port":8080}"#;
20275        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
20276        assert!(parsed.secret.is_none());
20277        assert_eq!(parsed.port, 8080);
20278    }
20279
20280    #[test]
20281    async fn webhook_config_port_defaults_when_omitted() {
20282        let p: WebhookConfig = serde_json::from_str("{}").unwrap();
20283        assert_eq!(p.port, 8090);
20284    }
20285
20286    #[test]
20287    async fn webhook_config_retry_fields_default_to_none() {
20288        let json = r#"{"port":8080}"#;
20289        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
20290        assert!(parsed.max_retries.is_none());
20291        assert!(parsed.retry_base_delay_ms.is_none());
20292        assert!(parsed.retry_max_delay_ms.is_none());
20293    }
20294
20295    #[test]
20296    async fn webhook_config_retry_fields_roundtrip() {
20297        let wc = WebhookConfig {
20298            enabled: true,
20299            port: 8080,
20300            listen_path: None,
20301            send_url: Some("https://example.com/cb".into()),
20302            send_method: None,
20303            auth_header: None,
20304            secret: None,
20305            excluded_tools: vec![],
20306            reply_min_interval_secs: 0,
20307            reply_queue_depth_max: 0,
20308            max_retries: Some(5),
20309            retry_base_delay_ms: Some(250),
20310            retry_max_delay_ms: Some(10_000),
20311        };
20312
20313        let json = serde_json::to_string(&wc).unwrap();
20314        let parsed: WebhookConfig = serde_json::from_str(&json).unwrap();
20315        assert_eq!(parsed.max_retries, Some(5));
20316        assert_eq!(parsed.retry_base_delay_ms, Some(250));
20317        assert_eq!(parsed.retry_max_delay_ms, Some(10_000));
20318
20319        let toml_str = toml::to_string(&wc).unwrap();
20320        let parsed: WebhookConfig = toml::from_str(&toml_str).unwrap();
20321        assert_eq!(parsed.max_retries, Some(5));
20322        assert_eq!(parsed.retry_base_delay_ms, Some(250));
20323        assert_eq!(parsed.retry_max_delay_ms, Some(10_000));
20324    }
20325
20326    // ── WhatsApp config ──────────────────────────────────────
20327
20328    #[test]
20329    async fn whatsapp_config_serde() {
20330        let wc = WhatsAppConfig {
20331            enabled: true,
20332            access_token: Some("EAABx...".into()),
20333            phone_number_id: Some("123456789".into()),
20334            verify_token: Some("my-verify-token".into()),
20335            app_secret: None,
20336            session_path: None,
20337            pair_phone: None,
20338            pair_code: None,
20339            ws_url: None,
20340            mention_only: false,
20341            interrupt_on_new_message: false,
20342            mode: WhatsAppWebMode::default(),
20343            dm_policy: WhatsAppChatPolicy::default(),
20344            group_policy: WhatsAppChatPolicy::default(),
20345            self_chat_mode: false,
20346            dm_mention_patterns: vec![],
20347            group_mention_patterns: vec![],
20348            proxy_url: None,
20349            approval_timeout_secs: 300,
20350            excluded_tools: vec![],
20351            reply_min_interval_secs: 0,
20352            reply_queue_depth_max: 0,
20353        };
20354        let json = serde_json::to_string(&wc).unwrap();
20355        let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
20356        assert_eq!(parsed.access_token, Some("EAABx...".into()));
20357        assert_eq!(parsed.phone_number_id, Some("123456789".into()));
20358        assert_eq!(parsed.verify_token, Some("my-verify-token".into()));
20359    }
20360
20361    #[test]
20362    async fn whatsapp_config_toml_roundtrip() {
20363        let wc = WhatsAppConfig {
20364            enabled: true,
20365            access_token: Some("tok".into()),
20366            phone_number_id: Some("12345".into()),
20367            verify_token: Some("verify".into()),
20368            app_secret: Some("secret123".into()),
20369            session_path: None,
20370            pair_phone: None,
20371            pair_code: None,
20372            ws_url: None,
20373            mention_only: false,
20374            interrupt_on_new_message: false,
20375            mode: WhatsAppWebMode::default(),
20376            dm_policy: WhatsAppChatPolicy::default(),
20377            group_policy: WhatsAppChatPolicy::default(),
20378            self_chat_mode: false,
20379            dm_mention_patterns: vec![],
20380            group_mention_patterns: vec![],
20381            proxy_url: None,
20382            approval_timeout_secs: 300,
20383            excluded_tools: vec![],
20384            reply_min_interval_secs: 0,
20385            reply_queue_depth_max: 0,
20386        };
20387        let toml_str = toml::to_string(&wc).unwrap();
20388        let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
20389        assert_eq!(parsed.phone_number_id, Some("12345".into()));
20390    }
20391
20392    #[test]
20393    async fn whatsapp_v2_allowed_numbers_fold_into_peer_groups() {
20394        // V2 `allowed_numbers` on a WhatsApp channel migrates to a
20395        // synthesized `peer_groups.whatsapp_default` group. The wildcard
20396        // `*` is dropped at synthesis; concrete numbers round-trip.
20397        let raw = r#"
20398schema_version = 2
20399
20400[channels.whatsapp]
20401enabled = true
20402access_token = "tok"
20403phone_number_id = "123"
20404verify_token = "ver"
20405allowed_numbers = ["+1", "+2"]
20406"#;
20407        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
20408        let group = parsed
20409            .peer_groups
20410            .get("whatsapp_default")
20411            .expect("V2 whatsapp.allowed_numbers must fold into peer_groups.whatsapp_default");
20412        assert_eq!(group.channel, "whatsapp");
20413        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
20414        assert_eq!(usernames, vec!["+1", "+2"]);
20415    }
20416
20417    #[test]
20418    async fn whatsapp_config_backend_type_cloud_precedence_when_ambiguous() {
20419        let wc = WhatsAppConfig {
20420            enabled: true,
20421            access_token: Some("tok".into()),
20422            phone_number_id: Some("123".into()),
20423            verify_token: Some("ver".into()),
20424            app_secret: None,
20425            session_path: Some("~/.zeroclaw/state/whatsapp-web/session.db".into()),
20426            pair_phone: None,
20427            pair_code: None,
20428            ws_url: None,
20429            mention_only: false,
20430            interrupt_on_new_message: false,
20431            mode: WhatsAppWebMode::default(),
20432            dm_policy: WhatsAppChatPolicy::default(),
20433            group_policy: WhatsAppChatPolicy::default(),
20434            self_chat_mode: false,
20435            dm_mention_patterns: vec![],
20436            group_mention_patterns: vec![],
20437            proxy_url: None,
20438            approval_timeout_secs: 300,
20439            excluded_tools: vec![],
20440            reply_min_interval_secs: 0,
20441            reply_queue_depth_max: 0,
20442        };
20443        assert!(wc.is_ambiguous_config());
20444        assert_eq!(wc.backend_type(), "cloud");
20445    }
20446
20447    #[test]
20448    async fn whatsapp_config_backend_type_web() {
20449        let wc = WhatsAppConfig {
20450            enabled: true,
20451            access_token: None,
20452            phone_number_id: None,
20453            verify_token: None,
20454            app_secret: None,
20455            session_path: Some("~/.zeroclaw/state/whatsapp-web/session.db".into()),
20456            pair_phone: None,
20457            pair_code: None,
20458            ws_url: None,
20459            mention_only: false,
20460            interrupt_on_new_message: false,
20461            mode: WhatsAppWebMode::default(),
20462            dm_policy: WhatsAppChatPolicy::default(),
20463            group_policy: WhatsAppChatPolicy::default(),
20464            self_chat_mode: false,
20465            dm_mention_patterns: vec![],
20466            group_mention_patterns: vec![],
20467            proxy_url: None,
20468            approval_timeout_secs: 300,
20469            excluded_tools: vec![],
20470            reply_min_interval_secs: 0,
20471            reply_queue_depth_max: 0,
20472        };
20473        assert!(!wc.is_ambiguous_config());
20474        assert_eq!(wc.backend_type(), "web");
20475    }
20476
20477    #[test]
20478    async fn channels_with_whatsapp() {
20479        let c = ChannelsConfig {
20480            cli: true,
20481            telegram: HashMap::new(),
20482            discord: HashMap::new(),
20483            slack: HashMap::new(),
20484            mattermost: HashMap::new(),
20485            webhook: HashMap::new(),
20486            imessage: HashMap::new(),
20487            matrix: HashMap::new(),
20488            signal: HashMap::new(),
20489            whatsapp: HashMap::from([(
20490                "default".to_string(),
20491                WhatsAppConfig {
20492                    enabled: true,
20493                    access_token: Some("tok".into()),
20494                    phone_number_id: Some("123".into()),
20495                    verify_token: Some("ver".into()),
20496                    app_secret: None,
20497                    session_path: None,
20498                    pair_phone: None,
20499                    pair_code: None,
20500                    ws_url: None,
20501                    mention_only: false,
20502                    interrupt_on_new_message: false,
20503                    mode: WhatsAppWebMode::default(),
20504                    dm_policy: WhatsAppChatPolicy::default(),
20505                    group_policy: WhatsAppChatPolicy::default(),
20506                    self_chat_mode: false,
20507                    dm_mention_patterns: vec![],
20508                    group_mention_patterns: vec![],
20509                    proxy_url: None,
20510                    approval_timeout_secs: 300,
20511                    excluded_tools: vec![],
20512                    reply_min_interval_secs: 0,
20513                    reply_queue_depth_max: 0,
20514                },
20515            )]),
20516            linq: HashMap::new(),
20517            wati: HashMap::new(),
20518            nextcloud_talk: HashMap::new(),
20519            email: HashMap::new(),
20520            gmail_push: HashMap::new(),
20521            irc: HashMap::new(),
20522            twitch: HashMap::new(),
20523            lark: HashMap::new(),
20524            line: HashMap::new(),
20525            dingtalk: HashMap::new(),
20526            wecom: HashMap::new(),
20527            wecom_ws: HashMap::new(),
20528            wechat: HashMap::new(),
20529            qq: HashMap::new(),
20530            twitter: HashMap::new(),
20531            mochat: HashMap::new(),
20532            nostr: HashMap::new(),
20533            clawdtalk: HashMap::new(),
20534            reddit: HashMap::new(),
20535            bluesky: HashMap::new(),
20536            voice_call: HashMap::new(),
20537            voice_duplex: HashMap::new(),
20538            voice_wake: HashMap::new(),
20539            mqtt: HashMap::new(),
20540            amqp: HashMap::new(),
20541            message_timeout_secs: 300,
20542            max_concurrent_per_channel: default_channel_max_concurrent_per_channel(),
20543            ack_reactions: true,
20544            show_tool_calls: true,
20545            session_persistence: true,
20546            session_backend: default_session_backend(),
20547            session_ttl_hours: 0,
20548            debounce_ms: 0,
20549        };
20550        let toml_str = toml::to_string_pretty(&c).unwrap();
20551        let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
20552        assert!(!parsed.whatsapp.is_empty());
20553        let wa = parsed.whatsapp.get("default").unwrap();
20554        assert_eq!(wa.phone_number_id, Some("123".into()));
20555    }
20556
20557    #[test]
20558    async fn channels_default_has_no_whatsapp() {
20559        let c = ChannelsConfig::default();
20560        assert!(c.whatsapp.is_empty());
20561    }
20562
20563    #[test]
20564    async fn channels_default_has_no_nextcloud_talk() {
20565        let c = ChannelsConfig::default();
20566        assert!(c.nextcloud_talk.is_empty());
20567    }
20568
20569    // ══════════════════════════════════════════════════════════
20570    // SECURITY CHECKLIST TESTS — Gateway config
20571    // ══════════════════════════════════════════════════════════
20572
20573    #[test]
20574    async fn checklist_gateway_default_requires_pairing() {
20575        let g = GatewayConfig::default();
20576        assert!(g.require_pairing, "Pairing must be required by default");
20577    }
20578
20579    #[test]
20580    async fn checklist_gateway_default_blocks_public_bind() {
20581        let g = GatewayConfig::default();
20582        assert!(
20583            !g.allow_public_bind,
20584            "Public bind must be blocked by default"
20585        );
20586    }
20587
20588    #[test]
20589    async fn checklist_gateway_default_no_tokens() {
20590        let g = GatewayConfig::default();
20591        assert!(
20592            g.paired_tokens.is_empty(),
20593            "No pre-paired tokens by default"
20594        );
20595        assert_eq!(g.pair_rate_limit_per_minute, 10);
20596        assert_eq!(g.webhook_rate_limit_per_minute, 60);
20597        assert!(!g.trust_forwarded_headers);
20598        assert_eq!(g.rate_limit_max_keys, 10_000);
20599        assert_eq!(g.idempotency_ttl_secs, 300);
20600        assert_eq!(g.idempotency_max_keys, 10_000);
20601    }
20602
20603    #[test]
20604    async fn checklist_gateway_cli_default_host_is_localhost() {
20605        // The CLI default for --host is 127.0.0.1 (checked in main.rs)
20606        // Here we verify the config default matches
20607        let c = Config::default();
20608        assert!(
20609            c.gateway.require_pairing,
20610            "Config default must require pairing"
20611        );
20612        assert!(
20613            !c.gateway.allow_public_bind,
20614            "Config default must block public bind"
20615        );
20616    }
20617
20618    #[test]
20619    async fn checklist_gateway_serde_roundtrip() {
20620        let g = GatewayConfig {
20621            port: 42617,
20622            host: "127.0.0.1".into(),
20623            require_pairing: true,
20624            allow_public_bind: false,
20625            allow_remote_admin: false,
20626            paired_tokens: vec!["zc_test_token".into()],
20627            pair_rate_limit_per_minute: 12,
20628            webhook_rate_limit_per_minute: 80,
20629            trust_forwarded_headers: true,
20630            path_prefix: Some("/zeroclaw".into()),
20631            rate_limit_max_keys: 2048,
20632            idempotency_ttl_secs: 600,
20633            idempotency_max_keys: 4096,
20634            session_persistence: true,
20635            session_ttl_hours: 0,
20636            pairing_dashboard: PairingDashboardConfig::default(),
20637            web_dist_dir: None,
20638            tls: None,
20639            request_timeout_secs: 30,
20640            long_running_request_timeout_secs: 600,
20641        };
20642        let toml_str = toml::to_string(&g).unwrap();
20643        let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
20644        assert!(parsed.require_pairing);
20645        assert!(parsed.session_persistence);
20646        assert_eq!(parsed.session_ttl_hours, 0);
20647        assert!(!parsed.allow_public_bind);
20648        assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
20649        assert_eq!(parsed.pair_rate_limit_per_minute, 12);
20650        assert_eq!(parsed.webhook_rate_limit_per_minute, 80);
20651        assert!(parsed.trust_forwarded_headers);
20652        assert_eq!(parsed.path_prefix.as_deref(), Some("/zeroclaw"));
20653        assert_eq!(parsed.rate_limit_max_keys, 2048);
20654        assert_eq!(parsed.idempotency_ttl_secs, 600);
20655        assert_eq!(parsed.idempotency_max_keys, 4096);
20656    }
20657
20658    #[test]
20659    async fn checklist_gateway_backward_compat_no_gateway_section() {
20660        // Old configs without [gateway] should get secure defaults
20661        let minimal = r#"
20662workspace_dir = "/tmp/ws"
20663config_path = "/tmp/config.toml"
20664default_temperature = 0.7
20665"#;
20666        let parsed = parse_test_config(minimal);
20667        assert!(
20668            parsed.gateway.require_pairing,
20669            "Missing [gateway] must default to require_pairing=true"
20670        );
20671        assert!(
20672            !parsed.gateway.allow_public_bind,
20673            "Missing [gateway] must default to allow_public_bind=false"
20674        );
20675    }
20676
20677    #[test]
20678    async fn checklist_risk_profile_default_is_workspace_scoped() {
20679        let a = RiskProfileConfig::default();
20680        assert!(a.workspace_only, "Default profile must be workspace_only");
20681        assert!(
20682            !a.forbidden_paths.is_empty(),
20683            "Default forbidden_paths must not be empty"
20684        );
20685        #[cfg(not(target_os = "windows"))]
20686        {
20687            assert!(
20688                a.forbidden_paths.iter().any(|p| p == "/etc"),
20689                "Must block /etc on Unix"
20690            );
20691            assert!(
20692                a.forbidden_paths.iter().any(|p| p == "/proc"),
20693                "Must block /proc on Unix"
20694            );
20695        }
20696        #[cfg(target_os = "windows")]
20697        {
20698            assert!(
20699                a.forbidden_paths.iter().any(|p| p == "C:\\Windows"),
20700                "Must block C:\\Windows on Windows"
20701            );
20702            assert!(
20703                a.forbidden_paths.iter().any(|p| p == "C:\\Program Files"),
20704                "Must block C:\\Program Files on Windows"
20705            );
20706        }
20707        assert!(
20708            a.forbidden_paths.contains(&"~/.ssh".to_string()),
20709            "Must block ~/.ssh"
20710        );
20711    }
20712
20713    // ══════════════════════════════════════════════════════════
20714    // COMPOSIO CONFIG TESTS
20715    // ══════════════════════════════════════════════════════════
20716
20717    #[test]
20718    async fn composio_config_default_disabled() {
20719        let c = ComposioConfig::default();
20720        assert!(!c.enabled, "Composio must be disabled by default");
20721        assert!(c.api_key.is_none(), "No API key by default");
20722        assert_eq!(c.entity_id, "default");
20723    }
20724
20725    #[test]
20726    async fn composio_config_serde_roundtrip() {
20727        let c = ComposioConfig {
20728            enabled: true,
20729            api_key: Some("comp-key-123".into()),
20730            entity_id: "user42".into(),
20731        };
20732        let toml_str = toml::to_string(&c).unwrap();
20733        let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
20734        assert!(parsed.enabled);
20735        assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
20736        assert_eq!(parsed.entity_id, "user42");
20737    }
20738
20739    #[test]
20740    async fn composio_config_backward_compat_missing_section() {
20741        let minimal = r#"
20742workspace_dir = "/tmp/ws"
20743config_path = "/tmp/config.toml"
20744default_temperature = 0.7
20745"#;
20746        let parsed = parse_test_config(minimal);
20747        assert!(
20748            !parsed.composio.enabled,
20749            "Missing [composio] must default to disabled"
20750        );
20751        assert!(parsed.composio.api_key.is_none());
20752    }
20753
20754    #[test]
20755    async fn composio_config_partial_toml() {
20756        let toml_str = r"
20757enabled = true
20758";
20759        let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
20760        assert!(parsed.enabled);
20761        assert!(parsed.api_key.is_none());
20762        assert_eq!(parsed.entity_id, "default");
20763    }
20764
20765    #[test]
20766    async fn composio_config_enable_alias_supported() {
20767        let toml_str = r"
20768enable = true
20769";
20770        let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
20771        assert!(parsed.enabled);
20772        assert!(parsed.api_key.is_none());
20773        assert_eq!(parsed.entity_id, "default");
20774    }
20775
20776    // ══════════════════════════════════════════════════════════
20777    // SECRETS CONFIG TESTS
20778    // ══════════════════════════════════════════════════════════
20779
20780    #[test]
20781    async fn secrets_config_default_encrypts() {
20782        let s = SecretsConfig::default();
20783        assert!(s.encrypt, "Encryption must be enabled by default");
20784    }
20785
20786    #[test]
20787    async fn secrets_config_serde_roundtrip() {
20788        let s = SecretsConfig { encrypt: false };
20789        let toml_str = toml::to_string(&s).unwrap();
20790        let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
20791        assert!(!parsed.encrypt);
20792    }
20793
20794    #[test]
20795    async fn secrets_config_backward_compat_missing_section() {
20796        let minimal = r#"
20797workspace_dir = "/tmp/ws"
20798config_path = "/tmp/config.toml"
20799default_temperature = 0.7
20800"#;
20801        let parsed = parse_test_config(minimal);
20802        assert!(
20803            parsed.secrets.encrypt,
20804            "Missing [secrets] must default to encrypt=true"
20805        );
20806    }
20807
20808    #[test]
20809    async fn config_default_has_composio_and_secrets() {
20810        let c = Config::default();
20811        assert!(!c.composio.enabled);
20812        assert!(c.composio.api_key.is_none());
20813        assert!(c.secrets.encrypt);
20814        assert!(c.browser.enabled);
20815        assert_eq!(c.browser.allowed_domains, vec!["*".to_string()]);
20816    }
20817
20818    #[test]
20819    async fn browser_config_default_enabled() {
20820        let b = BrowserConfig::default();
20821        assert!(b.enabled);
20822        assert_eq!(b.allowed_domains, vec!["*".to_string()]);
20823        assert_eq!(b.backend, "agent_browser");
20824        assert_eq!(b.headed, None);
20825        assert!(b.native_headless);
20826        assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
20827        assert!(b.native_chrome_path.is_none());
20828        assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions");
20829        assert_eq!(b.computer_use.timeout_ms, 15_000);
20830        assert!(!b.computer_use.allow_remote_endpoint);
20831        assert!(b.computer_use.window_allowlist.is_empty());
20832        assert!(b.computer_use.max_coordinate_x.is_none());
20833        assert!(b.computer_use.max_coordinate_y.is_none());
20834    }
20835
20836    #[test]
20837    async fn browser_config_serde_roundtrip() {
20838        let b = BrowserConfig {
20839            enabled: true,
20840            allowed_domains: vec!["example.com".into(), "docs.example.com".into()],
20841            session_name: None,
20842            backend: "auto".into(),
20843            headed: Some(true),
20844            native_headless: false,
20845            native_webdriver_url: "http://localhost:4444".into(),
20846            native_chrome_path: Some("/usr/bin/chromium".into()),
20847            computer_use: BrowserComputerUseConfig {
20848                endpoint: "https://computer-use.example.com/v1/actions".into(),
20849                api_key: Some("test-token".into()),
20850                timeout_ms: 8_000,
20851                allow_remote_endpoint: true,
20852                window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()],
20853                max_coordinate_x: Some(3840),
20854                max_coordinate_y: Some(2160),
20855            },
20856        };
20857        let toml_str = toml::to_string(&b).unwrap();
20858        let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap();
20859        assert!(parsed.enabled);
20860        assert_eq!(parsed.allowed_domains.len(), 2);
20861        assert_eq!(parsed.allowed_domains[0], "example.com");
20862        assert_eq!(parsed.backend, "auto");
20863        assert_eq!(parsed.headed, Some(true));
20864        assert!(!parsed.native_headless);
20865        assert_eq!(parsed.native_webdriver_url, "http://localhost:4444");
20866        assert_eq!(
20867            parsed.native_chrome_path.as_deref(),
20868            Some("/usr/bin/chromium")
20869        );
20870        assert_eq!(
20871            parsed.computer_use.endpoint,
20872            "https://computer-use.example.com/v1/actions"
20873        );
20874        assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token"));
20875        assert_eq!(parsed.computer_use.timeout_ms, 8_000);
20876        assert!(parsed.computer_use.allow_remote_endpoint);
20877        assert_eq!(parsed.computer_use.window_allowlist.len(), 2);
20878        assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840));
20879        assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160));
20880    }
20881
20882    #[test]
20883    async fn browser_config_parses_headed_true() {
20884        let parsed: BrowserConfig = toml::from_str(
20885            r#"
20886backend = "agent_browser"
20887headed = true
20888"#,
20889        )
20890        .unwrap();
20891
20892        assert_eq!(parsed.backend, "agent_browser");
20893        assert_eq!(parsed.headed, Some(true));
20894        assert!(parsed.native_headless);
20895    }
20896
20897    #[test]
20898    async fn browser_config_backward_compat_missing_section() {
20899        let minimal = r#"
20900workspace_dir = "/tmp/ws"
20901config_path = "/tmp/config.toml"
20902default_temperature = 0.7
20903"#;
20904        let parsed = parse_test_config(minimal);
20905        assert!(parsed.browser.enabled);
20906        assert_eq!(parsed.browser.allowed_domains, vec!["*".to_string()]);
20907    }
20908
20909    async fn env_override_lock() -> MutexGuard<'static, ()> {
20910        // Delegate to the crate-shared lock so env-mutating tests in this
20911        // module serialize against `env_overrides::tests` too. Without
20912        // this, tests across the two modules race on `ZEROCLAW_*` vars.
20913        crate::env_overrides::env_test_lock().await
20914    }
20915
20916    #[test]
20917    async fn v1_known_provider_migrates_with_globals_folded_onto_typed_slot() {
20918        // Top-level `model_provider` + `model` + `default_temperature` flow
20919        // onto the migrated typed-slot entry. Vendor-canonical names like
20920        // `openai` map straight to their typed slot; `wire_api` and
20921        // `requires_openai_auth` survive the move.
20922        //
20923        // (Unknown V1 names like `sub2api` are intentionally silent-dropped
20924        // by the V2→V3 migration — see the `Unknown/passthrough` arm of
20925        // `normalize_provider_type` in schema/v2.rs.)
20926        let raw = r#"
20927default_temperature = 0.7
20928model_provider = "openai"
20929model = "gpt-5.3-codex"
20930
20931[model_providers.openai]
20932api_key = "sk-test"
20933uri = "https://api.openai.com/v1"
20934wire_api = "responses"
20935requires_openai_auth = true
20936"#;
20937
20938        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
20939        assert!(
20940            parsed
20941                .providers
20942                .models
20943                .contains_model_provider_type("openai"),
20944            "vendor-canonical V1 provider should land in its typed slot",
20945        );
20946        let profile = parsed
20947            .providers
20948            .models
20949            .find("openai", "default")
20950            .expect("openai.default entry");
20951        assert_eq!(profile.api_key.as_deref(), Some("sk-test"));
20952        assert_eq!(profile.uri.as_deref(), Some("https://api.openai.com/v1"));
20953        assert_eq!(profile.model.as_deref(), Some("gpt-5.3-codex"));
20954        assert_eq!(profile.wire_api, Some(WireApi::Responses));
20955        assert!(profile.requires_openai_auth);
20956    }
20957
20958    #[test]
20959    async fn typed_custom_slot_routes_uri_through_find() {
20960        let _env_guard = env_override_lock().await;
20961        let mut config = Config::default();
20962        config.providers.models.custom.insert(
20963            "default".to_string(),
20964            CustomModelProviderConfig {
20965                base: ModelProviderConfig {
20966                    uri: Some("https://api.tonsof.blue/v1".to_string()),
20967                    ..Default::default()
20968                },
20969            },
20970        );
20971
20972        assert_eq!(
20973            config
20974                .providers
20975                .models
20976                .find("custom", "default")
20977                .and_then(|e| e.uri.as_deref()),
20978            Some("https://api.tonsof.blue/v1")
20979        );
20980        assert!(config.providers.models.find("custom", "default").is_some());
20981    }
20982
20983    #[test]
20984    async fn openai_codex_alias_carries_responses_wire_api_and_requires_openai_auth() {
20985        let _env_guard = env_override_lock().await;
20986        let mut config = Config::default();
20987        config.providers.models.openai.insert(
20988            "codex".to_string(),
20989            OpenAIModelProviderConfig {
20990                base: ModelProviderConfig {
20991                    uri: Some("https://api.tonsof.blue".to_string()),
20992                    wire_api: Some(WireApi::Responses),
20993                    requires_openai_auth: true,
20994                    ..Default::default()
20995                },
20996            },
20997        );
20998
20999        let entry = config
21000            .providers
21001            .models
21002            .find("openai", "codex")
21003            .expect("openai.codex entry");
21004        assert_eq!(entry.uri.as_deref(), Some("https://api.tonsof.blue"));
21005        assert_eq!(entry.wire_api, Some(WireApi::Responses));
21006        assert!(entry.requires_openai_auth);
21007    }
21008
21009    /// Round-trip test for the config CLI: a TOML file with a typed-family
21010    /// model entry must deserialize, find via the typed accessor, and
21011    /// re-serialize without losing any field.
21012    #[test]
21013    async fn provider_models_round_trips_through_load_apply_serialize() {
21014        let _env_guard = env_override_lock().await;
21015        let toml_in = r#"
21016schema_version = 3
21017
21018[providers.models.openrouter.default]
21019uri = "https://example.invalid/v1"
21020model = "primary-model"
21021"#;
21022
21023        let config: Config = toml::from_str(toml_in).expect("parse toml");
21024
21025        assert_eq!(
21026            config
21027                .providers
21028                .models
21029                .find("openrouter", "default")
21030                .and_then(|e| e.model.as_deref()),
21031            Some("primary-model"),
21032        );
21033
21034        // What `config save` would write back to disk.
21035        let toml_out = toml::to_string(&config).expect("serialize toml");
21036        assert!(
21037            toml_out.contains("primary-model"),
21038            "serialized config must keep model value; got:\n{toml_out}",
21039        );
21040    }
21041
21042    /// `resolve_default_model` returns the first available `models.*` entry's
21043    /// model. Returning `None` is reserved for "no model_provider has any model
21044    /// configured", which callers must surface as a configuration error
21045    /// rather than silently substituting a vendor default.
21046    #[test]
21047    async fn resolve_default_model_picks_first_available() {
21048        let _env_guard = env_override_lock().await;
21049        let mut config = Config::default();
21050        // Empty config: no model anywhere -> None (caller errors loudly).
21051        assert_eq!(config.resolve_default_model(), None);
21052
21053        // Add an entry without a model -> still None.
21054        config
21055            .providers
21056            .models
21057            .anthropic
21058            .insert("default".into(), AnthropicModelProviderConfig::default());
21059        assert_eq!(config.resolve_default_model(), None);
21060
21061        // Add an entry with a model -> first-available wins.
21062        config.providers.models.together.insert(
21063            "default".to_string(),
21064            TogetherModelProviderConfig {
21065                base: ModelProviderConfig {
21066                    model: Some("tertiary-model".to_string()),
21067                    ..Default::default()
21068                },
21069            },
21070        );
21071        assert_eq!(
21072            config.resolve_default_model().as_deref(),
21073            Some("tertiary-model"),
21074        );
21075
21076        // Add a model_provider with a model — resolve_default_model finds it.
21077        config.providers.models.openrouter.insert(
21078            "default".to_string(),
21079            OpenRouterModelProviderConfig {
21080                base: ModelProviderConfig {
21081                    model: Some("primary-model".to_string()),
21082                    ..Default::default()
21083                },
21084            },
21085        );
21086        // resolve_default_model returns the first non-empty model across all model_providers.
21087        assert!(config.resolve_default_model().is_some());
21088    }
21089
21090    #[test]
21091    async fn save_repairs_bare_config_filename_using_runtime_resolution() {
21092        let _env_guard = env_override_lock().await;
21093        let temp_home =
21094            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21095        let workspace_dir = temp_home.join("workspace");
21096        let resolved_config_path = temp_home.join(".zeroclaw").join("config.toml");
21097
21098        let original_home = std::env::var("HOME").ok();
21099        // SAFETY: test-only, single-threaded test runner.
21100        unsafe { std::env::set_var("HOME", &temp_home) };
21101        // SAFETY: test-only, single-threaded test runner.
21102        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21103
21104        let mut config = Config {
21105            data_dir: workspace_dir,
21106            config_path: PathBuf::from("config.toml"),
21107            ..Default::default()
21108        };
21109        config.providers.models.anthropic.insert(
21110            "default".to_string(),
21111            AnthropicModelProviderConfig {
21112                base: ModelProviderConfig {
21113                    temperature: Some(0.5),
21114                    ..Default::default()
21115                },
21116            },
21117        );
21118        // ModelProvider fields are now resolved directly — no cache needed.
21119        config.save().await.unwrap();
21120
21121        assert!(resolved_config_path.exists());
21122        let saved = tokio::fs::read_to_string(&resolved_config_path)
21123            .await
21124            .unwrap();
21125        let parsed = parse_test_config(&saved);
21126        assert!(
21127            (parsed
21128                .providers
21129                .models
21130                .find("anthropic", "default")
21131                .and_then(|e| e.temperature)
21132                .unwrap_or(0.7)
21133                - 0.5)
21134                .abs()
21135                < f64::EPSILON
21136        );
21137
21138        // SAFETY: test-only, single-threaded test runner.
21139        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21140        if let Some(home) = original_home {
21141            // SAFETY: test-only, single-threaded test runner.
21142            unsafe { std::env::set_var("HOME", home) };
21143        } else {
21144            // SAFETY: test-only, single-threaded test runner.
21145            unsafe { std::env::remove_var("HOME") };
21146        }
21147        let _ = tokio::fs::remove_dir_all(temp_home).await;
21148    }
21149
21150    #[test]
21151    async fn validate_ollama_cloud_model_requires_remote_api_url() {
21152        let _env_guard = env_override_lock().await;
21153        let mut config = Config::default();
21154        config.providers.models.ollama.insert(
21155            "default".to_string(),
21156            OllamaModelProviderConfig {
21157                base: ModelProviderConfig {
21158                    model: Some("glm-5:cloud".to_string()),
21159                    uri: None,
21160                    api_key: Some("ollama-key".to_string()),
21161                    ..Default::default()
21162                },
21163                ..OllamaModelProviderConfig::default()
21164            },
21165        );
21166
21167        let error = config.validate().expect_err("expected validation to fail");
21168        assert!(error.to_string().contains(
21169            "providers.models.ollama.default.model uses ':cloud', but uri is local or unset"
21170        ));
21171    }
21172
21173    #[test]
21174    async fn validate_ollama_cloud_model_accepts_private_remote_without_api_key() {
21175        let _env_guard = env_override_lock().await;
21176        let mut config = Config::default();
21177        config.providers.models.ollama.insert(
21178            "default".to_string(),
21179            OllamaModelProviderConfig {
21180                base: ModelProviderConfig {
21181                    model: Some("glm-5:cloud".to_string()),
21182                    uri: Some("http://192.168.1.100:11434".to_string()),
21183                    api_key: None,
21184                    ..Default::default()
21185                },
21186                ..OllamaModelProviderConfig::default()
21187            },
21188        );
21189
21190        let result = config.validate();
21191        assert!(result.is_ok(), "expected validation to pass: {result:?}");
21192    }
21193
21194    #[test]
21195    async fn validate_ollama_cloud_model_requires_api_key_for_official_endpoint() {
21196        let _env_guard = env_override_lock().await;
21197        let mut config = Config::default();
21198        config.providers.models.ollama.insert(
21199            "default".to_string(),
21200            OllamaModelProviderConfig {
21201                base: ModelProviderConfig {
21202                    model: Some("glm-5:cloud".to_string()),
21203                    uri: Some("https://ollama.com/api".to_string()),
21204                    api_key: None,
21205                    ..Default::default()
21206                },
21207                ..OllamaModelProviderConfig::default()
21208            },
21209        );
21210
21211        let error = config.validate().expect_err("expected validation to fail");
21212        assert!(error.to_string().contains(
21213            "providers.models.ollama.default.model uses ':cloud', but no API key is configured"
21214        ));
21215    }
21216
21217    #[test]
21218    async fn validate_ollama_cloud_model_accepts_remote_endpoint_with_typed_api_key() {
21219        // V0.8.0: env-var fallback (`OLLAMA_API_KEY`) eradicated.
21220        // Operators set the credential on the typed alias.
21221        let _env_guard = env_override_lock().await;
21222        let mut config = Config::default();
21223        config.providers.models.ollama.insert(
21224            "default".to_string(),
21225            OllamaModelProviderConfig {
21226                base: ModelProviderConfig {
21227                    model: Some("glm-5:cloud".to_string()),
21228                    uri: Some("https://ollama.com/api".to_string()),
21229                    api_key: Some("ollama-typed-key".to_string()),
21230                    ..Default::default()
21231                },
21232                ..OllamaModelProviderConfig::default()
21233            },
21234        );
21235
21236        let result = config.validate();
21237        assert!(result.is_ok(), "expected validation to pass: {result:?}");
21238    }
21239
21240    #[test]
21241    async fn validate_ollama_cloud_model_checks_each_alias_for_official_key() {
21242        let _env_guard = env_override_lock().await;
21243        let mut config = Config::default();
21244        config.providers.models.ollama.insert(
21245            "local".to_string(),
21246            OllamaModelProviderConfig {
21247                base: ModelProviderConfig {
21248                    model: Some("llama3".to_string()),
21249                    uri: Some("http://192.168.1.100:11434".to_string()),
21250                    ..Default::default()
21251                },
21252                ..OllamaModelProviderConfig::default()
21253            },
21254        );
21255        config.providers.models.ollama.insert(
21256            "cloud".to_string(),
21257            OllamaModelProviderConfig {
21258                base: ModelProviderConfig {
21259                    model: Some("glm-5:cloud".to_string()),
21260                    uri: Some("https://ollama.com/api".to_string()),
21261                    api_key: None,
21262                    ..Default::default()
21263                },
21264                ..OllamaModelProviderConfig::default()
21265            },
21266        );
21267
21268        let error = config.validate().expect_err("expected validation to fail");
21269        assert!(error.to_string().contains(
21270            "providers.models.ollama.cloud.model uses ':cloud', but no API key is configured"
21271        ));
21272    }
21273
21274    #[test]
21275    async fn deserialize_rejects_unknown_model_provider_wire_api() {
21276        let toml = r#"
21277schema_version = 3
21278
21279[providers.models.openrouter.default]
21280uri = "https://api.tonsof.blue/v1"
21281wire_api = "ws"
21282"#;
21283        let err = toml::from_str::<Config>(toml).expect_err("expected deserialize failure");
21284        let msg = err.to_string();
21285        assert!(
21286            msg.contains("wire_api") || msg.contains("ws"),
21287            "error should reference the invalid wire_api value, got: {msg}"
21288        );
21289    }
21290
21291    #[test]
21292    async fn resolve_runtime_config_dirs_accepts_legacy_zeroclaw_workspace() {
21293        let _env_guard = env_override_lock().await;
21294        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
21295        let default_workspace_dir = default_config_dir.join("workspace");
21296        let workspace_dir = default_config_dir.join("profile-a");
21297
21298        // SAFETY: test-only, single-threaded test runner.
21299        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21300        let (config_dir, resolved_workspace_dir, source) =
21301            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
21302                .await
21303                .unwrap();
21304
21305        // ZEROCLAW_WORKSPACE is the deprecated alias for ZEROCLAW_DATA_DIR.
21306        // Resolution treats the path as the config root and derives the data
21307        // sub-dir from it; the source label reflects the deprecated entry.
21308        assert_eq!(source, ConfigResolutionSource::EnvWorkspaceLegacy);
21309        assert_eq!(config_dir, workspace_dir);
21310        assert_eq!(resolved_workspace_dir, workspace_dir.join("data"));
21311
21312        // SAFETY: test-only, single-threaded test runner.
21313        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21314        let _ = fs::remove_dir_all(default_config_dir).await;
21315    }
21316
21317    #[test]
21318    async fn resolve_runtime_config_dirs_uses_env_config_dir_first() {
21319        let _env_guard = env_override_lock().await;
21320        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
21321        let default_workspace_dir = default_config_dir.join("workspace");
21322        let explicit_config_dir = default_config_dir.join("explicit-config");
21323
21324        fs::create_dir_all(&default_config_dir).await.unwrap();
21325
21326        // SAFETY: test-only, single-threaded test runner.
21327        unsafe { std::env::set_var("ZEROCLAW_CONFIG_DIR", &explicit_config_dir) };
21328        // SAFETY: test-only, single-threaded test runner.
21329        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21330
21331        let (config_dir, resolved_workspace_dir, source) =
21332            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
21333                .await
21334                .unwrap();
21335
21336        assert_eq!(source, ConfigResolutionSource::EnvConfigDir);
21337        assert_eq!(config_dir, explicit_config_dir);
21338        assert_eq!(resolved_workspace_dir, explicit_config_dir.join("data"));
21339
21340        // SAFETY: test-only, single-threaded test runner.
21341        unsafe { std::env::remove_var("ZEROCLAW_CONFIG_DIR") };
21342        let _ = fs::remove_dir_all(default_config_dir).await;
21343    }
21344
21345    #[test]
21346    async fn resolve_runtime_config_dirs_falls_back_to_default_layout() {
21347        let _env_guard = env_override_lock().await;
21348        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
21349        let default_workspace_dir = default_config_dir.join("workspace");
21350
21351        // SAFETY: test-only, single-threaded test runner.
21352        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21353        let (config_dir, resolved_workspace_dir, source) =
21354            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
21355                .await
21356                .unwrap();
21357
21358        assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
21359        assert_eq!(config_dir, default_config_dir);
21360        assert_eq!(resolved_workspace_dir, default_workspace_dir);
21361
21362        let _ = fs::remove_dir_all(default_config_dir).await;
21363    }
21364
21365    async fn create_homebrew_prefix() -> TempDir {
21366        let prefix = TempDir::new().expect("homebrew prefix temp dir");
21367        fs::create_dir_all(prefix.path().join("Cellar"))
21368            .await
21369            .expect("create Cellar marker");
21370        prefix
21371    }
21372
21373    #[test]
21374    async fn try_resolve_macos_homebrew_config_dir_detects_cellar_layout() {
21375        let prefix = create_homebrew_prefix().await;
21376        let exe = prefix
21377            .path()
21378            .join("Cellar")
21379            .join("zeroclaw")
21380            .join("0.7.0")
21381            .join("bin")
21382            .join("zeroclaw");
21383
21384        let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
21385            .await
21386            .expect("expected Homebrew layout");
21387
21388        assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
21389    }
21390
21391    #[test]
21392    async fn try_resolve_macos_homebrew_config_dir_detects_prefix_bin_layout() {
21393        let prefix = create_homebrew_prefix().await;
21394        let exe = prefix.path().join("bin").join("zeroclaw");
21395
21396        let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
21397            .await
21398            .expect("expected Homebrew layout");
21399
21400        assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
21401    }
21402
21403    #[test]
21404    async fn try_resolve_macos_homebrew_config_dir_detects_opt_bin_layout() {
21405        let prefix = create_homebrew_prefix().await;
21406        let exe = prefix
21407            .path()
21408            .join("opt")
21409            .join("zeroclaw")
21410            .join("bin")
21411            .join("zeroclaw");
21412
21413        let config_dir = try_resolve_macos_homebrew_config_dir(&exe)
21414            .await
21415            .expect("expected Homebrew layout");
21416
21417        assert_eq!(config_dir, prefix.path().join("var").join("zeroclaw"));
21418    }
21419
21420    #[test]
21421    async fn try_resolve_macos_homebrew_config_dir_rejects_non_homebrew_layout() {
21422        let prefix = TempDir::new().expect("non-homebrew temp dir");
21423        let exe = prefix.path().join("bin").join("zeroclaw");
21424
21425        assert!(try_resolve_macos_homebrew_config_dir(&exe).await.is_none());
21426    }
21427
21428    #[test]
21429    async fn default_path_under_config_dir_respects_zeroclaw_config_dir() {
21430        let _env_guard = env_override_lock().await;
21431        let custom_dir = std::env::temp_dir().join("zeroclaw-test-profile");
21432        // SAFETY: test-only, single-threaded test runner.
21433        unsafe { std::env::set_var("ZEROCLAW_CONFIG_DIR", &custom_dir) };
21434
21435        let result = default_path_under_config_dir("knowledge.db");
21436
21437        // SAFETY: test-only, single-threaded test runner.
21438        unsafe { std::env::remove_var("ZEROCLAW_CONFIG_DIR") };
21439
21440        assert_eq!(
21441            result,
21442            custom_dir.join("knowledge.db").to_string_lossy().as_ref(),
21443            "expected path under ZEROCLAW_CONFIG_DIR, got: {result}"
21444        );
21445    }
21446
21447    #[test]
21448    async fn load_or_init_workspace_override_uses_workspace_root_for_config() {
21449        let _env_guard = env_override_lock().await;
21450        let temp_home =
21451            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21452        let workspace_dir = temp_home.join("profile-a");
21453
21454        let original_home = std::env::var("HOME").ok();
21455        // SAFETY: test-only, single-threaded test runner.
21456        unsafe { std::env::set_var("HOME", &temp_home) };
21457        // SAFETY: test-only, single-threaded test runner.
21458        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21459
21460        let config = Box::pin(Config::load_or_init()).await.unwrap();
21461
21462        // V3 fresh init: `config.data_dir` lives at `<install>/data/`
21463        // (the shared databases root); the install root holds
21464        // `config.toml`. No synthesized `agents/default/workspace/` is
21465        // created at boot — `default` is migration-only, and per-agent
21466        // workspaces are created lazily at agent-loop entry.
21467        assert_eq!(config.data_dir, workspace_dir.join("data"));
21468        assert_eq!(config.config_path, workspace_dir.join("config.toml"));
21469        assert!(workspace_dir.join("config.toml").exists());
21470        assert!(
21471            !workspace_dir.join("agents").exists(),
21472            "fresh init must not create agents/ tree"
21473        );
21474
21475        // SAFETY: test-only, single-threaded test runner.
21476        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21477        if let Some(home) = original_home {
21478            // SAFETY: test-only, single-threaded test runner.
21479            unsafe { std::env::set_var("HOME", home) };
21480        } else {
21481            // SAFETY: test-only, single-threaded test runner.
21482            unsafe { std::env::remove_var("HOME") };
21483        }
21484        let _ = fs::remove_dir_all(temp_home).await;
21485    }
21486
21487    #[test]
21488    async fn load_or_init_workspace_suffix_uses_legacy_config_layout() {
21489        let _env_guard = env_override_lock().await;
21490        let temp_home =
21491            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21492        let workspace_dir = temp_home.join("workspace");
21493        let legacy_config_dir = temp_home.join(".zeroclaw");
21494        let legacy_config_path = legacy_config_dir.join("config.toml");
21495
21496        let original_home = std::env::var("HOME").ok();
21497        // SAFETY: test-only, single-threaded test runner.
21498        unsafe { std::env::set_var("HOME", &temp_home) };
21499        // SAFETY: test-only, single-threaded test runner.
21500        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21501
21502        let config = Box::pin(Config::load_or_init()).await.unwrap();
21503
21504        // V3: `config.data_dir` lives at `<install>/data/`. The
21505        // ZEROCLAW_WORKSPACE env var (deprecated alias) resolved to the
21506        // legacy config layout where the install root is the parent of
21507        // the env-var path; data sits at `<install>/data/`.
21508        assert_eq!(config.data_dir, legacy_config_dir.join("data"));
21509        assert_eq!(config.config_path, legacy_config_path);
21510        assert!(config.config_path.exists());
21511
21512        // SAFETY: test-only, single-threaded test runner.
21513        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21514        if let Some(home) = original_home {
21515            // SAFETY: test-only, single-threaded test runner.
21516            unsafe { std::env::set_var("HOME", home) };
21517        } else {
21518            // SAFETY: test-only, single-threaded test runner.
21519            unsafe { std::env::remove_var("HOME") };
21520        }
21521        let _ = fs::remove_dir_all(temp_home).await;
21522    }
21523
21524    #[test]
21525    async fn load_or_init_workspace_override_keeps_existing_legacy_config() {
21526        let _env_guard = env_override_lock().await;
21527        let temp_home =
21528            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21529        let workspace_dir = temp_home.join("custom-workspace");
21530        let legacy_config_dir = temp_home.join(".zeroclaw");
21531        let legacy_config_path = legacy_config_dir.join("config.toml");
21532
21533        fs::create_dir_all(&legacy_config_dir).await.unwrap();
21534        fs::write(
21535            &legacy_config_path,
21536            r#"default_temperature = 0.7
21537default_model = "legacy-model"
21538"#,
21539        )
21540        .await
21541        .unwrap();
21542
21543        let original_home = std::env::var("HOME").ok();
21544        // SAFETY: test-only, single-threaded test runner.
21545        unsafe { std::env::set_var("HOME", &temp_home) };
21546        // SAFETY: test-only, single-threaded test runner.
21547        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21548
21549        let config = Box::pin(Config::load_or_init()).await.unwrap();
21550
21551        // V3: `config.data_dir` resolves to `<install>/data/` under
21552        // the install root (the directory holding the existing
21553        // `config.toml`), regardless of the ZEROCLAW_WORKSPACE
21554        // (deprecated) override.
21555        assert_eq!(config.data_dir, legacy_config_dir.join("data"));
21556        assert_eq!(config.config_path, legacy_config_path);
21557        assert_eq!(
21558            config
21559                .providers
21560                .models
21561                .find("openrouter", "default")
21562                .and_then(|e| e.model.as_deref()),
21563            Some("legacy-model")
21564        );
21565
21566        // SAFETY: test-only, single-threaded test runner.
21567        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21568        if let Some(home) = original_home {
21569            // SAFETY: test-only, single-threaded test runner.
21570            unsafe { std::env::set_var("HOME", home) };
21571        } else {
21572            // SAFETY: test-only, single-threaded test runner.
21573            unsafe { std::env::remove_var("HOME") };
21574        }
21575        let _ = fs::remove_dir_all(temp_home).await;
21576    }
21577
21578    #[test]
21579    async fn load_or_init_decrypts_feishu_channel_secrets() {
21580        let _env_guard = env_override_lock().await;
21581        let temp_home =
21582            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21583        let config_dir = temp_home.join(".zeroclaw");
21584        let config_path = config_dir.join("config.toml");
21585
21586        fs::create_dir_all(&config_dir).await.unwrap();
21587
21588        let original_home = std::env::var("HOME").ok();
21589        // SAFETY: test-only, single-threaded test runner.
21590        unsafe { std::env::set_var("HOME", &temp_home) };
21591        // SAFETY: test-only, single-threaded test runner.
21592        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21593
21594        let mut config = Config {
21595            config_path: config_path.clone(),
21596            data_dir: config_dir.join("workspace"),
21597            ..Default::default()
21598        };
21599        config.secrets.encrypt = true;
21600        config.channels.lark.insert(
21601            "feishu".to_string(),
21602            LarkConfig {
21603                enabled: true,
21604                app_id: "cli_feishu_123".into(),
21605                app_secret: "feishu-secret".into(),
21606                encrypt_key: Some("feishu-encrypt".into()),
21607                verification_token: Some("feishu-verify".into()),
21608                mention_only: false,
21609                use_feishu: true,
21610                receive_mode: LarkReceiveMode::Websocket,
21611                port: None,
21612                proxy_url: None,
21613                excluded_tools: vec![],
21614                approval_timeout_secs: 300,
21615                per_user_session: false,
21616                stream_mode: StreamMode::default(),
21617                draft_update_interval_ms: default_draft_update_interval_ms(),
21618            },
21619        );
21620        config.save().await.unwrap();
21621
21622        let loaded = Box::pin(Config::load_or_init()).await.unwrap();
21623        let feishu = loaded.channels.lark.get("feishu").unwrap();
21624        assert_eq!(feishu.app_secret, "feishu-secret");
21625        assert_eq!(feishu.encrypt_key.as_deref(), Some("feishu-encrypt"));
21626        assert_eq!(feishu.verification_token.as_deref(), Some("feishu-verify"));
21627
21628        if let Some(home) = original_home {
21629            // SAFETY: test-only, single-threaded test runner.
21630            unsafe { std::env::set_var("HOME", home) };
21631        } else {
21632            // SAFETY: test-only, single-threaded test runner.
21633            unsafe { std::env::remove_var("HOME") };
21634        }
21635        let _ = fs::remove_dir_all(temp_home).await;
21636    }
21637
21638    #[test]
21639    #[allow(clippy::large_futures)]
21640    async fn load_or_init_logs_existing_config_as_initialized() {
21641        let _env_guard = env_override_lock().await;
21642        let temp_home =
21643            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21644        let workspace_dir = temp_home.join("profile-a");
21645        let config_path = workspace_dir.join("config.toml");
21646
21647        fs::create_dir_all(&workspace_dir).await.unwrap();
21648        fs::write(
21649            &config_path,
21650            r#"default_temperature = 0.7
21651default_model = "persisted-profile"
21652"#,
21653        )
21654        .await
21655        .unwrap();
21656
21657        let original_home = std::env::var("HOME").ok();
21658        // SAFETY: test-only, single-threaded test runner.
21659        unsafe { std::env::set_var("HOME", &temp_home) };
21660        // SAFETY: test-only, single-threaded test runner.
21661        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21662
21663        let mut rx = capture_log_events();
21664
21665        let config = Box::pin(Config::load_or_init()).await.unwrap();
21666
21667        let logs = drain_captured(&mut rx);
21668
21669        // V3: shared databases live at `<install>/data/`, per-agent
21670        // identity at `<install>/agents/<alias>/workspace/`. The
21671        // ZEROCLAW_WORKSPACE env var (deprecated alias for
21672        // ZEROCLAW_DATA_DIR) pinned the install root, so data_dir is
21673        // `<install>/data/` derived from the resolved root.
21674        assert_eq!(config.data_dir, workspace_dir.join("data"));
21675        assert_eq!(config.config_path, config_path);
21676        assert_eq!(
21677            config
21678                .providers
21679                .models
21680                .find("openrouter", "default")
21681                .and_then(|e| e.model.as_deref()),
21682            Some("persisted-profile")
21683        );
21684        assert!(logs.contains("Config loaded"), "{logs}");
21685        assert!(logs.contains("\"initialized\":true"), "{logs}");
21686        assert!(!logs.contains("\"initialized\":false"), "{logs}");
21687
21688        // SAFETY: test-only, single-threaded test runner.
21689        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21690        if let Some(home) = original_home {
21691            // SAFETY: test-only, single-threaded test runner.
21692            unsafe { std::env::set_var("HOME", home) };
21693        } else {
21694            // SAFETY: test-only, single-threaded test runner.
21695            unsafe { std::env::remove_var("HOME") };
21696        }
21697        let _ = fs::remove_dir_all(temp_home).await;
21698    }
21699
21700    #[test]
21701    #[allow(clippy::large_futures)]
21702    async fn load_or_init_assigns_degraded_security_for_malformed_section() {
21703        let _env_guard = env_override_lock().await;
21704        let temp_home =
21705            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21706        let workspace_dir = temp_home.join("profile-a");
21707        let config_path = workspace_dir.join("config.toml");
21708
21709        fs::create_dir_all(&workspace_dir).await.unwrap();
21710        // `[security] audit` must be a table; a scalar forces the security
21711        // section to drop to its default on the resilient daemon path.
21712        fs::write(
21713            &config_path,
21714            r#"schema_version = 3
21715audit = "should-be-a-table-not-a-string"
21716
21717[security]
21718audit = "should-be-a-table-not-a-string"
21719"#,
21720        )
21721        .await
21722        .unwrap();
21723
21724        let original_home = std::env::var("HOME").ok();
21725        // SAFETY: test-only, single-threaded test runner.
21726        unsafe { std::env::set_var("HOME", &temp_home) };
21727        // SAFETY: test-only, single-threaded test runner.
21728        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21729
21730        let config = Box::pin(Config::load_or_init()).await.unwrap();
21731
21732        assert!(
21733            config.degraded_security.iter().any(|s| s == "security"),
21734            "load_or_init must surface a dropped [security] section on degraded_security, got {:?}",
21735            config.degraded_security
21736        );
21737
21738        // SAFETY: test-only, single-threaded test runner.
21739        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21740        if let Some(home) = original_home {
21741            // SAFETY: test-only, single-threaded test runner.
21742            unsafe { std::env::set_var("HOME", home) };
21743        } else {
21744            // SAFETY: test-only, single-threaded test runner.
21745            unsafe { std::env::remove_var("HOME") };
21746        }
21747        let _ = fs::remove_dir_all(temp_home).await;
21748    }
21749
21750    #[test]
21751    #[allow(clippy::large_futures)]
21752    async fn load_or_init_marks_whole_config_degraded_for_unparseable_file() {
21753        let _env_guard = env_override_lock().await;
21754        let temp_home =
21755            std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
21756        let workspace_dir = temp_home.join("profile-a");
21757        let config_path = workspace_dir.join("config.toml");
21758
21759        fs::create_dir_all(&workspace_dir).await.unwrap();
21760        // Not valid TOML at all: the whole config defaults, so every
21761        // security-critical section is lost at once. load_or_init must surface
21762        // that on degraded_security so the serving gate refuses to start.
21763        fs::write(&config_path, "this is not valid TOML {{{")
21764            .await
21765            .unwrap();
21766
21767        let original_home = std::env::var("HOME").ok();
21768        // SAFETY: test-only, single-threaded test runner.
21769        unsafe { std::env::set_var("HOME", &temp_home) };
21770        // SAFETY: test-only, single-threaded test runner.
21771        unsafe { std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir) };
21772
21773        let config = Box::pin(Config::load_or_init()).await.unwrap();
21774
21775        assert!(
21776            !config.degraded_security.is_empty(),
21777            "load_or_init must surface a whole-config loss on degraded_security, got {:?}",
21778            config.degraded_security
21779        );
21780
21781        // SAFETY: test-only, single-threaded test runner.
21782        unsafe { std::env::remove_var("ZEROCLAW_WORKSPACE") };
21783        if let Some(home) = original_home {
21784            // SAFETY: test-only, single-threaded test runner.
21785            unsafe { std::env::set_var("HOME", home) };
21786        } else {
21787            // SAFETY: test-only, single-threaded test runner.
21788            unsafe { std::env::remove_var("HOME") };
21789        }
21790        let _ = fs::remove_dir_all(temp_home).await;
21791    }
21792
21793    #[test]
21794    async fn validate_rejects_out_of_range_temperature() {
21795        let mut config = Config::default();
21796        config.providers.models.openrouter.insert(
21797            "default".to_string(),
21798            OpenRouterModelProviderConfig {
21799                base: ModelProviderConfig {
21800                    api_key: Some("sk-test".into()),
21801                    temperature: Some(99.0),
21802                    ..Default::default()
21803                },
21804            },
21805        );
21806        let err = config.validate().unwrap_err();
21807        assert!(
21808            err.to_string().contains("temperature"),
21809            "expected temperature validation error, got: {err}"
21810        );
21811    }
21812
21813    #[test]
21814    async fn validate_rejects_negative_temperature() {
21815        let mut config = Config::default();
21816        config.providers.models.openrouter.insert(
21817            "default".to_string(),
21818            OpenRouterModelProviderConfig {
21819                base: ModelProviderConfig {
21820                    api_key: Some("sk-test".into()),
21821                    temperature: Some(-0.5),
21822                    ..Default::default()
21823                },
21824            },
21825        );
21826        let err = config.validate().unwrap_err();
21827        assert!(
21828            err.to_string().contains("temperature"),
21829            "expected temperature validation error, got: {err}"
21830        );
21831    }
21832
21833    #[test]
21834    async fn validate_accepts_valid_temperature() {
21835        let mut config = Config::default();
21836        config.providers.models.openrouter.insert(
21837            "default".to_string(),
21838            OpenRouterModelProviderConfig {
21839                base: ModelProviderConfig {
21840                    temperature: Some(0.7),
21841                    ..Default::default()
21842                },
21843            },
21844        );
21845        assert!(config.validate().is_ok());
21846    }
21847
21848    #[test]
21849    async fn validate_rejects_unknown_jira_actions() {
21850        for action in ["delete_ticket", "drop_database", ""] {
21851            let mut config = Config::default();
21852            config.jira.enabled = true;
21853            config.jira.base_url = "https://jira.example.test".into();
21854            config.jira.api_token = "token".into();
21855            config.jira.allowed_actions = vec![action.into()];
21856
21857            let err = config
21858                .validate()
21859                .expect_err("unknown Jira action should be rejected")
21860                .to_string();
21861            assert!(
21862                err.contains("jira.allowed_actions contains unknown action"),
21863                "expected Jira allowed action error for {action:?}, got: {err}"
21864            );
21865        }
21866    }
21867
21868    #[test]
21869    async fn validate_accepts_all_published_jira_actions() {
21870        for action in [
21871            "get_ticket",
21872            "search_tickets",
21873            "comment_ticket",
21874            "list_projects",
21875            "myself",
21876            "list_transitions",
21877            "transition_ticket",
21878            "create_ticket",
21879        ] {
21880            let mut config = Config::default();
21881            config.jira.enabled = true;
21882            config.jira.base_url = "https://jira.example.test".into();
21883            config.jira.api_token = "token".into();
21884            config.jira.allowed_actions = vec![action.into()];
21885
21886            assert!(
21887                config.validate().is_ok(),
21888                "published Jira action {action:?} should validate"
21889            );
21890        }
21891    }
21892
21893    #[test]
21894    async fn jira_email_empty_string_deserializes_as_none() {
21895        // Legacy configs round-tripped `email = ""` to disk because the
21896        // pre-rename `email: String` lacked `skip_serializing_if`. The
21897        // current `Option<String>` would otherwise deserialize `""` as
21898        // `Some("")`, and JiraTool would attempt Basic auth with empty
21899        // username (the dropped email-required validation no longer
21900        // catches this). Defense-in-depth: empty strings deserialize as
21901        // None.
21902        let toml_input = r#"
21903enabled = true
21904base_url = "https://jira.example.test"
21905email = ""
21906api_token = "tok"
21907"#;
21908        let cfg: JiraConfig = toml::from_str(toml_input).expect("parses with empty email");
21909        assert!(
21910            cfg.email.is_none(),
21911            "empty `email = \"\"` must deserialize as None, got {:?}",
21912            cfg.email
21913        );
21914        // Whitespace-only is also normalized to None.
21915        let toml_input_ws = r#"
21916enabled = true
21917base_url = "https://jira.example.test"
21918email = "   "
21919api_token = "tok"
21920"#;
21921        let cfg_ws: JiraConfig =
21922            toml::from_str(toml_input_ws).expect("parses with whitespace email");
21923        assert!(
21924            cfg_ws.email.is_none(),
21925            "whitespace-only email must deserialize as None, got {:?}",
21926            cfg_ws.email
21927        );
21928        // A real email still survives.
21929        let toml_input_real = r#"
21930enabled = true
21931base_url = "https://jira.example.test"
21932email = "ops@example.com"
21933api_token = "tok"
21934"#;
21935        let cfg_real: JiraConfig = toml::from_str(toml_input_real).expect("parses with real email");
21936        assert_eq!(
21937            cfg_real.email.as_deref(),
21938            Some("ops@example.com"),
21939            "non-empty email must round-trip unchanged"
21940        );
21941    }
21942
21943    #[test]
21944    async fn proxy_config_scope_services_requires_entries_when_enabled() {
21945        let proxy = ProxyConfig {
21946            enabled: true,
21947            http_proxy: Some("http://127.0.0.1:7890".into()),
21948            https_proxy: None,
21949            all_proxy: None,
21950            no_proxy: Vec::new(),
21951            scope: ProxyScope::Services,
21952            services: Vec::new(),
21953        };
21954
21955        let error = proxy.validate().unwrap_err().to_string();
21956        assert!(error.contains("proxy.scope='services'"));
21957    }
21958
21959    #[test]
21960    async fn google_workspace_allowed_operations_require_methods() {
21961        let mut config = Config::default();
21962        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
21963            service: "gmail".into(),
21964            resource: "users".into(),
21965            sub_resource: Some("drafts".into()),
21966            methods: Vec::new(),
21967        }];
21968
21969        let err = config.validate().unwrap_err().to_string();
21970        assert!(err.contains("google_workspace.allowed_operations[0].methods"));
21971    }
21972
21973    #[test]
21974    async fn google_workspace_allowed_operations_reject_duplicate_service_resource_sub_resource_entries()
21975     {
21976        let mut config = Config::default();
21977        config.google_workspace.allowed_operations = vec![
21978            GoogleWorkspaceAllowedOperation {
21979                service: "gmail".into(),
21980                resource: "users".into(),
21981                sub_resource: Some("drafts".into()),
21982                methods: vec!["create".into()],
21983            },
21984            GoogleWorkspaceAllowedOperation {
21985                service: "gmail".into(),
21986                resource: "users".into(),
21987                sub_resource: Some("drafts".into()),
21988                methods: vec!["update".into()],
21989            },
21990        ];
21991
21992        let err = config.validate().unwrap_err().to_string();
21993        assert!(err.contains("duplicate service/resource/sub_resource entry"));
21994    }
21995
21996    #[test]
21997    async fn google_workspace_allowed_operations_allow_same_resource_different_sub_resource() {
21998        let mut config = Config::default();
21999        config.google_workspace.allowed_operations = vec![
22000            GoogleWorkspaceAllowedOperation {
22001                service: "gmail".into(),
22002                resource: "users".into(),
22003                sub_resource: Some("messages".into()),
22004                methods: vec!["list".into(), "get".into()],
22005            },
22006            GoogleWorkspaceAllowedOperation {
22007                service: "gmail".into(),
22008                resource: "users".into(),
22009                sub_resource: Some("drafts".into()),
22010                methods: vec!["create".into(), "update".into()],
22011            },
22012        ];
22013
22014        assert!(config.validate().is_ok());
22015    }
22016
22017    #[test]
22018    async fn google_workspace_allowed_operations_reject_duplicate_methods_within_entry() {
22019        let mut config = Config::default();
22020        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
22021            service: "gmail".into(),
22022            resource: "users".into(),
22023            sub_resource: Some("drafts".into()),
22024            methods: vec!["create".into(), "create".into()],
22025        }];
22026
22027        let err = config.validate().unwrap_err().to_string();
22028        assert!(
22029            err.contains("duplicate entry"),
22030            "expected duplicate entry error, got: {err}"
22031        );
22032    }
22033
22034    #[test]
22035    async fn google_workspace_allowed_operations_accept_valid_entries() {
22036        let mut config = Config::default();
22037        config.google_workspace.allowed_operations = vec![
22038            GoogleWorkspaceAllowedOperation {
22039                service: "gmail".into(),
22040                resource: "users".into(),
22041                sub_resource: Some("messages".into()),
22042                methods: vec!["list".into(), "get".into()],
22043            },
22044            GoogleWorkspaceAllowedOperation {
22045                service: "drive".into(),
22046                resource: "files".into(),
22047                sub_resource: None,
22048                methods: vec!["list".into(), "get".into()],
22049            },
22050        ];
22051
22052        assert!(config.validate().is_ok());
22053    }
22054
22055    #[test]
22056    async fn google_workspace_allowed_operations_reject_invalid_sub_resource_characters() {
22057        let mut config = Config::default();
22058        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
22059            service: "gmail".into(),
22060            resource: "users".into(),
22061            sub_resource: Some("bad resource!".into()),
22062            methods: vec!["list".into()],
22063        }];
22064
22065        let err = config.validate().unwrap_err().to_string();
22066        assert!(err.contains("sub_resource contains invalid characters"));
22067    }
22068
22069    fn runtime_proxy_cache_contains(cache_key: &str) -> bool {
22070        match runtime_proxy_client_cache().read() {
22071            Ok(guard) => guard.contains_key(cache_key),
22072            Err(poisoned) => poisoned.into_inner().contains_key(cache_key),
22073        }
22074    }
22075
22076    #[test]
22077    async fn runtime_proxy_client_cache_reuses_default_profile_key() {
22078        let service_key = format!(
22079            "model_provider.cache_test.{}",
22080            std::time::SystemTime::now()
22081                .duration_since(std::time::UNIX_EPOCH)
22082                .expect("system clock should be after unix epoch")
22083                .as_nanos()
22084        );
22085        let cache_key = runtime_proxy_cache_key(&service_key, None, None);
22086
22087        clear_runtime_proxy_client_cache();
22088        assert!(!runtime_proxy_cache_contains(&cache_key));
22089
22090        let _ = build_runtime_proxy_client(&service_key);
22091        assert!(runtime_proxy_cache_contains(&cache_key));
22092
22093        let _ = build_runtime_proxy_client(&service_key);
22094        assert!(runtime_proxy_cache_contains(&cache_key));
22095    }
22096
22097    #[test]
22098    async fn set_runtime_proxy_config_clears_runtime_proxy_client_cache() {
22099        let service_key = format!(
22100            "model_provider.cache_timeout_test.{}",
22101            std::time::SystemTime::now()
22102                .duration_since(std::time::UNIX_EPOCH)
22103                .expect("system clock should be after unix epoch")
22104                .as_nanos()
22105        );
22106        let cache_key = runtime_proxy_cache_key(&service_key, Some(30), Some(5));
22107
22108        clear_runtime_proxy_client_cache();
22109        let _ = build_runtime_proxy_client_with_timeouts(&service_key, 30, 5);
22110        assert!(runtime_proxy_cache_contains(&cache_key));
22111
22112        set_runtime_proxy_config(ProxyConfig::default());
22113        assert!(!runtime_proxy_cache_contains(&cache_key));
22114    }
22115
22116    #[test]
22117    async fn gateway_config_default_values() {
22118        let g = GatewayConfig::default();
22119        assert_eq!(g.port, 42617);
22120        assert_eq!(g.host, "127.0.0.1");
22121        assert!(g.require_pairing);
22122        assert!(!g.allow_public_bind);
22123        assert!(g.paired_tokens.is_empty());
22124        assert!(!g.trust_forwarded_headers);
22125        assert_eq!(g.rate_limit_max_keys, 10_000);
22126        assert_eq!(g.idempotency_max_keys, 10_000);
22127    }
22128
22129    // ── Peripherals config ───────────────────────────────────────
22130
22131    #[test]
22132    async fn peripherals_config_default_disabled() {
22133        let p = PeripheralsConfig::default();
22134        assert!(!p.enabled);
22135        assert!(p.boards.is_empty());
22136    }
22137
22138    #[test]
22139    async fn peripheral_board_config_defaults() {
22140        let b = PeripheralBoardConfig::default();
22141        assert!(b.board.is_empty());
22142        assert_eq!(b.transport, "serial");
22143        assert!(b.path.is_none());
22144        assert_eq!(b.baud, 115_200);
22145    }
22146
22147    #[test]
22148    async fn peripherals_config_toml_roundtrip() {
22149        let p = PeripheralsConfig {
22150            enabled: true,
22151            boards: vec![PeripheralBoardConfig {
22152                board: "nucleo-f401re".into(),
22153                transport: "serial".into(),
22154                path: Some("/dev/ttyACM0".into()),
22155                baud: 115_200,
22156            }],
22157            datasheet_dir: None,
22158        };
22159        let toml_str = toml::to_string(&p).unwrap();
22160        let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
22161        assert!(parsed.enabled);
22162        assert_eq!(parsed.boards.len(), 1);
22163        assert_eq!(parsed.boards[0].board, "nucleo-f401re");
22164        assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
22165    }
22166
22167    #[test]
22168    async fn lark_config_serde() {
22169        let lc = LarkConfig {
22170            enabled: true,
22171            app_id: "cli_123456".into(),
22172            app_secret: "secret_abc".into(),
22173            encrypt_key: Some("encrypt_key".into()),
22174            verification_token: Some("verify_token".into()),
22175            mention_only: false,
22176            use_feishu: true,
22177            receive_mode: LarkReceiveMode::Websocket,
22178            port: None,
22179            proxy_url: None,
22180            excluded_tools: vec![],
22181            approval_timeout_secs: 300,
22182            per_user_session: false,
22183            stream_mode: StreamMode::default(),
22184            draft_update_interval_ms: default_draft_update_interval_ms(),
22185        };
22186        let json = serde_json::to_string(&lc).unwrap();
22187        let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
22188        assert_eq!(parsed.app_id, "cli_123456");
22189        assert_eq!(parsed.app_secret, "secret_abc");
22190        assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
22191        assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
22192        assert!(parsed.use_feishu);
22193    }
22194
22195    #[test]
22196    async fn lark_config_toml_roundtrip() {
22197        let lc = LarkConfig {
22198            enabled: true,
22199            app_id: "cli_123456".into(),
22200            app_secret: "secret_abc".into(),
22201            encrypt_key: Some("encrypt_key".into()),
22202            verification_token: Some("verify_token".into()),
22203            mention_only: false,
22204            use_feishu: false,
22205            receive_mode: LarkReceiveMode::Webhook,
22206            port: Some(9898),
22207            proxy_url: None,
22208            excluded_tools: vec![],
22209            approval_timeout_secs: 300,
22210            per_user_session: false,
22211            stream_mode: StreamMode::default(),
22212            draft_update_interval_ms: default_draft_update_interval_ms(),
22213        };
22214        let toml_str = toml::to_string(&lc).unwrap();
22215        let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
22216        assert_eq!(parsed.app_id, "cli_123456");
22217        assert_eq!(parsed.app_secret, "secret_abc");
22218        assert!(!parsed.use_feishu);
22219    }
22220
22221    #[test]
22222    async fn lark_config_deserializes_without_optional_fields() {
22223        let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
22224        let parsed: LarkConfig = serde_json::from_str(json).unwrap();
22225        assert!(parsed.encrypt_key.is_none());
22226        assert!(parsed.verification_token.is_none());
22227        assert!(!parsed.mention_only);
22228        assert!(!parsed.use_feishu);
22229    }
22230
22231    #[test]
22232    async fn lark_config_defaults_to_lark_endpoint() {
22233        let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
22234        let parsed: LarkConfig = serde_json::from_str(json).unwrap();
22235        assert!(
22236            !parsed.use_feishu,
22237            "use_feishu should default to false (Lark)"
22238        );
22239    }
22240
22241    #[test]
22242    async fn lark_v2_allowed_users_fold_into_peer_groups() {
22243        // V2 `allowed_users` on a Lark channel migrates to a synthesized
22244        // `peer_groups.lark_default` group. The wildcard `*` is dropped at
22245        // synthesis (operator-explicit lists only); concrete user IDs
22246        // round-trip through.
22247        let raw = r#"
22248schema_version = 2
22249
22250[channels.lark]
22251enabled = true
22252app_id = "cli_123"
22253app_secret = "secret"
22254allowed_users = ["user_alpha", "user_beta"]
22255"#;
22256        let parsed = crate::migration::migrate_to_current(raw).expect("migration succeeds");
22257        let group = parsed
22258            .peer_groups
22259            .get("lark_default")
22260            .expect("V2 lark.allowed_users must fold into peer_groups.lark_default");
22261        assert_eq!(group.channel, "lark");
22262        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
22263        assert_eq!(usernames, vec!["user_alpha", "user_beta"]);
22264    }
22265
22266    // ── LINE ──────────────────────────────────────────────────
22267
22268    #[test]
22269    async fn line_config_toml_roundtrip() {
22270        // Full [channels.line] TOML block — covers every user-facing field.
22271        //
22272        // channel_access_token and channel_secret can be omitted here and
22273        // supplied via LINE_CHANNEL_ACCESS_TOKEN / LINE_CHANNEL_SECRET env vars
22274        // instead; both fields default to "" when absent.
22275        let toml = r#"
22276[channels_config.line.default]
22277enabled = true
22278channel_access_token = "ChannelAccessToken=="
22279channel_secret = "abc123secret"
22280dm_policy = "pairing"
22281group_policy = "mention"
22282allowed_users = []
22283webhook_port = 8443
22284"#;
22285        let config: Config = toml::from_str(toml).unwrap();
22286        let ln = config.channels.line.get("default").unwrap();
22287        assert_eq!(ln.channel_access_token, "ChannelAccessToken==");
22288        assert_eq!(ln.channel_secret, "abc123secret");
22289        assert_eq!(ln.dm_policy, LineDmPolicy::Pairing);
22290        assert_eq!(ln.group_policy, LineGroupPolicy::Mention);
22291        assert_eq!(ln.webhook_port, 8443);
22292        assert!(ln.proxy_url.is_none());
22293    }
22294
22295    #[test]
22296    async fn line_config_defaults() {
22297        // Minimal config — only the required secret fields are provided.
22298        // All optional fields should resolve to documented defaults.
22299        let toml = r#"
22300[channels_config.line.default]
22301channel_access_token = "tok"
22302channel_secret = "sec"
22303"#;
22304        let config: Config = toml::from_str(toml).unwrap();
22305        let ln = config.channels.line.get("default").unwrap();
22306        assert_eq!(
22307            ln.dm_policy,
22308            LineDmPolicy::Pairing,
22309            "dm_policy default is pairing"
22310        );
22311        assert_eq!(
22312            ln.group_policy,
22313            LineGroupPolicy::Mention,
22314            "group_policy default is mention"
22315        );
22316        assert_eq!(ln.webhook_port, 8443, "webhook_port default is 8443");
22317        assert!(ln.proxy_url.is_none());
22318    }
22319
22320    #[test]
22321    async fn line_config_allowlist_policy() {
22322        // dm_policy = allowlist; the user ID list itself now lives on the
22323        // V3 `peer_groups.line_default` group (synthesized from V2's
22324        // `allowed_users`), not on the LineConfig struct.
22325        let toml = r#"
22326schema_version = 2
22327
22328[channels.line]
22329enabled = true
22330channel_access_token = "tok"
22331channel_secret = "sec"
22332dm_policy = "allowlist"
22333allowed_users = ["Uabc123", "Udef456"]
22334"#;
22335        let config = crate::migration::migrate_to_current(toml).expect("migration succeeds");
22336        let ln = config.channels.line.get("default").unwrap();
22337        assert_eq!(ln.dm_policy, LineDmPolicy::Allowlist);
22338        let group = config
22339            .peer_groups
22340            .get("line_default")
22341            .expect("V2 line.allowed_users must fold into peer_groups.line_default");
22342        let usernames: Vec<&str> = group.external_peers.iter().map(|p| p.as_str()).collect();
22343        assert_eq!(usernames, vec!["Uabc123", "Udef456"]);
22344    }
22345
22346    #[test]
22347    async fn line_config_open_policies() {
22348        // dm_policy = open + group_policy = open — most permissive combination.
22349        let toml = r#"
22350[channels_config.line.default]
22351channel_access_token = "tok"
22352channel_secret = "sec"
22353dm_policy = "open"
22354group_policy = "open"
22355"#;
22356        let config: Config = toml::from_str(toml).unwrap();
22357        let ln = config.channels.line.get("default").unwrap();
22358        assert_eq!(ln.dm_policy, LineDmPolicy::Open);
22359        assert_eq!(ln.group_policy, LineGroupPolicy::Open);
22360    }
22361
22362    #[test]
22363    async fn line_config_group_disabled() {
22364        // group_policy = disabled — bot ignores all group/room messages.
22365        let toml = r#"
22366[channels_config.line.default]
22367channel_access_token = "tok"
22368channel_secret = "sec"
22369group_policy = "disabled"
22370"#;
22371        let config: Config = toml::from_str(toml).unwrap();
22372        let ln = config.channels.line.get("default").unwrap();
22373        assert_eq!(ln.group_policy, LineGroupPolicy::Disabled);
22374    }
22375
22376    #[test]
22377    async fn nextcloud_talk_config_serde() {
22378        let nc = NextcloudTalkConfig {
22379            enabled: true,
22380            base_url: "https://cloud.example.com".into(),
22381            app_token: "app-token".into(),
22382            webhook_secret: Some("webhook-secret".into()),
22383            proxy_url: None,
22384            bot_name: None,
22385            excluded_tools: vec![],
22386            stream_mode: StreamMode::default(),
22387            draft_update_interval_ms: 1000,
22388        };
22389
22390        let json = serde_json::to_string(&nc).unwrap();
22391        let parsed: NextcloudTalkConfig = serde_json::from_str(&json).unwrap();
22392        assert_eq!(parsed.base_url, "https://cloud.example.com");
22393        assert_eq!(parsed.app_token, "app-token");
22394        assert_eq!(parsed.webhook_secret.as_deref(), Some("webhook-secret"));
22395    }
22396
22397    #[test]
22398    async fn nextcloud_talk_config_defaults_optional_fields() {
22399        let json = r#"{"base_url":"https://cloud.example.com","app_token":"app-token"}"#;
22400        let parsed: NextcloudTalkConfig = serde_json::from_str(json).unwrap();
22401        assert!(parsed.webhook_secret.is_none());
22402    }
22403
22404    // ── Config file permission hardening (Unix only) ───────────────
22405
22406    #[cfg(unix)]
22407    #[test]
22408    async fn new_config_file_has_restricted_permissions() {
22409        let tmp = tempfile::TempDir::new().unwrap();
22410        let config_path = tmp.path().join("config.toml");
22411
22412        // Create a config and save it
22413        let config = Config {
22414            config_path: config_path.clone(),
22415            ..Default::default()
22416        };
22417        config.save().await.unwrap();
22418
22419        let meta = fs::metadata(&config_path).await.unwrap();
22420        let mode = meta.permissions().mode() & 0o777;
22421        assert_eq!(
22422            mode, 0o600,
22423            "New config file should be owner-only (0600), got {mode:o}"
22424        );
22425    }
22426
22427    #[cfg(unix)]
22428    #[test]
22429    async fn save_restricts_existing_world_readable_config_to_owner_only() {
22430        let tmp = tempfile::TempDir::new().unwrap();
22431        let config_path = tmp.path().join("config.toml");
22432
22433        let mut config = Config {
22434            config_path: config_path.clone(),
22435            ..Default::default()
22436        };
22437        config.save().await.unwrap();
22438
22439        // Simulate the regression state observed in issue #1345.
22440        std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
22441        let loose_mode = std::fs::metadata(&config_path)
22442            .unwrap()
22443            .permissions()
22444            .mode()
22445            & 0o777;
22446        assert_eq!(
22447            loose_mode, 0o644,
22448            "test setup requires world-readable config"
22449        );
22450
22451        if let Some(entry) = config.providers.models.ensure("openrouter", "default") {
22452            entry.temperature = Some(0.6);
22453        }
22454        config.save().await.unwrap();
22455
22456        let hardened_mode = std::fs::metadata(&config_path)
22457            .unwrap()
22458            .permissions()
22459            .mode()
22460            & 0o777;
22461        assert_eq!(
22462            hardened_mode, 0o600,
22463            "Saving config should restore owner-only permissions (0600)"
22464        );
22465    }
22466
22467    #[test]
22468    async fn save_dirty_stamps_current_schema_version_on_stale_label() {
22469        // Regression for #7271. An incremental save writes current-schema-shaped
22470        // sections, but `schema_version` is never a dirty path. Without an
22471        // explicit stamp, a file first written by an older binary keeps its
22472        // stale `schema_version` label while gaining a current-schema body — a
22473        // state that crashes older binaries with `missing field ...`.
22474        let tmp = tempfile::TempDir::new().unwrap();
22475        let config_path = tmp.path().join("config.toml");
22476
22477        // Seed an on-disk file labeled with a stale schema version so the
22478        // incremental path (not the new-file fallback to full `save`) runs.
22479        std::fs::write(
22480            &config_path,
22481            "schema_version = 2\n\n[observability]\nbackend = \"none\"\n",
22482        )
22483        .unwrap();
22484
22485        let mut config = Config {
22486            config_path: config_path.clone(),
22487            ..Default::default()
22488        };
22489        config.observability.backend = "otel".to_string();
22490        config.mark_dirty("observability.backend");
22491        config.save_dirty().await.unwrap();
22492
22493        let written = std::fs::read_to_string(&config_path).unwrap();
22494        assert!(
22495            written.contains(&format!(
22496                "schema_version = {}",
22497                crate::migration::CURRENT_SCHEMA_VERSION
22498            )),
22499            "save_dirty must stamp the current schema_version; got:\n{written}"
22500        );
22501        assert!(
22502            !written.contains("schema_version = 2"),
22503            "stale schema_version label must be overwritten; got:\n{written}"
22504        );
22505        // The dirty value still lands, and the stamp sits at the top of the file.
22506        assert!(
22507            written.contains("backend = \"otel\""),
22508            "dirty value must still be written; got:\n{written}"
22509        );
22510        assert!(
22511            written.trim_start().starts_with("schema_version ="),
22512            "schema_version should remain the first key; got:\n{written}"
22513        );
22514    }
22515
22516    #[test]
22517    async fn collect_warnings_flags_wire_api_on_fixed_protocol_family() {
22518        let mut config = Config::default();
22519        // mistral has a fixed wire protocol and ignores wire_api.
22520        config
22521            .providers
22522            .models
22523            .ensure("mistral", "primary")
22524            .unwrap()
22525            .wire_api = Some(WireApi::Responses);
22526        // custom honors wire_api — must NOT warn.
22527        config
22528            .providers
22529            .models
22530            .ensure("custom", "vllm")
22531            .unwrap()
22532            .wire_api = Some(WireApi::Responses);
22533
22534        let warnings = config.collect_warnings();
22535        assert_eq!(warnings.len(), 1, "exactly the mistral entry should warn");
22536        let w = &warnings[0];
22537        assert_eq!(w.code, "wire_api_not_supported_for_family");
22538        assert_eq!(w.path, "providers.models.mistral.primary.wire_api");
22539        assert!(
22540            !warnings.iter().any(|w| w.path.contains("custom.vllm")),
22541            "custom honors wire_api and must not warn",
22542        );
22543    }
22544
22545    #[cfg(unix)]
22546    #[test]
22547    async fn world_readable_config_is_detectable() {
22548        use std::os::unix::fs::PermissionsExt;
22549
22550        let tmp = tempfile::TempDir::new().unwrap();
22551        let config_path = tmp.path().join("config.toml");
22552
22553        // Create a config file with intentionally loose permissions
22554        std::fs::write(&config_path, "# test config").unwrap();
22555        std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
22556
22557        let meta = std::fs::metadata(&config_path).unwrap();
22558        let mode = meta.permissions().mode();
22559        assert!(
22560            mode & 0o004 != 0,
22561            "Test setup: file should be world-readable (mode {mode:o})"
22562        );
22563    }
22564
22565    #[test]
22566    async fn transcription_config_defaults() {
22567        let tc = TranscriptionConfig::default();
22568        assert!(!tc.enabled);
22569        assert!(tc.api_url.contains("groq.com"));
22570        assert_eq!(tc.model, "whisper-large-v3-turbo");
22571        assert!(tc.language.is_none());
22572        assert!(tc.max_audio_bytes.is_none());
22573        assert_eq!(tc.max_duration_secs, 120);
22574        assert!(!tc.transcribe_non_ptt_audio);
22575    }
22576
22577    #[test]
22578    async fn config_roundtrip_with_transcription() {
22579        let mut config = Config::default();
22580        config.transcription.enabled = true;
22581        config.transcription.language = Some("en".into());
22582
22583        let toml_str = toml::to_string_pretty(&config).unwrap();
22584        let parsed = parse_test_config(&toml_str);
22585
22586        assert!(parsed.transcription.enabled);
22587        assert_eq!(parsed.transcription.language.as_deref(), Some("en"));
22588        assert_eq!(parsed.transcription.model, "whisper-large-v3-turbo");
22589    }
22590
22591    #[test]
22592    async fn config_roundtrip_with_transcription_max_audio_bytes() {
22593        let mut config = Config::default();
22594        config.transcription.max_audio_bytes = Some(65_536);
22595
22596        let toml_str = toml::to_string_pretty(&config).unwrap();
22597        let parsed = parse_test_config(&toml_str);
22598
22599        assert_eq!(parsed.transcription.max_audio_bytes, Some(65_536));
22600    }
22601
22602    #[test]
22603    async fn transcription_max_audio_bytes_round_trips_through_prop_path() {
22604        let mut config = Config::default();
22605
22606        assert_eq!(
22607            config
22608                .get_prop("transcription.max_audio_bytes")
22609                .unwrap()
22610                .as_str(),
22611            "<unset>"
22612        );
22613
22614        config
22615            .set_prop("transcription.max_audio_bytes", "65536")
22616            .unwrap();
22617        assert_eq!(config.transcription.max_audio_bytes, Some(65_536));
22618        assert_eq!(
22619            config.get_prop("transcription.max_audio_bytes").unwrap(),
22620            "65536"
22621        );
22622
22623        config
22624            .set_prop("transcription.max_audio_bytes", "")
22625            .unwrap();
22626        assert!(config.transcription.max_audio_bytes.is_none());
22627        assert_eq!(
22628            config.get_prop("transcription.max_audio_bytes").unwrap(),
22629            "<unset>"
22630        );
22631    }
22632
22633    #[test]
22634    async fn config_validate_rejects_zero_transcription_max_audio_bytes() {
22635        let mut config = Config::default();
22636        config.transcription.max_audio_bytes = Some(0);
22637
22638        let err = config.validate().unwrap_err();
22639        assert!(
22640            err.to_string()
22641                .contains("transcription.max_audio_bytes must be greater than zero"),
22642            "got: {err}"
22643        );
22644    }
22645
22646    #[test]
22647    async fn config_without_transcription_uses_defaults() {
22648        let toml_str = r#"
22649            default_model_provider = "openrouter"
22650            default_model = "test-model"
22651            default_temperature = 0.7
22652        "#;
22653        let parsed = parse_test_config(toml_str);
22654        assert!(!parsed.transcription.enabled);
22655        assert_eq!(parsed.transcription.max_duration_secs, 120);
22656    }
22657
22658    #[test]
22659    async fn security_defaults_are_backward_compatible() {
22660        let parsed = parse_test_config(
22661            r#"
22662default_model_provider = "openrouter"
22663default_model = "anthropic/claude-sonnet-4.6"
22664default_temperature = 0.7
22665"#,
22666        );
22667
22668        assert!(!parsed.security.otp.enabled);
22669        assert_eq!(parsed.security.otp.method, OtpMethod::Totp);
22670        assert!(!parsed.security.estop.enabled);
22671        assert!(parsed.security.estop.require_otp_to_resume);
22672    }
22673
22674    #[test]
22675    async fn security_toml_parses_otp_and_estop_sections() {
22676        let parsed = parse_test_config(
22677            r#"
22678default_model_provider = "openrouter"
22679default_model = "anthropic/claude-sonnet-4.6"
22680default_temperature = 0.7
22681
22682[security.otp]
22683enabled = true
22684method = "totp"
22685token_ttl_secs = 30
22686cache_valid_secs = 120
22687gated_actions = ["shell", "browser_open"]
22688gated_domains = ["*.chase.com", "accounts.google.com"]
22689gated_domain_categories = ["banking"]
22690
22691[security.estop]
22692enabled = true
22693state_file = "~/.zeroclaw/estop-state.json"
22694require_otp_to_resume = true
22695"#,
22696        );
22697
22698        assert!(parsed.security.otp.enabled);
22699        assert!(parsed.security.estop.enabled);
22700        assert_eq!(parsed.security.otp.gated_actions.len(), 2);
22701        assert_eq!(parsed.security.otp.gated_domains.len(), 2);
22702        parsed.validate().unwrap();
22703    }
22704
22705    #[test]
22706    async fn security_validation_rejects_invalid_domain_glob() {
22707        let mut config = Config::default();
22708        config.security.otp.gated_domains = vec!["bad domain.com".into()];
22709
22710        let err = config.validate().expect_err("expected invalid domain glob");
22711        assert!(err.to_string().contains("gated_domains"));
22712    }
22713
22714    #[test]
22715    async fn security_validation_accepts_all_default_gated_actions_without_warning() {
22716        let mut config = Config::default();
22717        config.security.otp.gated_actions = default_otp_gated_actions();
22718
22719        config
22720            .validate()
22721            .expect("the canonical default gated actions must validate clean");
22722    }
22723
22724    #[test]
22725    async fn security_validation_accepts_unknown_gated_action_but_does_not_bail() {
22726        // An unknown but well-formed action name is a silent no-op today
22727        // (OTP enforcement of gated_actions is not wired through). Config load
22728        // must not fail on it — the operator's whole config would be rejected
22729        // for a typo'd gate — but the runtime emits a WARN so the no-op is not
22730        // silent. This asserts the warn-and-continue contract: load succeeds.
22731        let mut config = Config::default();
22732        config.security.otp.gated_actions = vec!["kubectl_write".into()];
22733
22734        config
22735            .validate()
22736            .expect("an unknown gated action must warn, not reject the config");
22737    }
22738
22739    #[test]
22740    async fn security_validation_still_rejects_malformed_gated_action() {
22741        // The unknown-name warn must not weaken the existing hard checks from
22742        // the charset/empty validation: a name with invalid characters still
22743        // bails rather than degrading to a warn.
22744        let mut config = Config::default();
22745        config.security.otp.gated_actions = vec!["kubectl write".into()];
22746
22747        let err = config
22748            .validate()
22749            .expect_err("malformed gated action must still be rejected");
22750        assert!(err.to_string().contains("gated_actions"));
22751    }
22752
22753    // The two `validate_*_transcription_default_provider` tests were removed
22754    // alongside the deleted `TranscriptionConfig.default_transcription_provider`
22755    // field in #6273. there is no global default-provider concept; the equivalent
22756    // dangling-reference enforcement now lives on the per-agent
22757    // `agent.transcription_provider` field (see
22758    // `Config::validate()` checks for `tts_provider` / `transcription_provider`).
22759
22760    #[tokio::test]
22761    async fn channel_secret_telegram_bot_token_roundtrip() {
22762        let dir = std::env::temp_dir().join(format!(
22763            "zeroclaw_test_tg_bot_token_{}",
22764            uuid::Uuid::new_v4()
22765        ));
22766        fs::create_dir_all(&dir).await.unwrap();
22767
22768        let plaintext_token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";
22769
22770        let mut config = Config {
22771            data_dir: dir.join("workspace"),
22772            config_path: dir.join("config.toml"),
22773            ..Default::default()
22774        };
22775        config.channels.telegram.insert(
22776            "default".to_string(),
22777            TelegramConfig {
22778                enabled: true,
22779                bot_token: plaintext_token.into(),
22780                stream_mode: StreamMode::default(),
22781                draft_update_interval_ms: default_draft_update_interval_ms(),
22782                interrupt_on_new_message: false,
22783                mention_only: false,
22784                ack_reactions: None,
22785                proxy_url: None,
22786                approval_timeout_secs: default_telegram_approval_timeout_secs(),
22787                excluded_tools: vec![],
22788                reply_min_interval_secs: 0,
22789                reply_queue_depth_max: 0,
22790            },
22791        );
22792
22793        // Save (triggers encryption)
22794        config.save().await.unwrap();
22795
22796        // Read raw TOML and verify plaintext token is NOT present
22797        let raw_toml = tokio::fs::read_to_string(&config.config_path)
22798            .await
22799            .unwrap();
22800        assert!(
22801            !raw_toml.contains(plaintext_token),
22802            "Saved TOML must not contain the plaintext bot_token"
22803        );
22804
22805        // Parse stored TOML and verify the value is encrypted
22806        let stored: Config = toml::from_str(&raw_toml).unwrap();
22807        let stored_token = &stored.channels.telegram.get("default").unwrap().bot_token;
22808        assert!(
22809            crate::secrets::SecretStore::is_encrypted(stored_token),
22810            "Stored bot_token must be marked as encrypted"
22811        );
22812
22813        // Decrypt and verify it matches the original plaintext
22814        let store = crate::secrets::SecretStore::new(&dir, true);
22815        assert_eq!(store.decrypt(stored_token).unwrap(), plaintext_token);
22816
22817        // Simulate a full load: deserialize then decrypt (mirrors load_or_init logic)
22818        let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
22819        loaded.config_path = dir.join("config.toml");
22820        let load_store = crate::secrets::SecretStore::new(&dir, loaded.secrets.encrypt);
22821        loaded.decrypt_secrets(&load_store).unwrap();
22822        assert_eq!(
22823            loaded.channels.telegram.get("default").unwrap().bot_token,
22824            plaintext_token,
22825            "Loaded bot_token must match the original plaintext after decryption"
22826        );
22827
22828        let _ = fs::remove_dir_all(&dir).await;
22829    }
22830
22831    #[test]
22832    async fn security_validation_rejects_unknown_domain_category() {
22833        let mut config = Config::default();
22834        config.security.otp.gated_domain_categories = vec!["not_real".into()];
22835
22836        let err = config
22837            .validate()
22838            .expect_err("expected unknown domain category");
22839        assert!(err.to_string().contains("gated_domain_categories"));
22840    }
22841
22842    #[test]
22843    async fn security_validation_rejects_zero_token_ttl() {
22844        let mut config = Config::default();
22845        config.security.otp.token_ttl_secs = 0;
22846
22847        let err = config
22848            .validate()
22849            .expect_err("expected ttl validation failure");
22850        assert!(err.to_string().contains("token_ttl_secs"));
22851    }
22852
22853    // ── MCP config validation ─────────────────────────────────────────────
22854
22855    fn stdio_server(name: &str, command: &str) -> McpServerConfig {
22856        McpServerConfig {
22857            name: name.to_string(),
22858            transport: McpTransport::Stdio,
22859            command: command.to_string(),
22860            ..Default::default()
22861        }
22862    }
22863
22864    fn http_server(name: &str, url: &str) -> McpServerConfig {
22865        McpServerConfig {
22866            name: name.to_string(),
22867            transport: McpTransport::Http,
22868            url: Some(url.to_string()),
22869            ..Default::default()
22870        }
22871    }
22872
22873    fn sse_server(name: &str, url: &str) -> McpServerConfig {
22874        McpServerConfig {
22875            name: name.to_string(),
22876            transport: McpTransport::Sse,
22877            url: Some(url.to_string()),
22878            ..Default::default()
22879        }
22880    }
22881
22882    #[test]
22883    async fn validate_mcp_config_empty_servers_ok() {
22884        let cfg = McpConfig::default();
22885        assert!(validate_mcp_config(&cfg).is_ok());
22886    }
22887
22888    #[test]
22889    async fn validate_mcp_config_valid_stdio_ok() {
22890        let cfg = McpConfig {
22891            enabled: true,
22892            servers: vec![stdio_server("fs", "/usr/bin/mcp-fs")],
22893            ..Default::default()
22894        };
22895        assert!(validate_mcp_config(&cfg).is_ok());
22896    }
22897
22898    #[test]
22899    async fn validate_mcp_config_valid_http_ok() {
22900        let cfg = McpConfig {
22901            enabled: true,
22902            servers: vec![http_server("svc", "http://localhost:8080/mcp")],
22903            ..Default::default()
22904        };
22905        assert!(validate_mcp_config(&cfg).is_ok());
22906    }
22907
22908    #[test]
22909    async fn validate_mcp_config_valid_sse_ok() {
22910        let cfg = McpConfig {
22911            enabled: true,
22912            servers: vec![sse_server("svc", "https://example.com/events")],
22913            ..Default::default()
22914        };
22915        assert!(validate_mcp_config(&cfg).is_ok());
22916    }
22917
22918    #[test]
22919    async fn validate_mcp_config_rejects_empty_name() {
22920        let cfg = McpConfig {
22921            enabled: true,
22922            servers: vec![stdio_server("", "/usr/bin/tool")],
22923            ..Default::default()
22924        };
22925        let err = validate_mcp_config(&cfg).expect_err("empty name should fail");
22926        assert!(
22927            err.to_string().contains("name must not be empty"),
22928            "got: {err}"
22929        );
22930    }
22931
22932    #[test]
22933    async fn validate_mcp_config_rejects_whitespace_name() {
22934        let cfg = McpConfig {
22935            enabled: true,
22936            servers: vec![stdio_server("   ", "/usr/bin/tool")],
22937            ..Default::default()
22938        };
22939        let err = validate_mcp_config(&cfg).expect_err("whitespace name should fail");
22940        assert!(
22941            err.to_string().contains("name must not be empty"),
22942            "got: {err}"
22943        );
22944    }
22945
22946    #[test]
22947    async fn validate_mcp_config_rejects_duplicate_names() {
22948        let cfg = McpConfig {
22949            enabled: true,
22950            servers: vec![
22951                stdio_server("fs", "/usr/bin/mcp-a"),
22952                stdio_server("fs", "/usr/bin/mcp-b"),
22953            ],
22954            ..Default::default()
22955        };
22956        let err = validate_mcp_config(&cfg).expect_err("duplicate name should fail");
22957        assert!(err.to_string().contains("duplicate name"), "got: {err}");
22958    }
22959
22960    #[test]
22961    async fn validate_mcp_config_rejects_zero_timeout() {
22962        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
22963        server.tool_timeout_secs = Some(0);
22964        let cfg = McpConfig {
22965            enabled: true,
22966            servers: vec![server],
22967            ..Default::default()
22968        };
22969        let err = validate_mcp_config(&cfg).expect_err("zero timeout should fail");
22970        assert!(err.to_string().contains("greater than 0"), "got: {err}");
22971    }
22972
22973    #[test]
22974    async fn validate_mcp_config_rejects_timeout_exceeding_max() {
22975        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
22976        server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS + 1);
22977        let cfg = McpConfig {
22978            enabled: true,
22979            servers: vec![server],
22980            ..Default::default()
22981        };
22982        let err = validate_mcp_config(&cfg).expect_err("oversized timeout should fail");
22983        assert!(err.to_string().contains("exceeds max"), "got: {err}");
22984    }
22985
22986    #[test]
22987    async fn validate_mcp_config_allows_max_timeout_exactly() {
22988        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
22989        server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS);
22990        let cfg = McpConfig {
22991            enabled: true,
22992            servers: vec![server],
22993            ..Default::default()
22994        };
22995        assert!(validate_mcp_config(&cfg).is_ok());
22996    }
22997
22998    #[test]
22999    async fn validate_mcp_config_rejects_stdio_with_empty_command() {
23000        let cfg = McpConfig {
23001            enabled: true,
23002            servers: vec![stdio_server("fs", "")],
23003            ..Default::default()
23004        };
23005        let err = validate_mcp_config(&cfg).expect_err("empty command should fail");
23006        assert!(
23007            err.to_string().contains("requires non-empty command"),
23008            "got: {err}"
23009        );
23010    }
23011
23012    #[test]
23013    async fn validate_mcp_config_rejects_http_without_url() {
23014        let cfg = McpConfig {
23015            enabled: true,
23016            servers: vec![McpServerConfig {
23017                name: "svc".to_string(),
23018                transport: McpTransport::Http,
23019                url: None,
23020                ..Default::default()
23021            }],
23022            ..Default::default()
23023        };
23024        let err = validate_mcp_config(&cfg).expect_err("http without url should fail");
23025        assert!(err.to_string().contains("requires url"), "got: {err}");
23026    }
23027
23028    #[test]
23029    async fn validate_mcp_config_rejects_sse_without_url() {
23030        let cfg = McpConfig {
23031            enabled: true,
23032            servers: vec![McpServerConfig {
23033                name: "svc".to_string(),
23034                transport: McpTransport::Sse,
23035                url: None,
23036                ..Default::default()
23037            }],
23038            ..Default::default()
23039        };
23040        let err = validate_mcp_config(&cfg).expect_err("sse without url should fail");
23041        assert!(err.to_string().contains("requires url"), "got: {err}");
23042    }
23043
23044    #[test]
23045    async fn validate_mcp_config_rejects_non_http_scheme() {
23046        let cfg = McpConfig {
23047            enabled: true,
23048            servers: vec![http_server("svc", "ftp://example.com/mcp")],
23049            ..Default::default()
23050        };
23051        let err = validate_mcp_config(&cfg).expect_err("non-http scheme should fail");
23052        assert!(err.to_string().contains("http/https"), "got: {err}");
23053    }
23054
23055    #[test]
23056    async fn validate_mcp_config_rejects_invalid_url() {
23057        let cfg = McpConfig {
23058            enabled: true,
23059            servers: vec![http_server("svc", "not a url at all !!!")],
23060            ..Default::default()
23061        };
23062        let err = validate_mcp_config(&cfg).expect_err("invalid url should fail");
23063        assert!(err.to_string().contains("valid URL"), "got: {err}");
23064    }
23065
23066    #[test]
23067    async fn mcp_config_defaults_enabled_eager_loading_with_empty_servers() {
23068        let cfg = McpConfig::default();
23069        assert!(cfg.enabled);
23070        assert!(!cfg.deferred_loading);
23071        assert!(cfg.servers.is_empty());
23072    }
23073
23074    #[test]
23075    async fn mcp_config_parsed_missing_flags_uses_enabled_eager_defaults() {
23076        let raw = r#"
23077[mcp]
23078
23079[[mcp.servers]]
23080name = "svc"
23081transport = "http"
23082url = "http://localhost:8080/mcp"
23083"#;
23084        let parsed = parse_test_config(raw);
23085        assert!(parsed.mcp.enabled);
23086        assert!(!parsed.mcp.deferred_loading);
23087        assert_eq!(parsed.mcp.servers.len(), 1);
23088    }
23089
23090    #[test]
23091    async fn mcp_config_explicit_disable_and_deferred_loading_are_respected() {
23092        let raw = r#"
23093[mcp]
23094enabled = false
23095deferred_loading = true
23096
23097[[mcp.servers]]
23098name = "svc"
23099transport = "http"
23100url = "http://localhost:8080/mcp"
23101"#;
23102        let parsed = parse_test_config(raw);
23103        assert!(!parsed.mcp.enabled);
23104        assert!(parsed.mcp.deferred_loading);
23105        assert_eq!(parsed.mcp.servers.len(), 1);
23106    }
23107
23108    #[test]
23109    async fn mcp_transport_serde_roundtrip_lowercase() {
23110        let cases = [
23111            (McpTransport::Stdio, "\"stdio\""),
23112            (McpTransport::Http, "\"http\""),
23113            (McpTransport::Sse, "\"sse\""),
23114        ];
23115        for (variant, expected_json) in &cases {
23116            let serialized = serde_json::to_string(variant).expect("serialize");
23117            assert_eq!(&serialized, expected_json, "variant: {variant:?}");
23118            let deserialized: McpTransport =
23119                serde_json::from_str(expected_json).expect("deserialize");
23120            assert_eq!(&deserialized, variant);
23121        }
23122    }
23123
23124    #[tokio::test]
23125    async fn nevis_client_secret_encrypt_decrypt_roundtrip() {
23126        let dir = std::env::temp_dir().join(format!(
23127            "zeroclaw_test_nevis_secret_{}",
23128            uuid::Uuid::new_v4()
23129        ));
23130        fs::create_dir_all(&dir).await.unwrap();
23131
23132        let plaintext_secret = "nevis-test-client-secret-value";
23133
23134        let mut config = Config {
23135            data_dir: dir.join("workspace"),
23136            config_path: dir.join("config.toml"),
23137            ..Default::default()
23138        };
23139        config.security.nevis.client_secret = Some(plaintext_secret.into());
23140
23141        // Save (triggers encryption)
23142        config.save().await.unwrap();
23143
23144        // Read raw TOML and verify plaintext secret is NOT present
23145        let raw_toml = tokio::fs::read_to_string(&config.config_path)
23146            .await
23147            .unwrap();
23148        assert!(
23149            !raw_toml.contains(plaintext_secret),
23150            "Saved TOML must not contain the plaintext client_secret"
23151        );
23152
23153        // Parse stored TOML and verify the value is encrypted
23154        let stored: Config = toml::from_str(&raw_toml).unwrap();
23155        let stored_secret = stored.security.nevis.client_secret.as_ref().unwrap();
23156        assert!(
23157            crate::secrets::SecretStore::is_encrypted(stored_secret),
23158            "Stored client_secret must be marked as encrypted"
23159        );
23160
23161        // Decrypt and verify it matches the original plaintext
23162        let store = crate::secrets::SecretStore::new(&dir, true);
23163        assert_eq!(store.decrypt(stored_secret).unwrap(), plaintext_secret);
23164
23165        // Simulate a full load: deserialize then decrypt (mirrors load_or_init logic)
23166        let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
23167        loaded.config_path = dir.join("config.toml");
23168        let load_store = crate::secrets::SecretStore::new(&dir, loaded.secrets.encrypt);
23169        loaded.decrypt_secrets(&load_store).unwrap();
23170        assert_eq!(
23171            loaded.security.nevis.client_secret.as_deref().unwrap(),
23172            plaintext_secret,
23173            "Loaded client_secret must match the original plaintext after decryption"
23174        );
23175
23176        let _ = fs::remove_dir_all(&dir).await;
23177    }
23178
23179    // ══════════════════════════════════════════════════════════
23180    // Nevis config validation tests
23181    // ══════════════════════════════════════════════════════════
23182
23183    #[test]
23184    async fn nevis_config_validate_disabled_accepts_empty_fields() {
23185        let cfg = NevisConfig::default();
23186        assert!(!cfg.enabled);
23187        assert!(cfg.validate().is_ok());
23188    }
23189
23190    #[test]
23191    async fn nevis_config_validate_rejects_empty_instance_url() {
23192        let cfg = NevisConfig {
23193            enabled: true,
23194            instance_url: String::new(),
23195            client_id: "test-client".into(),
23196            ..NevisConfig::default()
23197        };
23198        let err = cfg.validate().unwrap_err();
23199        assert!(err.contains("instance_url"));
23200    }
23201
23202    #[test]
23203    async fn nevis_config_validate_rejects_empty_client_id() {
23204        let cfg = NevisConfig {
23205            enabled: true,
23206            instance_url: "https://nevis.example.com".into(),
23207            client_id: String::new(),
23208            ..NevisConfig::default()
23209        };
23210        let err = cfg.validate().unwrap_err();
23211        assert!(err.contains("client_id"));
23212    }
23213
23214    #[test]
23215    async fn nevis_config_validate_rejects_empty_realm() {
23216        let cfg = NevisConfig {
23217            enabled: true,
23218            instance_url: "https://nevis.example.com".into(),
23219            client_id: "test-client".into(),
23220            realm: String::new(),
23221            ..NevisConfig::default()
23222        };
23223        let err = cfg.validate().unwrap_err();
23224        assert!(err.contains("realm"));
23225    }
23226
23227    #[test]
23228    async fn nevis_config_validate_rejects_local_without_jwks() {
23229        let cfg = NevisConfig {
23230            enabled: true,
23231            instance_url: "https://nevis.example.com".into(),
23232            client_id: "test-client".into(),
23233            token_validation: "local".into(),
23234            jwks_url: None,
23235            ..NevisConfig::default()
23236        };
23237        let err = cfg.validate().unwrap_err();
23238        assert!(err.contains("jwks_url"));
23239    }
23240
23241    #[test]
23242    async fn nevis_config_validate_rejects_zero_session_timeout() {
23243        let cfg = NevisConfig {
23244            enabled: true,
23245            instance_url: "https://nevis.example.com".into(),
23246            client_id: "test-client".into(),
23247            token_validation: "remote".into(),
23248            session_timeout_secs: 0,
23249            ..NevisConfig::default()
23250        };
23251        let err = cfg.validate().unwrap_err();
23252        assert!(err.contains("session_timeout_secs"));
23253    }
23254
23255    #[test]
23256    async fn nevis_config_validate_accepts_valid_enabled_config() {
23257        let cfg = NevisConfig {
23258            enabled: true,
23259            instance_url: "https://nevis.example.com".into(),
23260            realm: "master".into(),
23261            client_id: "test-client".into(),
23262            token_validation: "remote".into(),
23263            session_timeout_secs: 3600,
23264            ..NevisConfig::default()
23265        };
23266        assert!(cfg.validate().is_ok());
23267    }
23268
23269    #[test]
23270    async fn nevis_config_validate_rejects_invalid_token_validation() {
23271        let cfg = NevisConfig {
23272            enabled: true,
23273            instance_url: "https://nevis.example.com".into(),
23274            realm: "master".into(),
23275            client_id: "test-client".into(),
23276            token_validation: "invalid_mode".into(),
23277            session_timeout_secs: 3600,
23278            ..NevisConfig::default()
23279        };
23280        let err = cfg.validate().unwrap_err();
23281        assert!(
23282            err.contains("invalid value 'invalid_mode'"),
23283            "Expected invalid token_validation error, got: {err}"
23284        );
23285    }
23286
23287    #[test]
23288    async fn nevis_config_debug_redacts_client_secret() {
23289        let cfg = NevisConfig {
23290            client_secret: Some("super-secret".into()),
23291            ..NevisConfig::default()
23292        };
23293        let debug_output = format!("{:?}", cfg);
23294        assert!(
23295            !debug_output.contains("super-secret"),
23296            "Debug output must not contain the raw client_secret"
23297        );
23298        assert!(
23299            debug_output.contains("[REDACTED]"),
23300            "Debug output must show [REDACTED] for client_secret"
23301        );
23302    }
23303
23304    #[test]
23305    async fn telegram_config_ack_reactions_false_deserializes() {
23306        let toml_str = r#"
23307            bot_token = "123:ABC"
23308            allowed_users = ["alice"]
23309            ack_reactions = false
23310        "#;
23311        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
23312        assert_eq!(cfg.ack_reactions, Some(false));
23313    }
23314
23315    #[test]
23316    async fn telegram_config_ack_reactions_true_deserializes() {
23317        let toml_str = r#"
23318            bot_token = "123:ABC"
23319            allowed_users = ["alice"]
23320            ack_reactions = true
23321        "#;
23322        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
23323        assert_eq!(cfg.ack_reactions, Some(true));
23324    }
23325
23326    #[test]
23327    async fn telegram_config_ack_reactions_missing_defaults_to_none() {
23328        let toml_str = r#"
23329            bot_token = "123:ABC"
23330            allowed_users = ["alice"]
23331        "#;
23332        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
23333        assert_eq!(cfg.ack_reactions, None);
23334    }
23335
23336    #[test]
23337    async fn telegram_config_ack_reactions_channel_overrides_top_level() {
23338        let tg_toml = r#"
23339            bot_token = "123:ABC"
23340            allowed_users = ["alice"]
23341            ack_reactions = false
23342        "#;
23343        let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
23344        let top_level_ack = true;
23345        let effective = tg.ack_reactions.unwrap_or(top_level_ack);
23346        assert!(
23347            !effective,
23348            "channel-level false must override top-level true"
23349        );
23350    }
23351
23352    #[test]
23353    async fn telegram_config_ack_reactions_falls_back_to_top_level() {
23354        let tg_toml = r#"
23355            bot_token = "123:ABC"
23356            allowed_users = ["alice"]
23357        "#;
23358        let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
23359        let top_level_ack = false;
23360        let effective = tg.ack_reactions.unwrap_or(top_level_ack);
23361        assert!(
23362            !effective,
23363            "must fall back to top-level false when channel omits field"
23364        );
23365    }
23366
23367    #[test]
23368    async fn google_workspace_allowed_operations_deserialize_from_toml() {
23369        let toml_str = r#"
23370            enabled = true
23371
23372            [[allowed_operations]]
23373            service = "gmail"
23374            resource = "users"
23375            sub_resource = "drafts"
23376            methods = ["create", "update"]
23377        "#;
23378
23379        let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
23380        assert_eq!(cfg.allowed_operations.len(), 1);
23381        assert_eq!(cfg.allowed_operations[0].service, "gmail");
23382        assert_eq!(cfg.allowed_operations[0].resource, "users");
23383        assert_eq!(
23384            cfg.allowed_operations[0].sub_resource.as_deref(),
23385            Some("drafts")
23386        );
23387        assert_eq!(
23388            cfg.allowed_operations[0].methods,
23389            vec!["create".to_string(), "update".to_string()]
23390        );
23391    }
23392
23393    #[test]
23394    async fn google_workspace_allowed_operations_deserialize_without_sub_resource() {
23395        let toml_str = r#"
23396            enabled = true
23397
23398            [[allowed_operations]]
23399            service = "drive"
23400            resource = "files"
23401            methods = ["list", "get"]
23402        "#;
23403
23404        let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
23405        assert_eq!(cfg.allowed_operations[0].sub_resource, None);
23406    }
23407
23408    #[test]
23409    async fn config_validate_accepts_google_workspace_allowed_operations() {
23410        let mut cfg = Config::default();
23411        cfg.google_workspace.enabled = true;
23412        cfg.google_workspace.allowed_services = vec!["gmail".into()];
23413        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
23414            service: "gmail".into(),
23415            resource: "users".into(),
23416            sub_resource: Some("drafts".into()),
23417            methods: vec!["create".into(), "update".into()],
23418        }];
23419
23420        cfg.validate().unwrap();
23421    }
23422
23423    #[test]
23424    async fn config_validate_rejects_duplicate_google_workspace_allowed_operations() {
23425        let mut cfg = Config::default();
23426        cfg.google_workspace.enabled = true;
23427        cfg.google_workspace.allowed_services = vec!["gmail".into()];
23428        cfg.google_workspace.allowed_operations = vec![
23429            GoogleWorkspaceAllowedOperation {
23430                service: "gmail".into(),
23431                resource: "users".into(),
23432                sub_resource: Some("drafts".into()),
23433                methods: vec!["create".into()],
23434            },
23435            GoogleWorkspaceAllowedOperation {
23436                service: "gmail".into(),
23437                resource: "users".into(),
23438                sub_resource: Some("drafts".into()),
23439                methods: vec!["update".into()],
23440            },
23441        ];
23442
23443        let err = cfg.validate().unwrap_err().to_string();
23444        assert!(err.contains("duplicate service/resource/sub_resource entry"));
23445    }
23446
23447    #[test]
23448    async fn config_validate_rejects_operation_service_not_in_allowed_services() {
23449        let mut cfg = Config::default();
23450        cfg.google_workspace.enabled = true;
23451        cfg.google_workspace.allowed_services = vec!["gmail".into()];
23452        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
23453            service: "drive".into(), // drive is not in allowed_services
23454            resource: "files".into(),
23455            sub_resource: None,
23456            methods: vec!["list".into()],
23457        }];
23458
23459        let err = cfg.validate().unwrap_err().to_string();
23460        assert!(
23461            err.contains("not in the effective allowed_services"),
23462            "expected not-in-allowed_services error, got: {err}"
23463        );
23464    }
23465
23466    #[test]
23467    async fn config_validate_accepts_default_service_when_allowed_services_empty() {
23468        // When allowed_services is empty the validator uses DEFAULT_GWS_SERVICES.
23469        // A known default service must pass.
23470        let mut cfg = Config::default();
23471        cfg.google_workspace.enabled = true;
23472        // allowed_services deliberately left empty (falls back to defaults)
23473        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
23474            service: "drive".into(),
23475            resource: "files".into(),
23476            sub_resource: None,
23477            methods: vec!["list".into()],
23478        }];
23479
23480        assert!(cfg.validate().is_ok());
23481    }
23482
23483    #[test]
23484    async fn config_validate_rejects_unknown_service_when_allowed_services_empty() {
23485        // Even with allowed_services empty (using defaults), an operation whose
23486        // service is not in DEFAULT_GWS_SERVICES must fail validation — not silently
23487        // pass through to be rejected at runtime.
23488        let mut cfg = Config::default();
23489        cfg.google_workspace.enabled = true;
23490        // allowed_services deliberately left empty
23491        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
23492            service: "not_a_real_service".into(),
23493            resource: "files".into(),
23494            sub_resource: None,
23495            methods: vec!["list".into()],
23496        }];
23497
23498        let err = cfg.validate().unwrap_err().to_string();
23499        assert!(
23500            err.contains("not in the effective allowed_services"),
23501            "expected effective-allowed_services error, got: {err}"
23502        );
23503    }
23504
23505    // ── Bootstrap files ─────────────────────────────────────
23506
23507    #[tokio::test]
23508    async fn ensure_bootstrap_files_creates_missing_files() {
23509        let tmp = tempfile::TempDir::new().unwrap();
23510        let ws = tmp.path().join("workspace");
23511        let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
23512
23513        ensure_bootstrap_files(&ws).await.unwrap();
23514
23515        let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
23516        let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
23517            .await
23518            .unwrap();
23519        assert!(soul.contains("SOUL.md"));
23520        assert!(identity.contains("IDENTITY.md"));
23521    }
23522
23523    #[tokio::test]
23524    async fn ensure_bootstrap_files_does_not_overwrite_existing() {
23525        let tmp = tempfile::TempDir::new().unwrap();
23526        let ws = tmp.path().join("workspace");
23527        let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
23528
23529        let custom = "# My custom SOUL";
23530        let _: () = tokio::fs::write(ws.join("SOUL.md"), custom).await.unwrap();
23531
23532        ensure_bootstrap_files(&ws).await.unwrap();
23533
23534        let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
23535        assert_eq!(
23536            soul, custom,
23537            "ensure_bootstrap_files must not overwrite existing files"
23538        );
23539
23540        // IDENTITY.md should still be created since it was missing
23541        let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
23542            .await
23543            .unwrap();
23544        assert!(identity.contains("IDENTITY.md"));
23545    }
23546
23547    // ── PacingConfig serde defaults ─────────────────────────────
23548
23549    #[test]
23550    async fn pacing_config_serde_defaults_match_manual_default() {
23551        // Deserialise an empty TOML table and verify the loop-detection
23552        // fields receive the same defaults as `PacingConfig::default()`.
23553        let from_toml: PacingConfig = toml::from_str("").unwrap();
23554        let manual = PacingConfig::default();
23555
23556        assert_eq!(
23557            from_toml.loop_detection_enabled,
23558            manual.loop_detection_enabled
23559        );
23560        assert_eq!(
23561            from_toml.loop_detection_window_size,
23562            manual.loop_detection_window_size
23563        );
23564        assert_eq!(
23565            from_toml.loop_detection_max_repeats,
23566            manual.loop_detection_max_repeats
23567        );
23568
23569        // Verify concrete values so a silent change to the defaults is caught.
23570        assert!(from_toml.loop_detection_enabled, "default should be true");
23571        assert_eq!(from_toml.loop_detection_window_size, 20);
23572        assert_eq!(from_toml.loop_detection_max_repeats, 3);
23573    }
23574
23575    // ── Docker baked config template ────────────────────────────
23576
23577    /// The TOML template baked into Docker images (Dockerfile + Dockerfile.debian).
23578    /// Kept here so changes to the Dockerfiles can be validated by `cargo test`.
23579    const DOCKER_CONFIG_TEMPLATE: &str = r#"
23580schema_version = 3
23581workspace_dir = "/zeroclaw-data/workspace"
23582config_path = "/zeroclaw-data/.zeroclaw/config.toml"
23583api_key = ""
23584default_model_provider = "openrouter"
23585default_model = "anthropic/claude-sonnet-4-20250514"
23586default_temperature = 0.7
23587
23588[gateway]
23589port = 42617
23590host = "[::]"
23591allow_public_bind = true
23592
23593[risk_profiles.default]
23594level = "supervised"
23595auto_approve = ["file_read", "file_write", "file_edit", "memory_recall", "memory_store", "web_search_tool", "web_fetch", "calculator", "glob_search", "content_search", "image_info", "weather", "git_operations"]
23596"#;
23597
23598    #[test]
23599    async fn docker_config_template_is_parseable() {
23600        let cfg: Config = toml::from_str(DOCKER_CONFIG_TEMPLATE)
23601            .expect("Docker baked config.toml must be valid TOML that deserialises into Config");
23602
23603        let auto = &cfg
23604            .risk_profiles
23605            .get("default")
23606            .expect("Docker config must define [risk_profiles.default]")
23607            .auto_approve;
23608        for tool in &[
23609            "file_read",
23610            "file_write",
23611            "file_edit",
23612            "memory_recall",
23613            "memory_store",
23614            "web_search_tool",
23615            "web_fetch",
23616            "calculator",
23617            "glob_search",
23618            "content_search",
23619            "image_info",
23620            "weather",
23621            "git_operations",
23622        ] {
23623            assert!(
23624                auto.iter().any(|t| t == tool),
23625                "Docker config risk_profiles.default.auto_approve missing expected tool: {tool}"
23626            );
23627        }
23628    }
23629
23630    #[test]
23631    async fn cost_enforcement_config_defaults() {
23632        let config = CostEnforcementConfig::default();
23633        assert_eq!(config.mode, "warn");
23634        assert_eq!(config.route_down_model, None);
23635        assert_eq!(config.reserve_percent, 10);
23636    }
23637
23638    #[test]
23639    async fn cost_config_includes_enforcement() {
23640        let config = CostConfig::default();
23641        assert_eq!(config.enforcement.mode, "warn");
23642        assert_eq!(config.enforcement.reserve_percent, 10);
23643    }
23644
23645    // ── Configurable macro tests ──
23646
23647    #[test]
23648    async fn matrix_secret_fields_discovered() {
23649        let mx = MatrixConfig {
23650            enabled: true,
23651            homeserver: "https://m.org".into(),
23652            access_token: Some("tok".into()),
23653            user_id: None,
23654            device_id: None,
23655            allowed_rooms: vec!["!r:m".into()],
23656            interrupt_on_new_message: false,
23657            stream_mode: StreamMode::default(),
23658            draft_update_interval_ms: 1500,
23659            multi_message_delay_ms: 800,
23660            recovery_key: None,
23661            mention_only: false,
23662            password: None,
23663            approval_timeout_secs: 300,
23664            reply_in_thread: true,
23665            ack_reactions: Some(true),
23666            excluded_tools: vec![],
23667            reply_min_interval_secs: 0,
23668            reply_queue_depth_max: 0,
23669        };
23670        let fields = mx.secret_fields();
23671        assert_eq!(fields.len(), 3);
23672        assert_eq!(fields[0].name, "channels.matrix.access_token");
23673        assert_eq!(fields[0].category, "Channels");
23674        assert!(fields[0].is_set);
23675        assert_eq!(fields[1].name, "channels.matrix.recovery_key");
23676        assert!(!fields[1].is_set);
23677        assert_eq!(fields[2].name, "channels.matrix.password");
23678        assert!(!fields[2].is_set);
23679    }
23680
23681    #[test]
23682    async fn matrix_secret_fields_empty_not_set() {
23683        let mx = MatrixConfig {
23684            enabled: true,
23685            homeserver: "https://m.org".into(),
23686            access_token: None,
23687            user_id: None,
23688            device_id: None,
23689            allowed_rooms: vec!["!r:m".into()],
23690            interrupt_on_new_message: false,
23691            stream_mode: StreamMode::default(),
23692            draft_update_interval_ms: 1500,
23693            multi_message_delay_ms: 800,
23694            recovery_key: None,
23695            mention_only: false,
23696            password: None,
23697            approval_timeout_secs: 300,
23698            reply_in_thread: true,
23699            ack_reactions: Some(true),
23700            excluded_tools: vec![],
23701            reply_min_interval_secs: 0,
23702            reply_queue_depth_max: 0,
23703        };
23704        let fields = mx.secret_fields();
23705        assert!(!fields[0].is_set);
23706    }
23707
23708    #[test]
23709    async fn set_secret_updates_field() {
23710        let mut mx = MatrixConfig {
23711            enabled: true,
23712            homeserver: "https://m.org".into(),
23713            access_token: Some("old".into()),
23714            user_id: None,
23715            device_id: None,
23716            allowed_rooms: vec!["!r:m".into()],
23717            interrupt_on_new_message: false,
23718            stream_mode: StreamMode::default(),
23719            draft_update_interval_ms: 1500,
23720            multi_message_delay_ms: 800,
23721            recovery_key: None,
23722            mention_only: false,
23723            password: None,
23724            approval_timeout_secs: 300,
23725            reply_in_thread: true,
23726            ack_reactions: Some(true),
23727            excluded_tools: vec![],
23728            reply_min_interval_secs: 0,
23729            reply_queue_depth_max: 0,
23730        };
23731        mx.set_secret("channels.matrix.access_token", "new-token".into())
23732            .unwrap();
23733        assert_eq!(mx.access_token.as_deref(), Some("new-token"));
23734    }
23735
23736    #[test]
23737    async fn set_secret_unknown_name_fails() {
23738        let mut mx = MatrixConfig {
23739            enabled: true,
23740            homeserver: "https://m.org".into(),
23741            access_token: Some("tok".into()),
23742            user_id: None,
23743            device_id: None,
23744            allowed_rooms: vec!["!r:m".into()],
23745            interrupt_on_new_message: false,
23746            stream_mode: StreamMode::default(),
23747            draft_update_interval_ms: 1500,
23748            multi_message_delay_ms: 800,
23749            recovery_key: None,
23750            mention_only: false,
23751            password: None,
23752            approval_timeout_secs: 300,
23753            reply_in_thread: true,
23754            ack_reactions: Some(true),
23755            excluded_tools: vec![],
23756            reply_min_interval_secs: 0,
23757            reply_queue_depth_max: 0,
23758        };
23759        assert!(
23760            mx.set_secret("channels.matrix.nonexistent", "val".into())
23761                .is_err()
23762        );
23763    }
23764
23765    #[test]
23766    async fn config_tree_traversal_discovers_nested_secrets() {
23767        let mut config = Config::default();
23768        // Set api_key on first model_provider entry (or create one)
23769        config
23770            .providers
23771            .models
23772            .ensure("anthropic", "default")
23773            .expect("anthropic typed slot")
23774            .api_key = Some("test-key".into());
23775        config.channels.matrix.insert(
23776            "default".to_string(),
23777            MatrixConfig {
23778                enabled: true,
23779                homeserver: "https://m.org".into(),
23780                access_token: Some("mx-tok".into()),
23781                user_id: None,
23782                device_id: None,
23783                allowed_rooms: vec!["!r:m".into()],
23784                interrupt_on_new_message: false,
23785                stream_mode: StreamMode::default(),
23786                draft_update_interval_ms: 1500,
23787                multi_message_delay_ms: 800,
23788                recovery_key: None,
23789                mention_only: false,
23790                password: None,
23791                approval_timeout_secs: 300,
23792                reply_in_thread: true,
23793                ack_reactions: Some(true),
23794                excluded_tools: vec![],
23795                reply_min_interval_secs: 0,
23796                reply_queue_depth_max: 0,
23797            },
23798        );
23799
23800        let fields = config.secret_fields();
23801        let names: Vec<&str> = fields.iter().map(|f| f.name).collect();
23802        assert!(names.contains(&"channels.matrix.access_token"));
23803        assert!(names.contains(&"channels.matrix.recovery_key"));
23804        assert!(
23805            names.contains(&"http_request.secrets"),
23806            "http_request.secrets must be classified as a secret map"
23807        );
23808    }
23809
23810    #[test]
23811    async fn config_set_secret_dispatches_to_child() {
23812        let mut config = Config::default();
23813        config.channels.matrix.insert(
23814            "default".to_string(),
23815            MatrixConfig {
23816                enabled: true,
23817                homeserver: "https://m.org".into(),
23818                access_token: Some("old".into()),
23819                user_id: None,
23820                device_id: None,
23821                allowed_rooms: vec!["!r:m".into()],
23822                interrupt_on_new_message: false,
23823                stream_mode: StreamMode::default(),
23824                draft_update_interval_ms: 1500,
23825                multi_message_delay_ms: 800,
23826                recovery_key: None,
23827                mention_only: false,
23828                password: None,
23829                approval_timeout_secs: 300,
23830                reply_in_thread: true,
23831                ack_reactions: Some(true),
23832                excluded_tools: vec![],
23833                reply_min_interval_secs: 0,
23834                reply_queue_depth_max: 0,
23835            },
23836        );
23837
23838        config
23839            .set_secret("channels.matrix.access_token", "new".into())
23840            .unwrap();
23841        assert_eq!(
23842            config
23843                .channels
23844                .matrix
23845                .get("default")
23846                .unwrap()
23847                .access_token
23848                .as_deref(),
23849            Some("new")
23850        );
23851    }
23852
23853    #[test]
23854    async fn config_set_secret_dispatches_to_matrix_child() {
23855        let mut config = Config::default();
23856        config.channels.matrix.insert(
23857            "default".to_string(),
23858            MatrixConfig {
23859                enabled: true,
23860                homeserver: "https://m.org".into(),
23861                access_token: Some("old".into()),
23862                user_id: None,
23863                device_id: None,
23864                allowed_rooms: vec!["!r:m".into()],
23865                interrupt_on_new_message: false,
23866                stream_mode: StreamMode::default(),
23867                draft_update_interval_ms: 1500,
23868                multi_message_delay_ms: 800,
23869                mention_only: false,
23870                recovery_key: None,
23871                password: None,
23872                approval_timeout_secs: 300,
23873                reply_in_thread: true,
23874                ack_reactions: Some(true),
23875                excluded_tools: vec![],
23876                reply_min_interval_secs: 0,
23877                reply_queue_depth_max: 0,
23878            },
23879        );
23880        config
23881            .set_secret("channels.matrix.access_token", "sk-test".into())
23882            .unwrap();
23883        assert_eq!(
23884            config
23885                .channels
23886                .matrix
23887                .get("default")
23888                .unwrap()
23889                .access_token
23890                .as_deref(),
23891            Some("sk-test")
23892        );
23893    }
23894
23895    #[test]
23896    async fn config_set_secret_unknown_fails() {
23897        let mut config = Config::default();
23898        assert!(
23899            config
23900                .set_secret("nonexistent.field", "val".into())
23901                .is_err()
23902        );
23903    }
23904
23905    #[test]
23906    async fn config_set_http_request_secret_map_key_is_masked_and_encrypted() {
23907        let dir = TempDir::new().unwrap();
23908        let config_path = dir.path().join("config.toml");
23909        tokio::fs::write(&config_path, "schema_version = 1\n")
23910            .await
23911            .unwrap();
23912        let mut config = Config {
23913            config_path: config_path.clone(),
23914            data_dir: dir.path().join("workspace"),
23915            secrets: SecretsConfig { encrypt: true },
23916            ..Config::default()
23917        };
23918        let path = "http_request.secrets.api_token";
23919
23920        assert!(
23921            Config::prop_is_secret(path),
23922            "dynamic http_request secret map entries must be classified as secret before the key exists"
23923        );
23924        config
23925            .set_prop_persistent(path, "Bearer from-config-set")
23926            .unwrap();
23927
23928        assert_eq!(
23929            config
23930                .http_request
23931                .secrets
23932                .get("api_token")
23933                .map(String::as_str),
23934            Some("Bearer from-config-set")
23935        );
23936        assert_eq!(config.get_prop(path).unwrap(), "****");
23937
23938        let field = config
23939            .prop_fields()
23940            .into_iter()
23941            .find(|field| field.name == path)
23942            .expect("dynamic secret map prop field");
23943        assert!(field.is_secret);
23944        assert_eq!(field.display_value, "****");
23945        assert_eq!(
23946            field.credential_class,
23947            Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
23948        );
23949
23950        config.save_dirty().await.unwrap();
23951        let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
23952        assert!(
23953            !contents.contains("Bearer from-config-set"),
23954            "auth secret must not be written in plaintext: {contents}"
23955        );
23956
23957        let stored = crate::migration::migrate_to_current(&contents).unwrap();
23958        let encrypted = stored.http_request.secrets.get("api_token").unwrap();
23959        assert!(crate::secrets::SecretStore::is_encrypted(encrypted));
23960        let store = crate::secrets::SecretStore::new(dir.path(), true);
23961        assert_eq!(store.decrypt(encrypted).unwrap(), "Bearer from-config-set");
23962    }
23963
23964    #[test]
23965    async fn encrypt_decrypt_roundtrip_via_macro() {
23966        let dir = TempDir::new().unwrap();
23967        let store = crate::secrets::SecretStore::new(dir.path(), true);
23968
23969        let mut mx = MatrixConfig {
23970            enabled: true,
23971            homeserver: "https://m.org".into(),
23972            access_token: Some("plaintext-token".into()),
23973            user_id: None,
23974            device_id: None,
23975            allowed_rooms: vec!["!r:m".into()],
23976            interrupt_on_new_message: false,
23977            stream_mode: StreamMode::default(),
23978            draft_update_interval_ms: 1500,
23979            multi_message_delay_ms: 800,
23980            recovery_key: None,
23981            mention_only: false,
23982            password: None,
23983            approval_timeout_secs: 300,
23984            reply_in_thread: true,
23985            ack_reactions: Some(true),
23986            excluded_tools: vec![],
23987            reply_min_interval_secs: 0,
23988            reply_queue_depth_max: 0,
23989        };
23990
23991        // Encrypt
23992        mx.encrypt_secrets(&store).unwrap();
23993        assert!(crate::secrets::SecretStore::is_encrypted(
23994            mx.access_token.as_deref().unwrap_or_default()
23995        ));
23996        assert_ne!(mx.access_token.as_deref(), Some("plaintext-token"));
23997
23998        // Decrypt
23999        mx.decrypt_secrets(&store).unwrap();
24000        assert_eq!(mx.access_token.as_deref(), Some("plaintext-token"));
24001    }
24002
24003    #[test]
24004    async fn encrypt_skips_already_encrypted() {
24005        let dir = TempDir::new().unwrap();
24006        let store = crate::secrets::SecretStore::new(dir.path(), true);
24007
24008        let mut mx = MatrixConfig {
24009            enabled: true,
24010            homeserver: "https://m.org".into(),
24011            access_token: Some("plaintext-token".into()),
24012            user_id: None,
24013            device_id: None,
24014            allowed_rooms: vec!["!r:m".into()],
24015            interrupt_on_new_message: false,
24016            stream_mode: StreamMode::default(),
24017            draft_update_interval_ms: 1500,
24018            multi_message_delay_ms: 800,
24019            recovery_key: None,
24020            mention_only: false,
24021            password: None,
24022            approval_timeout_secs: 300,
24023            reply_in_thread: true,
24024            ack_reactions: Some(true),
24025            excluded_tools: vec![],
24026            reply_min_interval_secs: 0,
24027            reply_queue_depth_max: 0,
24028        };
24029
24030        mx.encrypt_secrets(&store).unwrap();
24031        let first_encrypted = mx.access_token.clone();
24032
24033        // Encrypt again — should be idempotent
24034        mx.encrypt_secrets(&store).unwrap();
24035        assert_eq!(mx.access_token, first_encrypted);
24036    }
24037
24038    #[test]
24039    async fn encrypt_no_op_on_disabled_store() {
24040        let dir = TempDir::new().unwrap();
24041        let store = crate::secrets::SecretStore::new(dir.path(), false);
24042
24043        let mut mx = MatrixConfig {
24044            enabled: true,
24045            homeserver: "https://m.org".into(),
24046            access_token: Some("plaintext-token".into()),
24047            user_id: None,
24048            device_id: None,
24049            allowed_rooms: vec!["!r:m".into()],
24050            interrupt_on_new_message: false,
24051            stream_mode: StreamMode::default(),
24052            draft_update_interval_ms: 1500,
24053            multi_message_delay_ms: 800,
24054            recovery_key: None,
24055            mention_only: false,
24056            password: None,
24057            approval_timeout_secs: 300,
24058            reply_in_thread: true,
24059            ack_reactions: Some(true),
24060            excluded_tools: vec![],
24061            reply_min_interval_secs: 0,
24062            reply_queue_depth_max: 0,
24063        };
24064
24065        mx.encrypt_secrets(&store).unwrap();
24066        // With encryption disabled, value should stay plaintext
24067        assert_eq!(mx.access_token.as_deref(), Some("plaintext-token"));
24068    }
24069
24070    // ── Property method tests ──
24071
24072    fn test_matrix_config() -> MatrixConfig {
24073        MatrixConfig {
24074            enabled: true,
24075            homeserver: "https://m.org".into(),
24076            access_token: Some("tok".into()),
24077            user_id: Some("@bot:m.org".into()),
24078            device_id: None,
24079            allowed_rooms: vec!["!r:m".into()],
24080            interrupt_on_new_message: false,
24081            stream_mode: StreamMode::default(),
24082            draft_update_interval_ms: 1500,
24083            multi_message_delay_ms: 800,
24084            recovery_key: None,
24085            mention_only: false,
24086            password: None,
24087            approval_timeout_secs: 300,
24088            reply_in_thread: true,
24089            ack_reactions: Some(true),
24090            excluded_tools: vec![],
24091            reply_min_interval_secs: 0,
24092            reply_queue_depth_max: 0,
24093        }
24094    }
24095
24096    #[test]
24097    async fn prop_fields_returns_typed_entries() {
24098        let mx = test_matrix_config();
24099        let fields = mx.prop_fields();
24100        let by_name: std::collections::HashMap<&str, &crate::traits::PropFieldInfo> =
24101            fields.iter().map(|f| (f.name.as_str(), f)).collect();
24102
24103        // String field
24104        let homeserver = by_name["channels.matrix.homeserver"];
24105        assert_eq!(homeserver.type_hint, "String");
24106        assert_eq!(homeserver.display_value, "https://m.org");
24107
24108        // Option<String> — set
24109        let user_id = by_name["channels.matrix.user_id"];
24110        assert_eq!(user_id.type_hint, "Option<String>");
24111        assert_eq!(user_id.display_value, "@bot:m.org");
24112
24113        // Option<String> — unset
24114        let device_id = by_name["channels.matrix.device_id"];
24115        assert_eq!(device_id.display_value, "<unset>");
24116
24117        // u64 field
24118        let interval = by_name["channels.matrix.draft_update_interval_ms"];
24119        assert_eq!(interval.type_hint, "u64");
24120        assert_eq!(interval.display_value, "1500");
24121
24122        // Enum field
24123        let stream = by_name["channels.matrix.stream_mode"];
24124        assert!(stream.is_enum());
24125        assert!(stream.enum_variants.is_some());
24126
24127        // Secret field — masked
24128        let token = by_name["channels.matrix.access_token"];
24129        assert!(token.is_secret);
24130        assert_eq!(token.display_value, "****");
24131
24132        // All fields have correct category
24133        for field in &fields {
24134            assert_eq!(field.category, "Channels");
24135        }
24136    }
24137
24138    #[test]
24139    async fn get_prop_returns_values_by_path() {
24140        let mx = test_matrix_config();
24141
24142        assert_eq!(
24143            mx.get_prop("channels.matrix.homeserver").unwrap(),
24144            "https://m.org"
24145        );
24146        assert_eq!(
24147            mx.get_prop("channels.matrix.draft_update_interval_ms")
24148                .unwrap(),
24149            "1500"
24150        );
24151        assert_eq!(
24152            mx.get_prop("channels.matrix.user_id").unwrap(),
24153            "@bot:m.org"
24154        );
24155        assert_eq!(mx.get_prop("channels.matrix.device_id").unwrap(), "<unset>");
24156        // Secrets return masked value
24157        assert_eq!(
24158            mx.get_prop("channels.matrix.access_token").unwrap(),
24159            "**** (encrypted)"
24160        );
24161    }
24162
24163    #[test]
24164    async fn get_prop_unknown_path_fails() {
24165        let mx = test_matrix_config();
24166        assert!(mx.get_prop("channels.matrix.nonexistent").is_err());
24167    }
24168
24169    #[test]
24170    async fn set_prop_string() {
24171        let mut mx = test_matrix_config();
24172        mx.set_prop("channels.matrix.homeserver", "https://new.org")
24173            .unwrap();
24174        assert_eq!(mx.homeserver, "https://new.org");
24175    }
24176
24177    #[test]
24178    async fn set_prop_bool() {
24179        let mut mx = test_matrix_config();
24180        mx.set_prop("channels.matrix.interrupt_on_new_message", "true")
24181            .unwrap();
24182        assert!(mx.interrupt_on_new_message);
24183    }
24184
24185    #[test]
24186    async fn set_prop_bool_rejects_invalid() {
24187        let mut mx = test_matrix_config();
24188        let err = mx
24189            .set_prop("channels.matrix.interrupt_on_new_message", "yes")
24190            .unwrap_err();
24191        assert!(err.to_string().contains("bool"));
24192    }
24193
24194    #[test]
24195    async fn set_prop_u64() {
24196        let mut mx = test_matrix_config();
24197        mx.set_prop("channels.matrix.draft_update_interval_ms", "3000")
24198            .unwrap();
24199        assert_eq!(mx.draft_update_interval_ms, 3000);
24200    }
24201
24202    #[test]
24203    async fn set_prop_u64_rejects_invalid() {
24204        let mut mx = test_matrix_config();
24205        assert!(
24206            mx.set_prop("channels.matrix.draft_update_interval_ms", "abc")
24207                .is_err()
24208        );
24209    }
24210
24211    #[test]
24212    async fn set_prop_option_string_set_and_clear() {
24213        let mut mx = test_matrix_config();
24214        mx.set_prop("channels.matrix.user_id", "@new:m.org")
24215            .unwrap();
24216        assert_eq!(mx.user_id.as_deref(), Some("@new:m.org"));
24217
24218        // Empty string clears Option
24219        mx.set_prop("channels.matrix.user_id", "").unwrap();
24220        assert!(mx.user_id.is_none());
24221    }
24222
24223    #[test]
24224    async fn set_prop_enum() {
24225        let mut mx = test_matrix_config();
24226        mx.set_prop("channels.matrix.stream_mode", "partial")
24227            .unwrap();
24228        assert_eq!(mx.stream_mode, StreamMode::Partial);
24229
24230        mx.set_prop("channels.matrix.stream_mode", "multi_message")
24231            .unwrap();
24232        assert_eq!(mx.stream_mode, StreamMode::MultiMessage);
24233    }
24234
24235    #[test]
24236    async fn set_prop_enum_rejects_invalid() {
24237        let mut mx = test_matrix_config();
24238        let err = mx
24239            .set_prop("channels.matrix.stream_mode", "invalid")
24240            .unwrap_err();
24241        assert!(err.to_string().contains("expected one of"));
24242    }
24243
24244    #[test]
24245    async fn set_prop_unknown_path_fails() {
24246        let mut mx = test_matrix_config();
24247        assert!(mx.set_prop("channels.matrix.nonexistent", "val").is_err());
24248    }
24249
24250    #[test]
24251    async fn prop_is_secret_static_check() {
24252        assert!(MatrixConfig::prop_is_secret("channels.matrix.access_token"));
24253        assert!(MatrixConfig::prop_is_secret("channels.matrix.recovery_key"));
24254        assert!(!MatrixConfig::prop_is_secret("channels.matrix.homeserver"));
24255        assert!(!MatrixConfig::prop_is_secret(
24256            "channels.matrix.interrupt_on_new_message"
24257        ));
24258    }
24259
24260    #[test]
24261    async fn apply_env_overrides_rejects_schema_version() {
24262        let _env_guard = env_override_lock().await;
24263        // SAFETY: test-only, single-threaded test runner.
24264        unsafe { std::env::set_var("ZEROCLAW_schema_version", "99") };
24265        let mut config = Config::default();
24266        let result = crate::env_overrides::apply_env_overrides(&mut config);
24267        // SAFETY: test-only, single-threaded test runner.
24268        unsafe { std::env::remove_var("ZEROCLAW_schema_version") };
24269
24270        let err = result.expect_err("schema_version override must be rejected");
24271        let msg = format!("{err:#}");
24272        assert!(
24273            msg.contains("schema_version") && msg.contains("not overridable"),
24274            "error must name the path and the reason: {msg}",
24275        );
24276        // Untouched on rejection.
24277        assert_eq!(
24278            config.schema_version,
24279            crate::migration::CURRENT_SCHEMA_VERSION
24280        );
24281    }
24282
24283    #[test]
24284    async fn prop_is_env_overridden_reflects_env_overridden_paths() {
24285        // Empty by default — no env applied.
24286        let mut cfg = Config::default();
24287        assert!(!cfg.prop_is_env_overridden("channels.matrix.homeserver"));
24288        assert!(!cfg.prop_is_env_overridden("gateway.request_timeout_secs"));
24289
24290        // Populate the field directly (the same set that
24291        // `apply_env_overrides` returns from `load_or_init`).
24292        cfg.env_overridden_paths = std::collections::HashSet::from([
24293            "channels.matrix.homeserver".to_string(),
24294            "gateway.request_timeout_secs".to_string(),
24295        ]);
24296
24297        // True for paths in the list, false for anything else.
24298        assert!(cfg.prop_is_env_overridden("channels.matrix.homeserver"));
24299        assert!(cfg.prop_is_env_overridden("gateway.request_timeout_secs"));
24300        assert!(!cfg.prop_is_env_overridden("channels.matrix.access_token"));
24301        assert!(!cfg.prop_is_env_overridden("gateway.host"));
24302        // Empty path / non-schema path → false.
24303        assert!(!cfg.prop_is_env_overridden(""));
24304        assert!(!cfg.prop_is_env_overridden("does.not.exist"));
24305    }
24306
24307    #[test]
24308    async fn prop_is_secret_routes_through_hashmap_keyed_paths() {
24309        // Regression: the macro's HashMap<String, T> arm previously passed the
24310        // full materialised path (e.g. `model_providers.openrouter.api-key`)
24311        // straight to the inner type's `prop_is_secret`, which then matched on
24312        // its own configurable_prefix and returned false. Result: the CLI's
24313        // `config set --json` and the gateway's PropResponse both took the
24314        // non-secret branch and emitted `{value}` instead of `{populated}` for
24315        // any secret on a map-keyed nested type.
24316        assert!(Config::prop_is_secret(
24317            "providers.models.openrouter.default.api_key"
24318        ));
24319        assert!(Config::prop_is_secret(
24320            "providers.models.anthropic.default.api_key"
24321        ));
24322        assert!(!Config::prop_is_secret(
24323            "providers.models.openrouter.default.endpoint"
24324        ));
24325        assert!(!Config::prop_is_secret(
24326            "providers.models.openrouter.default.context-window"
24327        ));
24328    }
24329
24330    #[test]
24331    async fn typed_custom_slot_round_trips_uri_through_save_and_load() {
24332        // Legacy colon-URL keys (`custom:https://...`) are gone — `custom`
24333        // is a typed slot whose `uri` field carries the operator URL.
24334        // This pins: secret routing, save/encrypt, and round-trip reload
24335        // for the typed `custom` slot.
24336        let dir = TempDir::new().unwrap();
24337        let mut config = Config {
24338            config_path: dir.path().join("config.toml"),
24339            data_dir: dir.path().join("workspace"),
24340            ..Default::default()
24341        };
24342        let alias = "default";
24343        config
24344            .providers
24345            .models
24346            .ensure("custom", alias)
24347            .expect("custom typed slot");
24348
24349        let prefix = format!("providers.models.custom.{alias}");
24350        let api_key_path = format!("{prefix}.api_key");
24351        let uri_path = format!("{prefix}.uri");
24352        let model_path = format!("{prefix}.model");
24353        let temperature_path = format!("{prefix}.temperature");
24354
24355        assert!(
24356            Config::prop_is_secret(&api_key_path),
24357            "typed custom-slot api-key must route through the secret marker",
24358        );
24359
24360        config.set_prop(&api_key_path, "sk-test-custom").unwrap();
24361        config
24362            .set_prop(&uri_path, "https://api.example.invalid/v1")
24363            .unwrap();
24364        config.set_prop(&model_path, "local-large").unwrap();
24365        config.set_prop(&temperature_path, "0.2").unwrap();
24366
24367        let provider = config
24368            .providers
24369            .models
24370            .find("custom", alias)
24371            .expect("custom typed slot entry must be present");
24372        assert_eq!(provider.api_key.as_deref(), Some("sk-test-custom"));
24373        assert_eq!(
24374            provider.uri.as_deref(),
24375            Some("https://api.example.invalid/v1")
24376        );
24377        assert_eq!(provider.model.as_deref(), Some("local-large"));
24378        assert_eq!(provider.temperature, Some(0.2));
24379
24380        assert_eq!(config.get_prop(&api_key_path).unwrap(), "**** (encrypted)");
24381        assert_eq!(
24382            config.get_prop(&uri_path).unwrap(),
24383            "https://api.example.invalid/v1"
24384        );
24385
24386        config.save().await.unwrap();
24387        let raw_toml = tokio::fs::read_to_string(&config.config_path)
24388            .await
24389            .unwrap();
24390        assert!(
24391            raw_toml.contains("[providers.models.custom.default]"),
24392            "saved TOML should write under the typed custom slot",
24393        );
24394        assert!(
24395            !raw_toml.contains("sk-test-custom"),
24396            "saved TOML must not contain the plaintext custom provider API key",
24397        );
24398
24399        let mut loaded: Config = crate::migration::migrate_to_current(&raw_toml).unwrap();
24400        loaded.config_path = config.config_path.clone();
24401        loaded.data_dir = config.data_dir.clone();
24402        let store = crate::secrets::SecretStore::new(dir.path(), loaded.secrets.encrypt);
24403        loaded.decrypt_secrets(&store).unwrap();
24404        let loaded_provider = loaded
24405            .providers
24406            .models
24407            .find("custom", alias)
24408            .expect("typed custom slot entry must round-trip through save/load");
24409        assert_eq!(loaded_provider.api_key.as_deref(), Some("sk-test-custom"));
24410        assert_eq!(
24411            loaded_provider.uri.as_deref(),
24412            Some("https://api.example.invalid/v1")
24413        );
24414        assert_eq!(loaded_provider.model.as_deref(), Some("local-large"));
24415        assert_eq!(loaded_provider.temperature, Some(0.2));
24416    }
24417
24418    #[test]
24419    async fn env_override_save_cycle_preserves_on_disk_secret() {
24420        // Regression bar for the data-loss bug identified in PR
24421        // review: an operator with a real on-disk credential who sets a
24422        // `ZEROCLAW_*` env override for the same path and triggers any
24423        // save (dashboard auto-save, CLI `config set` for an unrelated
24424        // field, Quickstart finalizer) must NOT corrupt the disk file.
24425        //
24426        // Pre-fix behavior: `mask_env_overrides_for_save` read disk via
24427        // `get_prop`, which returns `"**** (encrypted)"` for secret-typed
24428        // fields regardless of underlying state. That mask string then got
24429        // re-encrypted as plaintext and written to disk, destroying the
24430        // operator's real credential on the next reload.
24431        //
24432        // Post-fix: `apply_env_overrides` snapshots the post-decrypt
24433        // plaintext at apply time; `mask_env_overrides_for_save` restores
24434        // from that snapshot before `encrypt_secrets()` runs. The disk
24435        // secret survives the cycle.
24436        let dir = TempDir::new().unwrap();
24437        let mut config = Config {
24438            config_path: dir.path().join("config.toml"),
24439            data_dir: dir.path().join("workspace"),
24440            ..Default::default()
24441        };
24442        let original_secret = "sk-ant-real-on-disk-credential";
24443        let api_key_path = "providers.models.anthropic.default.api_key";
24444        config
24445            .providers
24446            .models
24447            .ensure("anthropic", "default")
24448            .expect("typed slot");
24449        config.set_prop(api_key_path, original_secret).unwrap();
24450
24451        // First save: encrypts the original plaintext, writes to disk.
24452        config.save().await.unwrap();
24453
24454        // Reload from disk to confirm the original landed correctly.
24455        let raw = tokio::fs::read_to_string(&config.config_path)
24456            .await
24457            .unwrap();
24458        let mut reloaded: Config = crate::migration::migrate_to_current(&raw).unwrap();
24459        reloaded.config_path = config.config_path.clone();
24460        reloaded.data_dir = config.data_dir.clone();
24461        let store = crate::secrets::SecretStore::new(dir.path(), reloaded.secrets.encrypt);
24462        reloaded.decrypt_secrets(&store).unwrap();
24463        assert_eq!(
24464            reloaded
24465                .providers
24466                .models
24467                .anthropic
24468                .get("default")
24469                .and_then(|c| c.base.api_key.as_deref()),
24470            Some(original_secret),
24471            "baseline: original secret round-trips through one save/reload cycle",
24472        );
24473
24474        // Simulate `apply_env_overrides` having injected a different value
24475        // for the same path — this is the state `Config::load_or_init`
24476        // leaves the in-memory config in when an operator boots with
24477        // `ZEROCLAW_providers__models__anthropic__default__api_key=...`
24478        // set in the environment.
24479        let env_value = "sk-ant-from-env-DIFFERENT";
24480        reloaded.env_overridden_paths = std::collections::HashSet::from([api_key_path.to_string()]);
24481        reloaded.pre_override_snapshots = std::collections::HashMap::from([(
24482            api_key_path.to_string(),
24483            original_secret.to_string(),
24484        )]);
24485        reloaded.set_prop(api_key_path, env_value).unwrap();
24486
24487        // Save again. With the pre-fix code path, this is the moment the
24488        // disk file got corrupted with the encrypted display mask.
24489        reloaded.save().await.unwrap();
24490
24491        // Reload, decrypt, and confirm the original secret survived
24492        // (and the env value did NOT leak to disk, and the literal mask
24493        // string was NOT persisted).
24494        let raw_after = tokio::fs::read_to_string(&reloaded.config_path)
24495            .await
24496            .unwrap();
24497        assert!(
24498            !raw_after.contains(env_value),
24499            "env-injected value must never reach disk: {raw_after}",
24500        );
24501        assert!(
24502            !raw_after.contains("**** (encrypted)"),
24503            "display mask must never be persisted as a secret value: {raw_after}",
24504        );
24505
24506        let mut after: Config = crate::migration::migrate_to_current(&raw_after).unwrap();
24507        after.config_path = reloaded.config_path.clone();
24508        after.data_dir = reloaded.data_dir.clone();
24509        let store2 = crate::secrets::SecretStore::new(dir.path(), after.secrets.encrypt);
24510        after.decrypt_secrets(&store2).unwrap();
24511        assert_eq!(
24512            after
24513                .providers
24514                .models
24515                .anthropic
24516                .get("default")
24517                .and_then(|c| c.base.api_key.as_deref()),
24518            Some(original_secret),
24519            "original on-disk secret must survive an env-override + save cycle",
24520        );
24521    }
24522
24523    #[cfg(unix)]
24524    #[test]
24525    async fn onepassword_reference_survives_load_save_cycle() {
24526        let _env_guard = env_override_lock().await;
24527        let dir = TempDir::new().unwrap();
24528        let bin_dir = dir.path().join("bin");
24529        std::fs::create_dir_all(&bin_dir).unwrap();
24530        write_fake_op(
24531            &bin_dir,
24532            r#"#!/bin/sh
24533if [ "$1" = "read" ] && [ "$2" = "op://zeroclaw/provider/openai-api-key" ]; then
24534  printf '%s\n' 'sk-proj-from-onepassword'
24535  exit 0
24536fi
24537printf '%s\n' 'unexpected op invocation' >&2
24538exit 65
24539"#,
24540        );
24541        let path = match std::env::var_os("PATH") {
24542            Some(existing) if !existing.is_empty() => {
24543                format!("{}:{}", bin_dir.display(), existing.to_string_lossy())
24544            }
24545            _ => bin_dir.display().to_string(),
24546        };
24547        let _path_guard = EnvValueGuard::set("PATH", path);
24548        let _config_guard = EnvValueGuard::set("ZEROCLAW_CONFIG_DIR", dir.path());
24549        let _workspace_guard = EnvValueGuard::remove("ZEROCLAW_WORKSPACE");
24550
24551        let config_path = dir.path().join("config.toml");
24552        std::fs::write(
24553            &config_path,
24554            r#"
24555schema_version = 3
24556
24557[providers.models.openai.default]
24558model = "gpt-5"
24559api_key = "op://zeroclaw/provider/openai-api-key"
24560"#,
24561        )
24562        .unwrap();
24563
24564        let config = Config::load_or_init().await.unwrap();
24565        assert_eq!(
24566            config
24567                .providers
24568                .models
24569                .openai
24570                .get("default")
24571                .and_then(|entry| entry.base.api_key.as_deref()),
24572            Some("sk-proj-from-onepassword"),
24573            "runtime config uses resolved 1Password secret"
24574        );
24575
24576        config.save().await.unwrap();
24577        let raw_after = std::fs::read_to_string(&config_path).unwrap();
24578        assert!(
24579            raw_after.contains("op://zeroclaw/provider/openai-api-key"),
24580            "on-disk config must keep the 1Password reference: {raw_after}"
24581        );
24582        assert!(
24583            !raw_after.contains("sk-proj-from-onepassword"),
24584            "resolved secret must not be written back to disk: {raw_after}"
24585        );
24586    }
24587
24588    #[cfg(unix)]
24589    #[allow(
24590        clippy::disallowed_methods,
24591        reason = "test asserts Tokio worker responsiveness"
24592    )]
24593    #[test(flavor = "multi_thread", worker_threads = 1)]
24594    async fn onepassword_reference_load_does_not_block_runtime_worker() {
24595        let _env_guard = env_override_lock().await;
24596        let dir = TempDir::new().unwrap();
24597        let bin_dir = dir.path().join("bin");
24598        std::fs::create_dir_all(&bin_dir).unwrap();
24599        write_fake_op(
24600            &bin_dir,
24601            r#"#!/bin/sh
24602if [ "$1" = "read" ] && [ "$2" = "op://zeroclaw/provider/openai-api-key" ]; then
24603  sleep 1
24604  printf '%s\n' 'sk-proj-from-onepassword'
24605  exit 0
24606fi
24607exit 65
24608"#,
24609        );
24610        let path = match std::env::var_os("PATH") {
24611            Some(existing) if !existing.is_empty() => {
24612                format!("{}:{}", bin_dir.display(), existing.to_string_lossy())
24613            }
24614            _ => bin_dir.display().to_string(),
24615        };
24616        let _path_guard = EnvValueGuard::set("PATH", path);
24617        let _config_guard = EnvValueGuard::set("ZEROCLAW_CONFIG_DIR", dir.path());
24618        let _workspace_guard = EnvValueGuard::remove("ZEROCLAW_WORKSPACE");
24619
24620        let config_path = dir.path().join("config.toml");
24621        std::fs::write(
24622            &config_path,
24623            r#"
24624schema_version = 3
24625
24626[providers.models.openai.default]
24627model = "gpt-5"
24628api_key = "op://zeroclaw/provider/openai-api-key"
24629"#,
24630        )
24631        .unwrap();
24632
24633        let started = std::time::Instant::now();
24634        let load_task = tokio::spawn(Config::load_or_init());
24635        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
24636
24637        assert!(
24638            started.elapsed() < std::time::Duration::from_millis(500),
24639            "op:// config load should not block the async runtime worker"
24640        );
24641
24642        let config = load_task.await.unwrap().unwrap();
24643        assert_eq!(
24644            config
24645                .providers
24646                .models
24647                .openai
24648                .get("default")
24649                .and_then(|entry| entry.base.api_key.as_deref()),
24650            Some("sk-proj-from-onepassword")
24651        );
24652    }
24653
24654    #[cfg(unix)]
24655    #[test]
24656    async fn dirty_onepassword_secret_edit_replaces_reference() {
24657        let _env_guard = env_override_lock().await;
24658        let dir = TempDir::new().unwrap();
24659        let bin_dir = dir.path().join("bin");
24660        std::fs::create_dir_all(&bin_dir).unwrap();
24661        write_fake_op(
24662            &bin_dir,
24663            r#"#!/bin/sh
24664printf '%s\n' 'sk-proj-from-onepassword'
24665"#,
24666        );
24667        let path = match std::env::var_os("PATH") {
24668            Some(existing) if !existing.is_empty() => {
24669                format!("{}:{}", bin_dir.display(), existing.to_string_lossy())
24670            }
24671            _ => bin_dir.display().to_string(),
24672        };
24673        let _path_guard = EnvValueGuard::set("PATH", path);
24674        let _config_guard = EnvValueGuard::set("ZEROCLAW_CONFIG_DIR", dir.path());
24675        let _workspace_guard = EnvValueGuard::remove("ZEROCLAW_WORKSPACE");
24676
24677        let config_path = dir.path().join("config.toml");
24678        std::fs::write(
24679            &config_path,
24680            r#"
24681schema_version = 3
24682
24683[providers.models.openai.default]
24684model = "gpt-5"
24685api_key = "op://zeroclaw/provider/openai-api-key"
24686"#,
24687        )
24688        .unwrap();
24689
24690        let mut config = Config::load_or_init().await.unwrap();
24691        config
24692            .set_prop_persistent(
24693                "providers.models.openai.default.api_key",
24694                "sk-proj-new-direct-key",
24695            )
24696            .unwrap();
24697        config.save_dirty().await.unwrap();
24698
24699        let raw_after = std::fs::read_to_string(&config_path).unwrap();
24700        assert!(
24701            !raw_after.contains("op://zeroclaw/provider/openai-api-key"),
24702            "dirty secret edits should replace the old 1Password reference: {raw_after}"
24703        );
24704        assert!(
24705            !raw_after.contains("sk-proj-new-direct-key"),
24706            "direct replacement should still be encrypted at rest: {raw_after}"
24707        );
24708
24709        let stored: Config = toml::from_str(&raw_after).unwrap();
24710        let encrypted = stored
24711            .providers
24712            .models
24713            .openai
24714            .get("default")
24715            .and_then(|entry| entry.base.api_key.as_deref())
24716            .unwrap();
24717        let store = crate::secrets::SecretStore::new(dir.path(), true);
24718        assert_eq!(store.decrypt(encrypted).unwrap(), "sk-proj-new-direct-key");
24719    }
24720
24721    #[test]
24722    async fn enum_variants_callback_returns_values() {
24723        let mx = test_matrix_config();
24724        let fields = mx.prop_fields();
24725        let stream_field = fields
24726            .iter()
24727            .find(|f| f.name == "channels.matrix.stream_mode")
24728            .unwrap();
24729        let variants = (stream_field.enum_variants.unwrap())();
24730        assert!(variants.contains(&"off".to_string()));
24731        assert!(variants.contains(&"partial".to_string()));
24732        assert!(variants.contains(&"multi_message".to_string()));
24733    }
24734
24735    #[test]
24736    async fn map_key_sections_discovers_per_family_provider_slots() {
24737        // Typed-family split: `providers.models` is a struct of typed
24738        // family maps, not a single open HashMap. Each family slot
24739        // (`providers.models.<family>`) is its own Map-kind section; the
24740        // dashboard's "+ Add alias" affordance hangs off the family path.
24741        let sections = Config::map_key_sections();
24742        let anthropic = sections
24743            .iter()
24744            .find(|s| s.path == "providers.models.anthropic")
24745            .expect("providers.models.anthropic must be discoverable as a map-keyed section");
24746        assert_eq!(anthropic.kind, crate::traits::MapKeyKind::Map);
24747        assert_eq!(anthropic.value_type, "AnthropicModelProviderConfig");
24748
24749        // agents is also #[nested] HashMap on root Config.
24750        assert!(
24751            sections.iter().any(|s| s.path == "agents"),
24752            "agents map should be discoverable"
24753        );
24754
24755        // mcp.servers is a Vec<McpServerConfig> with #[nested] — should
24756        // surface as a List-kind section so the dashboard's "+ Add MCP
24757        // server" affordance picks it up. Without this, dashboard users
24758        // hit a silent dead-end and have to hand-edit config.toml. Pinned
24759        // here so a regression that drops the #[nested] annotation or the
24760        // Configurable derive on McpServerConfig fails CI.
24761        let mcp_servers = sections
24762            .iter()
24763            .find(|s| s.path == "mcp.servers")
24764            .expect("mcp.servers must be discoverable as a list-shaped section");
24765        assert_eq!(mcp_servers.kind, crate::traits::MapKeyKind::List);
24766        assert_eq!(mcp_servers.value_type, "McpServerConfig");
24767    }
24768
24769    #[test]
24770    async fn create_map_key_inserts_default_mcp_server() {
24771        // Round-trip: `POST /api/config/map-key?path=mcp.servers&key=github`.
24772        // The new entry's `name` field is initialized to the supplied key
24773        // by the macro's List-kind insertion logic.
24774        let mut config = Config::default();
24775        assert!(config.mcp.servers.is_empty());
24776
24777        let created = config
24778            .create_map_key("mcp.servers", "github")
24779            .expect("mcp.servers should accept new list entries");
24780        assert!(created, "first add should report created=true");
24781        assert_eq!(config.mcp.servers.len(), 1);
24782        assert_eq!(
24783            config.mcp.servers[0].name, "github",
24784            "new entry must carry the supplied key as its name field"
24785        );
24786    }
24787
24788    #[test]
24789    async fn create_map_key_inserts_default_alias_under_typed_family() {
24790        // Dashboard "+ Add alias" target is the typed family slot,
24791        // not a free-form provider key under `providers.models`.
24792        let mut config = Config::default();
24793        assert!(
24794            !config
24795                .providers
24796                .models
24797                .contains_model_provider_type("anthropic")
24798        );
24799
24800        let created = config
24801            .create_map_key("providers.models.anthropic", "default")
24802            .expect("typed family slot should accept a new alias");
24803        assert!(created, "first add should report created=true");
24804        assert!(
24805            config
24806                .providers
24807                .models
24808                .find("anthropic", "default")
24809                .is_some(),
24810            "the new alias must show up under the typed family slot",
24811        );
24812
24813        // Idempotent: second add returns false, doesn't error.
24814        let again = config
24815            .create_map_key("providers.models.anthropic", "default")
24816            .expect("second add still resolves the section");
24817        assert!(!again, "duplicate add should report created=false");
24818    }
24819
24820    #[test]
24821    async fn ensure_map_key_for_path_materializes_typed_provider_maps() {
24822        for (path, value) in [
24823            ("providers.models.openai.default.model", "gpt-4o"),
24824            ("providers.tts.openai.default.voice", "alloy"),
24825            ("providers.transcription.openai.default.model", "whisper-1"),
24826            ("channels.telegram.default.bot_token", "tok"),
24827        ] {
24828            let mut config = Config::default();
24829            assert!(
24830                config.set_prop(path, value).is_err(),
24831                "precondition: {path} is unknown on a fresh config"
24832            );
24833            config.ensure_map_key_for_path(path);
24834            assert!(
24835                config.set_prop(path, value).is_ok(),
24836                "{path} must be settable after ensure_map_key_for_path"
24837            );
24838        }
24839    }
24840
24841    #[test]
24842    async fn ensure_map_key_for_path_ignores_plain_fields() {
24843        let mut config = Config::default();
24844        config.ensure_map_key_for_path("gateway.port");
24845        config.ensure_map_key_for_path("locale");
24846        assert!(config.set_prop("gateway.port", "8080").is_ok());
24847    }
24848
24849    #[test]
24850    async fn create_map_key_rejects_unknown_section() {
24851        let mut config = Config::default();
24852        let err = config
24853            .create_map_key("not.a.real.section", "anything")
24854            .expect_err("unknown section path should error");
24855        assert!(err.contains("not.a.real.section"));
24856    }
24857
24858    #[test]
24859    async fn provider_slot_names_match_struct_fields() {
24860        // TtsProviders/TranscriptionProviders::slot_names are inline lists
24861        // (their slot macros carry a rate-type param); pin them against the
24862        // actual serialized field names so adding a family without updating
24863        // slot_names fails here.
24864        let tts = toml::Value::try_from(crate::providers::TtsProviders {
24865            openai: std::iter::once(("a".to_string(), Default::default())).collect(),
24866            elevenlabs: std::iter::once(("a".to_string(), Default::default())).collect(),
24867            google: std::iter::once(("a".to_string(), Default::default())).collect(),
24868            edge: std::iter::once(("a".to_string(), Default::default())).collect(),
24869            piper: std::iter::once(("a".to_string(), Default::default())).collect(),
24870        })
24871        .unwrap();
24872        let mut tts_fields: Vec<&str> =
24873            tts.as_table().unwrap().keys().map(String::as_str).collect();
24874        tts_fields.sort_unstable();
24875        let mut tts_slots = crate::providers::TtsProviders::slot_names().to_vec();
24876        tts_slots.sort_unstable();
24877        assert_eq!(tts_fields, tts_slots);
24878
24879        let tr = toml::Value::try_from(crate::providers::TranscriptionProviders {
24880            groq: std::iter::once(("a".to_string(), Default::default())).collect(),
24881            openai: std::iter::once(("a".to_string(), Default::default())).collect(),
24882            deepgram: std::iter::once(("a".to_string(), Default::default())).collect(),
24883            assemblyai: std::iter::once(("a".to_string(), Default::default())).collect(),
24884            google: std::iter::once(("a".to_string(), Default::default())).collect(),
24885            local_whisper: std::iter::once(("a".to_string(), Default::default())).collect(),
24886        })
24887        .unwrap();
24888        let mut tr_fields: Vec<&str> = tr.as_table().unwrap().keys().map(String::as_str).collect();
24889        tr_fields.sort_unstable();
24890        let mut tr_slots = crate::providers::TranscriptionProviders::slot_names().to_vec();
24891        tr_slots.sort_unstable();
24892        assert_eq!(tr_fields, tr_slots);
24893    }
24894
24895    #[test]
24896    async fn unknown_provider_families_flags_silent_serde_drop() {
24897        // serde ignores unknown keys under providers.models, so a typo'd
24898        // family parses cleanly and its aliases vanish on reload. The
24899        // detector must flag it; known families must pass.
24900        let raw = r#"
24901schema_version = 3
24902
24903[providers.models.antropic.main]
24904model = "claude-sonnet-4-6"
24905
24906[providers.models.openai.work]
24907model = "gpt-4o"
24908"#;
24909        let parsed: Config = toml::from_str(raw).expect("unknown family must not fail parse");
24910        assert!(
24911            parsed.providers.models.find("antropic", "main").is_none(),
24912            "precondition: serde silently drops the unknown family"
24913        );
24914        assert_eq!(
24915            Config::unknown_provider_families(raw),
24916            vec!["models.antropic".to_string()]
24917        );
24918        assert_eq!(
24919            Config::unknown_provider_families(
24920                "schema_version = 3\n[providers.tts.bogustts.x]\nenabled = true\n",
24921            ),
24922            vec!["tts.bogustts".to_string()]
24923        );
24924        assert!(Config::unknown_provider_families("not even toml {{{").is_empty());
24925        // Hostile shapes: scalar providers node, scalar kind node,
24926        // array-of-tables family. as_table() filters all of them; the
24927        // detector must stay silent rather than panic or false-positive.
24928        assert!(Config::unknown_provider_families("providers = 3\n").is_empty());
24929        assert!(Config::unknown_provider_families("[providers]\nmodels = 3\n").is_empty());
24930        assert_eq!(
24931            Config::unknown_provider_families("[[providers.models.weird]]\nx = 1\n"),
24932            vec!["models.weird".to_string()],
24933            "array-of-tables under an unknown family is still an unknown family"
24934        );
24935    }
24936
24937    #[test]
24938    async fn map_key_create_survives_incremental_save() {
24939        // Repro for the zerocode "providers vanish after restart" report:
24940        // the RPC config/map-key-create path is create_map_key + mark_dirty
24941        // + save_dirty. The new alias must reach config.toml, otherwise it
24942        // exists only in-memory and a daemon restart silently drops it
24943        // (and any agents.*.model_provider referencing it dangles).
24944        let tmp = tempfile::TempDir::new().unwrap();
24945        let config_path = tmp.path().join("config.toml");
24946
24947        // Seed a non-empty on-disk file so the incremental path runs, not
24948        // the new-file fallback to full save().
24949        std::fs::write(
24950            &config_path,
24951            "schema_version = 9\n\n[observability]\nbackend = \"none\"\n",
24952        )
24953        .unwrap();
24954
24955        let mut config = Config {
24956            config_path: config_path.clone(),
24957            ..Default::default()
24958        };
24959        let created = config
24960            .create_map_key("providers.models.openai", "myalias")
24961            .expect("typed family slot accepts a new alias");
24962        assert!(created);
24963        config.mark_dirty("providers.models.openai.myalias");
24964        config.save_dirty().await.unwrap();
24965
24966        let written = std::fs::read_to_string(&config_path).unwrap();
24967        let reloaded: Config = toml::from_str(&written)
24968            .unwrap_or_else(|e| panic!("rewritten config must reparse: {e}\n---\n{written}"));
24969        assert!(
24970            reloaded
24971                .providers
24972                .models
24973                .find("openai", "myalias")
24974                .is_some(),
24975            "created alias must survive save_dirty + reload; got:\n{written}"
24976        );
24977    }
24978
24979    #[test]
24980    async fn init_defaults_instantiates_none_sections() {
24981        let mut config = Config::default();
24982        assert!(config.channels.matrix.is_empty());
24983
24984        // Channels are HashMaps — init_defaults cannot insert a default key
24985        // (there is no meaningful default alias). Callers use create_map_key.
24986        config
24987            .create_map_key("channels.matrix", "default")
24988            .expect("create_map_key should insert a default matrix entry");
24989        assert!(
24990            config.channels.matrix.contains_key("default"),
24991            "create_map_key must add the 'default' alias"
24992        );
24993
24994        // init_defaults on an already-populated map section is a no-op.
24995        let initialized = config.init_defaults(Some("channels.matrix"));
24996        assert!(
24997            !initialized.contains(&"channels.matrix"),
24998            "init_defaults should not report channels.matrix when entry already exists"
24999        );
25000    }
25001
25002    #[test]
25003    async fn deserialized_matrix_set_prop_round_trips_vec_string() {
25004        // Mirror the real-world daemon flow: config loaded from disk where
25005        // [channels.matrix] is present (possibly with all default fields),
25006        // then a PATCH from the dashboard hits set_prop.
25007        let toml_src = r#"
25008schema_version = 3
25009
25010[channels.matrix.default]
25011enabled = false
25012homeserver = ""
25013access_token = ""
25014allowed_rooms = []
25015allowed_users = []
25016"#;
25017        let mut config: Config = toml::from_str(toml_src).expect("parse toml");
25018        assert!(
25019            config.channels.matrix.contains_key("default"),
25020            "matrix must have a 'default' alias after deserialize"
25021        );
25022
25023        config
25024            .set_prop(
25025                "channels.matrix.default.allowed_rooms",
25026                r#"["alice","bob"]"#,
25027            )
25028            .expect("set_prop should succeed against deserialized matrix");
25029        assert_eq!(
25030            config.channels.matrix.get("default").unwrap().allowed_rooms,
25031            vec!["alice".to_string(), "bob".to_string()],
25032        );
25033    }
25034
25035    #[test]
25036    async fn init_defaults_then_set_prop_round_trips_vec_string() {
25037        // Regression for #6175 Channels picker → form → save:
25038        // 1. create_map_key inserts channels.matrix["default"] = MatrixConfig::default()
25039        // 2. set_prop on channels.matrix.default.allowed_rooms must accept a JSON-array
25040        //    string (the shape coerce_for_set_prop emits for Vec<String>).
25041        // 3. get_prop reads it back.
25042        let mut config = Config::default();
25043        config
25044            .create_map_key("channels.matrix", "default")
25045            .expect("create_map_key should insert a default matrix entry");
25046        assert!(config.channels.matrix.contains_key("default"));
25047
25048        // prop_fields must surface the kebab path so the form can render it.
25049        let has_field = config
25050            .prop_fields()
25051            .iter()
25052            .any(|f| f.name == "channels.matrix.default.allowed_rooms");
25053        assert!(
25054            has_field,
25055            "channels.matrix.default.allowed_rooms must appear in prop_fields after init"
25056        );
25057
25058        // set_prop with the JSON-array string the gateway PATCH path produces.
25059        config
25060            .set_prop(
25061                "channels.matrix.default.allowed_rooms",
25062                r#"["alice","bob"]"#,
25063            )
25064            .expect("set_prop should accept JSON-array string for Vec<String>");
25065        assert_eq!(
25066            config.channels.matrix.get("default").unwrap().allowed_rooms,
25067            vec!["alice".to_string(), "bob".to_string()],
25068        );
25069    }
25070
25071    #[test]
25072    async fn mcp_servers_addable_via_create_map_key_and_per_entry_props() {
25073        // `mcp.servers` is a `Vec<McpServerConfig>` with `#[nested]`, so the
25074        // `Configurable` derive surfaces it as a List section (not an
25075        // ObjectArray prop) — operators add servers via
25076        // `POST /api/config/map-key?path=mcp.servers&key=<name>` and edit
25077        // each server's fields via per-prop GET/PUT.
25078        //
25079        // This replaces the prior model where the entire Vec round-tripped
25080        // through set_prop("mcp.servers", "<json-array>"). The List model
25081        // matches the rest of the schema (`providers.models`, `agents`,
25082        // etc.) and gives the dashboard a per-field editor instead of a
25083        // monolithic JSON blob.
25084        let mut config = Config::default();
25085
25086        // The List section is discoverable.
25087        let sections = Config::map_key_sections();
25088        assert!(
25089            sections
25090                .iter()
25091                .any(|s| s.path == "mcp.servers" && s.kind == crate::traits::MapKeyKind::List),
25092            "mcp.servers should surface as a List section in map_key_sections()"
25093        );
25094
25095        // create_map_key inserts a default-valued entry and seeds its
25096        // `name` field from the supplied key.
25097        config
25098            .create_map_key("mcp.servers", "fs")
25099            .expect("mcp.servers should accept new list entries via create_map_key");
25100        assert_eq!(config.mcp.servers.len(), 1);
25101        assert_eq!(config.mcp.servers[0].name, "fs");
25102
25103        // Per-entry fields are mutated via standard set_prop on the inner
25104        // path (the same call site the per-prop PUT handler uses); the
25105        // McpServerConfig schema's `#[prefix = "mcp.servers"]` makes the
25106        // path resolution work without hand-table dispatch.
25107        // (Wider per-entry path routing through Vec<T> requires a
25108        // future generalization of route_hashmap_path-equivalent for
25109        // List sections; tracked as future work.)
25110    }
25111
25112    #[test]
25113    async fn init_defaults_skips_already_set() {
25114        let mut config = Config::default();
25115        config
25116            .channels
25117            .matrix
25118            .insert("default".to_string(), test_matrix_config());
25119
25120        let initialized = config.init_defaults(Some("channels.matrix"));
25121        // Already set — should not re-initialize
25122        assert!(!initialized.contains(&"channels.matrix"));
25123        // Original value preserved
25124        assert_eq!(
25125            config.channels.matrix.get("default").unwrap().homeserver,
25126            "https://m.org"
25127        );
25128    }
25129
25130    #[test]
25131    async fn nested_get_set_prop_traverses_config_tree() {
25132        let mut config = Config::default();
25133        config
25134            .channels
25135            .matrix
25136            .insert("default".to_string(), test_matrix_config());
25137
25138        // get_prop traverses Config → ChannelsConfig → channels.matrix["default"] → MatrixConfig
25139        assert_eq!(
25140            config
25141                .get_prop("channels.matrix.default.homeserver")
25142                .unwrap(),
25143            "https://m.org"
25144        );
25145
25146        // set_prop traverses the same path
25147        config
25148            .set_prop("channels.matrix.default.homeserver", "https://new.org")
25149            .unwrap();
25150        assert_eq!(
25151            config.channels.matrix.get("default").unwrap().homeserver,
25152            "https://new.org"
25153        );
25154    }
25155
25156    #[test]
25157    async fn hashmap_nested_encrypt_decrypt_traverses_values() {
25158        let dir = TempDir::new().unwrap();
25159        let store = crate::secrets::SecretStore::new(dir.path(), true);
25160
25161        let mut config = Config::default();
25162        config.providers.models.openrouter.insert(
25163            "test".into(),
25164            crate::schema::OpenRouterModelProviderConfig {
25165                base: ModelProviderConfig {
25166                    api_key: Some("secret-key".into()),
25167                    ..Default::default()
25168                },
25169            },
25170        );
25171
25172        config.encrypt_secrets(&store).unwrap();
25173        let encrypted_key = config
25174            .providers
25175            .models
25176            .find("openrouter", "test")
25177            .expect("entry exists")
25178            .api_key
25179            .as_ref()
25180            .unwrap();
25181        assert!(crate::secrets::SecretStore::is_encrypted(encrypted_key));
25182
25183        config.decrypt_secrets(&store).unwrap();
25184        assert_eq!(
25185            config
25186                .providers
25187                .models
25188                .find("openrouter", "test")
25189                .expect("entry exists")
25190                .api_key
25191                .as_deref(),
25192            Some("secret-key")
25193        );
25194    }
25195
25196    #[test]
25197    async fn vec_secret_encrypt_decrypt_traverses_elements() {
25198        let dir = TempDir::new().unwrap();
25199        let store = crate::secrets::SecretStore::new(dir.path(), true);
25200
25201        let mut config = Config::default();
25202        config.gateway.paired_tokens = vec!["token-a".into(), "token-b".into()];
25203
25204        config.encrypt_secrets(&store).unwrap();
25205        for token in &config.gateway.paired_tokens {
25206            assert!(crate::secrets::SecretStore::is_encrypted(token));
25207        }
25208
25209        config.decrypt_secrets(&store).unwrap();
25210        assert_eq!(config.gateway.paired_tokens, vec!["token-a", "token-b"]);
25211    }
25212
25213    /// Walk every property on a default Config: get_prop must succeed,
25214    /// and set_prop must round-trip for non-secret, non-enum scalar fields.
25215    #[test]
25216    async fn every_prop_is_gettable_and_settable() {
25217        let mut config = Config::default();
25218        // Initialize all Option<T> sections so their fields are reachable
25219        config.init_defaults(None);
25220
25221        let fields = config.prop_fields();
25222        assert!(
25223            fields.len() > 50,
25224            "Expected 50+ props, got {} — macro may be skipping fields",
25225            fields.len()
25226        );
25227
25228        for field in &fields {
25229            // get_prop must not panic or error
25230            let get_result = config.get_prop(&field.name);
25231            assert!(
25232                get_result.is_ok(),
25233                "get_prop failed for '{}': {}",
25234                field.name,
25235                get_result.unwrap_err()
25236            );
25237
25238            // set_prop: round-trip the display value back through set_prop.
25239            // Skip secrets (masked), enums (need valid variant), and <unset> Options.
25240            if field.is_secret
25241                || field.is_enum()
25242                || field.display_value == crate::traits::UNSET_DISPLAY
25243            {
25244                continue;
25245            }
25246
25247            let set_result = config.set_prop(&field.name, &field.display_value);
25248            assert!(
25249                set_result.is_ok(),
25250                "set_prop failed for '{}' with value '{}': {}",
25251                field.name,
25252                field.display_value,
25253                set_result.unwrap_err()
25254            );
25255
25256            // Value should survive the round-trip
25257            let after = config.get_prop(&field.name).unwrap();
25258            assert_eq!(
25259                after, field.display_value,
25260                "round-trip mismatch for '{}': set '{}', got '{}'",
25261                field.name, field.display_value, after
25262            );
25263        }
25264    }
25265
25266    /// Audit gate: every path emitted by `prop_fields()` must round-trip
25267    /// through `get_prop`. The CLI (`zeroclaw config get/set`), the TUI
25268    /// Quickstart prompts (`prompt_field`), the gateway list endpoint
25269    /// (`/api/config/list`), and the dashboard form all derive from
25270    /// `prop_fields()`; if a path appears here but `get_prop` rejects
25271    /// it, that field is unreachable on every surface.
25272    ///
25273    /// `init_defaults(None)` populates Option-shaped subsections (memory
25274    /// backend specifics, tunnel provider details, etc.) so the walk
25275    /// also exercises fields that only materialize once a backend is
25276    /// chosen.
25277    #[test]
25278    async fn every_prop_field_path_is_reachable_via_get_prop() {
25279        let mut config = Config::default();
25280        config.init_defaults(None);
25281        for field in config.prop_fields() {
25282            let result = config.get_prop(&field.name);
25283            assert!(
25284                result.is_ok(),
25285                "get_prop('{}') failed: {} \u{2014} prop_fields() advertises a path \
25286                 that the CLI / gateway / TUI all expect to be readable. \
25287                 Either the macro emits the path but routing is missing, \
25288                 or the field shouldn't be in prop_fields().",
25289                field.name,
25290                result.unwrap_err()
25291            );
25292        }
25293    }
25294
25295    /// Audit gate for RFC #6971 Phase 0: any credential-shaped property path
25296    /// that reaches the CLI/gateway/TUI property surface must have an explicit
25297    /// classification. This catches future config additions whose names imply
25298    /// credential handling before they silently land without a security call.
25299    #[test]
25300    async fn credential_shaped_prop_fields_have_explicit_classification() {
25301        let mut config = Config::default();
25302        config.init_defaults(None);
25303        config
25304            .providers
25305            .models
25306            .anthropic
25307            .insert("default".into(), AnthropicModelProviderConfig::default());
25308        config
25309            .providers
25310            .tts
25311            .openai
25312            .insert("default".into(), OpenAITtsProviderConfig::default());
25313        config.providers.transcription.openai.insert(
25314            "default".into(),
25315            OpenAiTranscriptionProviderConfig::default(),
25316        );
25317        config.providers.transcription.local_whisper.insert(
25318            "default".into(),
25319            LocalWhisperTranscriptionProviderConfig::default(),
25320        );
25321        config
25322            .channels
25323            .matrix
25324            .insert("default".into(), MatrixConfig::default());
25325        config
25326            .storage
25327            .qdrant
25328            .insert("default".into(), QdrantStorageConfig::default());
25329
25330        let fields = config.prop_fields();
25331        let missing: Vec<_> = fields
25332            .iter()
25333            .filter(|field| credential_shaped_prop_path(&field.name))
25334            .filter(|field| field.credential_class.is_none())
25335            .map(|field| field.name.clone())
25336            .collect();
25337
25338        assert!(
25339            missing.is_empty(),
25340            "credential-shaped config fields need explicit classification: {missing:?}"
25341        );
25342
25343        let unmarked_secrets: Vec<_> = fields
25344            .iter()
25345            .filter(|field| {
25346                field.credential_class
25347                    == Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
25348            })
25349            .filter(|field| !field.is_secret && !Config::prop_is_secret(&field.name))
25350            .map(|field| field.name.clone())
25351            .collect();
25352
25353        assert!(
25354            unmarked_secrets.is_empty(),
25355            "EncryptedSecret classifications must route through #[secret]: {unmarked_secrets:?}"
25356        );
25357    }
25358
25359    #[test]
25360    async fn prop_fields_carry_credential_classification_from_schema_fields() {
25361        let mut config = Config::default();
25362        config.init_defaults(None);
25363        config.providers.models.openai.insert(
25364            "codex".into(),
25365            OpenAIModelProviderConfig {
25366                base: ModelProviderConfig {
25367                    requires_openai_auth: true,
25368                    ..ModelProviderConfig::default()
25369                },
25370            },
25371        );
25372        config
25373            .providers
25374            .tts
25375            .openai
25376            .insert("default".into(), OpenAITtsProviderConfig::default());
25377        config.providers.transcription.local_whisper.insert(
25378            "default".into(),
25379            LocalWhisperTranscriptionProviderConfig::default(),
25380        );
25381        config
25382            .channels
25383            .matrix
25384            .insert("default".into(), MatrixConfig::default());
25385
25386        let fields = config.prop_fields();
25387        let class_for = |name: &str| {
25388            fields
25389                .iter()
25390                .find(|field| field.name == name)
25391                .and_then(|field| field.credential_class)
25392        };
25393
25394        assert_eq!(
25395            class_for("providers.models.openai.codex.requires_openai_auth"),
25396            Some(crate::config::CredentialSurfaceClass::ExternalAuthStore)
25397        );
25398        assert_eq!(
25399            class_for("providers.tts.openai.default.api_key"),
25400            Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
25401        );
25402        assert_eq!(
25403            class_for("providers.transcription.local_whisper.default.bearer_token"),
25404            Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
25405        );
25406        assert_eq!(
25407            class_for("channels.matrix.default.access_token"),
25408            Some(crate::config::CredentialSurfaceClass::EncryptedSecret)
25409        );
25410        assert_eq!(
25411            class_for("model_routes"),
25412            Some(crate::config::CredentialSurfaceClass::RequiresFollowUp)
25413        );
25414        assert_eq!(
25415            class_for("embedding_routes"),
25416            Some(crate::config::CredentialSurfaceClass::RequiresFollowUp)
25417        );
25418        assert!(Config::prop_is_secret(
25419            "providers.tts.openai.default.api_key"
25420        ));
25421        assert!(Config::prop_is_secret(
25422            "providers.transcription.local_whisper.default.bearer_token"
25423        ));
25424        assert!(Config::prop_is_secret(
25425            "channels.matrix.default.access_token"
25426        ));
25427    }
25428
25429    fn credential_shaped_prop_path(path: &str) -> bool {
25430        path.split('.').any(|part| {
25431            let normalized = part.replace('_', "-");
25432            let has_term = |needle| normalized.split('-').any(|term| term == needle);
25433            normalized.contains("api-key")
25434                || normalized.contains("api-token")
25435                || normalized.contains("auth-file")
25436                || normalized.contains("auth-header")
25437                || normalized.contains("auth-token")
25438                || normalized.contains("bearer-token")
25439                || normalized.contains("bot-token")
25440                || normalized.contains("access-token")
25441                || normalized.contains("refresh-token")
25442                || normalized.contains("verification-token")
25443                || normalized.contains("paired-tokens")
25444                || part == "token"
25445                || has_term("credential")
25446                || has_term("env")
25447                || has_term("header")
25448                || has_term("headers")
25449                || has_term("password")
25450                || has_term("secret")
25451        })
25452    }
25453
25454    #[test]
25455    async fn object_array_prop_display_redacts_nested_secret_fields() {
25456        let fixture = ObjectArraySecretFixture {
25457            entries: vec![
25458                ObjectArraySecretEntry {
25459                    name: "primary".to_string(),
25460                    token: Some("nested-token-credential".to_string()),
25461                    headers: HashMap::from([
25462                        (
25463                            "Authorization".to_string(),
25464                            "Bearer nested-header-credential".to_string(),
25465                        ),
25466                        ("X-Tenant".to_string(), "tenant-credential".to_string()),
25467                    ]),
25468                },
25469                ObjectArraySecretEntry {
25470                    name: "unset-secret".to_string(),
25471                    token: None,
25472                    headers: HashMap::new(),
25473                },
25474            ],
25475        };
25476
25477        let display_value = fixture
25478            .prop_fields()
25479            .into_iter()
25480            .find(|field| field.name == "test.object_array.entries")
25481            .expect("object-array field should be surfaced")
25482            .display_value;
25483        let readback = fixture
25484            .get_prop("test.object_array.entries")
25485            .expect("object-array field should be readable");
25486
25487        for rendered in [&display_value, &readback] {
25488            assert!(
25489                !rendered.contains("nested-token-credential"),
25490                "object-array display/readback must redact scalar nested secrets: {rendered}"
25491            );
25492            assert!(
25493                !rendered.contains("Bearer nested-header-credential"),
25494                "object-array display/readback must redact nested secret map values: {rendered}"
25495            );
25496            assert!(
25497                !rendered.contains("tenant-credential"),
25498                "object-array display/readback must redact every value in nested secret maps: {rendered}"
25499            );
25500            assert!(
25501                rendered.contains("primary"),
25502                "non-secret object-array fields should remain visible: {rendered}"
25503            );
25504            assert!(
25505                rendered.contains("unset-secret"),
25506                "non-secret fields on entries with unset secrets should remain visible: {rendered}"
25507            );
25508            assert!(
25509                rendered.contains("****"),
25510                "redacted object-array output should show masked placeholders: {rendered}"
25511            );
25512        }
25513
25514        assert!(
25515            display_value.contains(r#""token":null"#),
25516            "JSON display should preserve unset optional secrets as null, not a populated mask: {display_value}"
25517        );
25518    }
25519
25520    #[test]
25521    async fn onboard_state_prop_path_uses_top_level_kebab_field_name() {
25522        let mut config = Config::default();
25523
25524        config
25525            .set_prop("onboard_state.completed_sections", "agents")
25526            .expect("onboard state marker path should be writable");
25527        assert_eq!(
25528            config
25529                .get_prop("onboard_state.completed_sections")
25530                .expect("onboard state marker path should be readable"),
25531            "[\"agents\"]"
25532        );
25533    }
25534
25535    /// `onboard_state.quickstart_completed` is the flag the Quickstart
25536    /// flips when it lands a `BuilderSubmission`. Defaults to `false`
25537    /// so first launches auto-open the Quickstart; round-trips through
25538    /// `set_prop` / `get_prop` like any other top-level config field.
25539    #[test]
25540    async fn onboard_state_quickstart_completed_round_trips() {
25541        let mut config = Config::default();
25542
25543        assert_eq!(
25544            config
25545                .get_prop("onboard_state.quickstart_completed")
25546                .expect("default quickstart-completed should be readable"),
25547            "false",
25548            "fresh configs default to quickstart-completed=false so the \
25549             Quickstart auto-opens on first launch",
25550        );
25551
25552        config
25553            .set_prop("onboard_state.quickstart_completed", "true")
25554            .expect("quickstart-completed should be writable via prop path");
25555        assert_eq!(
25556            config
25557                .get_prop("onboard_state.quickstart_completed")
25558                .expect("quickstart-completed should be readable after set"),
25559            "true"
25560        );
25561    }
25562
25563    #[test]
25564    async fn per_agent_nested_prop_fields_use_agent_alias_paths() {
25565        let mut config = Config::default();
25566        config
25567            .agents
25568            .insert("bob".to_string(), AliasedAgentConfig::default());
25569        config.runtime_profiles.insert(
25570            "fast".to_string(),
25571            crate::schema::RuntimeProfileConfig::default(),
25572        );
25573
25574        let fields = config.prop_fields();
25575        assert!(
25576            fields
25577                .iter()
25578                .any(|field| field.name == "runtime_profiles.fast.history_pruning.enabled"),
25579            "history-pruning is a runtime-profile field, emitted under the profile alias"
25580        );
25581        assert!(
25582            !fields
25583                .iter()
25584                .any(|field| field.name.starts_with("agents.bob.history_pruning")),
25585            "history-pruning must no longer be settable on the agent"
25586        );
25587
25588        config
25589            .set_prop("runtime_profiles.fast.history_pruning.enabled", "true")
25590            .expect("set_prop should accept the runtime-profile nested path");
25591        assert_eq!(
25592            config
25593                .get_prop("runtime_profiles.fast.history_pruning.enabled")
25594                .expect("get_prop should accept the runtime-profile nested path"),
25595            "true"
25596        );
25597    }
25598
25599    /// Audit gate: every non-secret scalar prop round-trips through
25600    /// `set_prop(get_prop(p))`. The CLI's `zeroclaw config set` and the
25601    /// dashboard's PATCH op both rely on this being true so an operator
25602    /// can read a value, edit it locally, and write it back. Vec /
25603    /// object-array fields are skipped — they pass through serde-JSON
25604    /// rather than scalar string parsing.
25605    #[test]
25606    async fn every_scalar_prop_round_trips_through_set_prop() {
25607        let mut config = Config::default();
25608        config.init_defaults(None);
25609        let fields = config.prop_fields();
25610        for field in &fields {
25611            if field.is_secret
25612                || matches!(
25613                    field.kind,
25614                    crate::config::PropKind::StringArray | crate::config::PropKind::ObjectArray
25615                )
25616            {
25617                continue;
25618            }
25619            let value = match config.get_prop(&field.name) {
25620                Ok(v) => v,
25621                Err(_) => continue,
25622            };
25623            // Sentinel for unset Option fields — no round-trip applies.
25624            if value == crate::traits::UNSET_DISPLAY {
25625                continue;
25626            }
25627            let result = config.set_prop(&field.name, &value);
25628            assert!(
25629                result.is_ok(),
25630                "round-trip set_prop('{}', '{}') failed: {}",
25631                field.name,
25632                value,
25633                result.unwrap_err()
25634            );
25635        }
25636    }
25637
25638    /// Every enum field must have a working enum_variants callback, and
25639    /// set_prop must accept each variant it advertises.
25640    #[test]
25641    async fn every_enum_variant_is_settable() {
25642        let mut config = Config::default();
25643        config.init_defaults(None);
25644
25645        for field in config.prop_fields() {
25646            if !field.is_enum() {
25647                continue;
25648            }
25649            let get_variants = field.enum_variants.unwrap_or_else(|| {
25650                panic!("enum field '{}' has no enum_variants callback", field.name)
25651            });
25652            let variants = get_variants();
25653            assert!(
25654                !variants.is_empty(),
25655                "enum field '{}' returned no variants",
25656                field.name
25657            );
25658
25659            for variant in &variants {
25660                let result = config.set_prop(&field.name, variant);
25661                assert!(
25662                    result.is_ok(),
25663                    "set_prop('{}', '{}') failed: {}",
25664                    field.name,
25665                    variant,
25666                    result.unwrap_err()
25667                );
25668            }
25669        }
25670    }
25671
25672    #[test]
25673    async fn channel_approval_timeout_secs_defaults_to_300() {
25674        let discord: DiscordConfig = serde_json::from_str(r#"{"bot_token":"tok"}"#).unwrap();
25675        assert_eq!(discord.approval_timeout_secs, 300);
25676
25677        let slack: SlackConfig = serde_json::from_str(r#"{"bot_token":"tok"}"#).unwrap();
25678        assert_eq!(slack.approval_timeout_secs, 300);
25679
25680        let signal: SignalConfig =
25681            serde_json::from_str(r#"{"http_url":"http://localhost","account":"+1"}"#).unwrap();
25682        assert_eq!(signal.approval_timeout_secs, 300);
25683
25684        let matrix: MatrixConfig = serde_json::from_str(
25685            r#"{"homeserver":"https://matrix.org","access_token":"tok","allowed_users":[]}"#,
25686        )
25687        .unwrap();
25688        assert_eq!(matrix.approval_timeout_secs, 300);
25689
25690        let whatsapp: WhatsAppConfig = serde_json::from_str(r#"{}"#).unwrap();
25691        assert_eq!(whatsapp.approval_timeout_secs, 300);
25692    }
25693
25694    #[test]
25695    async fn channel_approval_timeout_secs_explicit_override() {
25696        let discord: DiscordConfig =
25697            serde_json::from_str(r#"{"bot_token":"tok","approval_timeout_secs":60}"#).unwrap();
25698        assert_eq!(discord.approval_timeout_secs, 60);
25699
25700        let slack: SlackConfig =
25701            serde_json::from_str(r#"{"bot_token":"tok","approval_timeout_secs":120}"#).unwrap();
25702        assert_eq!(slack.approval_timeout_secs, 120);
25703
25704        let signal: SignalConfig = serde_json::from_str(
25705            r#"{"http_url":"http://localhost","account":"+1","approval_timeout_secs":90}"#,
25706        )
25707        .unwrap();
25708        assert_eq!(signal.approval_timeout_secs, 90);
25709
25710        let matrix: MatrixConfig = serde_json::from_str(
25711            r#"{"homeserver":"https://matrix.org","access_token":"tok","allowed_users":[],"approval_timeout_secs":45}"#,
25712        )
25713        .unwrap();
25714        assert_eq!(matrix.approval_timeout_secs, 45);
25715
25716        let whatsapp: WhatsAppConfig =
25717            serde_json::from_str(r#"{"approval_timeout_secs":180}"#).unwrap();
25718        assert_eq!(whatsapp.approval_timeout_secs, 180);
25719    }
25720
25721    // ── Multi-agent cross-reference validators ─────────────────────
25722
25723    /// Build a minimal valid Config with one agent on a configured
25724    /// channel + risk profile + model provider. Each test mutates a
25725    /// single field to provoke a validator.
25726    fn multi_agent_test_config() -> Config {
25727        use crate::providers::ChannelRef;
25728
25729        let mut config = Config::default();
25730
25731        // Risk profile (mandatory for enabled agents).
25732        config
25733            .risk_profiles
25734            .insert("default".to_string(), RiskProfileConfig::default());
25735
25736        // Anthropic model provider (mandatory for the agent).
25737        config.providers.models.anthropic.insert(
25738            "default".to_string(),
25739            AnthropicModelProviderConfig::default(),
25740        );
25741
25742        // A configured Telegram channel the agent can reference. Just
25743        // having the entry in the map is enough for the dotted-alias
25744        // validator; we are not exercising channel-level behavior here.
25745        config
25746            .channels
25747            .telegram
25748            .insert("draft".to_string(), TelegramConfig::default());
25749
25750        // Agent that targets the model provider, risk profile, and
25751        // channel. Default workspace is jailed.
25752        let agent = AliasedAgentConfig {
25753            channels: vec![ChannelRef::new("telegram.draft")],
25754            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
25755            risk_profile: "default".to_string(),
25756            ..AliasedAgentConfig::default()
25757        };
25758        config.agents.insert("alpha".to_string(), agent);
25759
25760        config
25761    }
25762
25763    #[test]
25764    async fn validate_rejects_workspace_access_self_reference() {
25765        let mut config = multi_agent_test_config();
25766        let alpha = config.agents.get_mut("alpha").unwrap();
25767        alpha.workspace.access.insert(
25768            crate::multi_agent::AgentAlias::new("alpha"),
25769            crate::multi_agent::AccessMode::Read,
25770        );
25771        let err = config
25772            .validate()
25773            .expect_err("self-reference must fail validation");
25774        let msg = err.to_string();
25775        assert!(
25776            msg.contains("agents.alpha.workspace.access.alpha"),
25777            "expected field path in error, got: {msg}"
25778        );
25779        assert!(
25780            msg.contains("self-references"),
25781            "expected self-reference explanation, got: {msg}"
25782        );
25783    }
25784
25785    #[test]
25786    async fn validate_rejects_workspace_access_dangling_target() {
25787        let mut config = multi_agent_test_config();
25788        let alpha = config.agents.get_mut("alpha").unwrap();
25789        alpha.workspace.access.insert(
25790            crate::multi_agent::AgentAlias::new("ghost"),
25791            crate::multi_agent::AccessMode::ReadWrite,
25792        );
25793        let err = config
25794            .validate()
25795            .expect_err("dangling target must fail validation");
25796        let msg = err.to_string();
25797        assert!(
25798            msg.contains("agents.ghost is not configured"),
25799            "expected dangling-ref explanation, got: {msg}"
25800        );
25801    }
25802
25803    #[test]
25804    async fn validate_rejects_read_memory_from_self_reference() {
25805        let mut config = multi_agent_test_config();
25806        let alpha = config.agents.get_mut("alpha").unwrap();
25807        alpha
25808            .workspace
25809            .read_memory_from
25810            .push(crate::multi_agent::AgentAlias::new("alpha"));
25811        let err = config
25812            .validate()
25813            .expect_err("self-reference must fail validation");
25814        assert!(
25815            err.to_string().contains("read_memory_from[0]"),
25816            "expected indexed field path, got: {err}"
25817        );
25818    }
25819
25820    #[test]
25821    async fn validate_rejects_read_memory_from_cross_backend() {
25822        let mut config = multi_agent_test_config();
25823
25824        // Add a second agent on Postgres.
25825        let beta = AliasedAgentConfig {
25826            channels: vec![crate::providers::ChannelRef::new("telegram.draft")],
25827            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
25828            risk_profile: "default".to_string(),
25829            memory: crate::multi_agent::AgentMemoryConfig {
25830                backend: crate::multi_agent::MemoryBackendKind::Postgres,
25831            },
25832            ..AliasedAgentConfig::default()
25833        };
25834        config.agents.insert("beta".to_string(), beta);
25835
25836        // Alpha (Sqlite default) tries to read from beta (Postgres).
25837        let alpha = config.agents.get_mut("alpha").unwrap();
25838        alpha
25839            .workspace
25840            .read_memory_from
25841            .push(crate::multi_agent::AgentAlias::new("beta"));
25842
25843        let err = config
25844            .validate()
25845            .expect_err("cross-backend allowlist must fail validation");
25846        let msg = err.to_string();
25847        assert!(
25848            msg.contains("same-backend siblings only"),
25849            "expected cross-backend explanation, got: {msg}"
25850        );
25851    }
25852
25853    #[test]
25854    async fn validate_rejects_peer_group_dangling_member() {
25855        let mut config = multi_agent_test_config();
25856        let group = crate::multi_agent::PeerGroupConfig {
25857            channel: "telegram".to_string(),
25858            agents: vec![
25859                crate::multi_agent::AgentAlias::new("alpha"),
25860                crate::multi_agent::AgentAlias::new("ghost"),
25861            ],
25862            ..crate::multi_agent::PeerGroupConfig::default()
25863        };
25864        config.peer_groups.insert("team_chat".to_string(), group);
25865        let err = config
25866            .validate()
25867            .expect_err("dangling group member must fail validation");
25868        assert!(
25869            err.to_string().contains("peer_groups.team_chat.agents[1]"),
25870            "expected indexed field path, got: {err}"
25871        );
25872    }
25873
25874    #[test]
25875    async fn validate_rejects_peer_group_member_without_channel() {
25876        let mut config = multi_agent_test_config();
25877
25878        // Add a discord channel and a beta agent that ONLY uses discord.
25879        config
25880            .channels
25881            .discord
25882            .insert("ops".to_string(), DiscordConfig::default());
25883        let beta = AliasedAgentConfig {
25884            channels: vec![crate::providers::ChannelRef::new("discord.ops")],
25885            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
25886            risk_profile: "default".to_string(),
25887            ..AliasedAgentConfig::default()
25888        };
25889        config.agents.insert("beta".to_string(), beta);
25890
25891        // Group on telegram.draft includes beta (who only has discord).
25892        let group = crate::multi_agent::PeerGroupConfig {
25893            channel: "telegram".to_string(),
25894            agents: vec![
25895                crate::multi_agent::AgentAlias::new("alpha"),
25896                crate::multi_agent::AgentAlias::new("beta"),
25897            ],
25898            ..crate::multi_agent::PeerGroupConfig::default()
25899        };
25900        config.peer_groups.insert("team_chat".to_string(), group);
25901
25902        let err = config
25903            .validate()
25904            .expect_err("channel-mismatch group member must fail validation");
25905        let msg = err.to_string();
25906        assert!(
25907            msg.contains("agents.beta.channels has no entry of type"),
25908            "expected channel-mismatch explanation, got: {msg}"
25909        );
25910    }
25911
25912    #[test]
25913    async fn validate_accepts_valid_peer_group_with_two_compatible_members() {
25914        let mut config = multi_agent_test_config();
25915
25916        // Beta on the same telegram channel.
25917        let beta = AliasedAgentConfig {
25918            channels: vec![crate::providers::ChannelRef::new("telegram.draft")],
25919            model_provider: crate::providers::ModelProviderRef::new("anthropic.default"),
25920            risk_profile: "default".to_string(),
25921            ..AliasedAgentConfig::default()
25922        };
25923        config.agents.insert("beta".to_string(), beta);
25924
25925        // Group on telegram.draft includes both members.
25926        let group = crate::multi_agent::PeerGroupConfig {
25927            channel: "telegram".to_string(),
25928            agents: vec![
25929                crate::multi_agent::AgentAlias::new("alpha"),
25930                crate::multi_agent::AgentAlias::new("beta"),
25931            ],
25932            ..crate::multi_agent::PeerGroupConfig::default()
25933        };
25934        config.peer_groups.insert("team_chat".to_string(), group);
25935
25936        config
25937            .validate()
25938            .expect("two-member same-channel peer group must validate cleanly");
25939    }
25940
25941    #[test]
25942    async fn config_validate_rejects_classifier_provider_pointing_at_missing_alias() {
25943        // Use the SHARED `typed_provider_refs` validation loop — same error
25944        // surface as tts_provider / transcription_provider.
25945        let toml = r#"
25946            [providers.models.custom.default]
25947            api_key = "k"
25948            model = "qwen3.6-plus"
25949            uri = "https://example.com/v1"
25950            wire_api = "chat_completions"
25951
25952            [risk_profiles.default]
25953            level = "supervised"
25954
25955            [agents.default]
25956            enabled = true
25957            model_provider = "custom.default"
25958            risk_profile = "default"
25959            classifier_provider = "custom.does-not-exist"
25960        "#;
25961        let cfg: Config = toml::from_str(toml).unwrap();
25962        let err = cfg
25963            .validate()
25964            .expect_err("missing alias must fail validate");
25965        let msg = format!("{err:#}");
25966        assert!(
25967            msg.contains("classifier_provider")
25968                && msg.contains("does-not-exist")
25969                && msg.contains("providers.models.custom.does-not-exist is not configured"),
25970            "expected DanglingReference error mentioning field + alias + section, got: {msg}"
25971        );
25972    }
25973
25974    #[test]
25975    async fn config_validate_accepts_classifier_provider_pointing_at_existing_alias() {
25976        let toml = r#"
25977            [providers.models.custom.default]
25978            api_key = "k1"
25979            model = "qwen3.6-plus"
25980            uri = "https://example.com/v1"
25981            wire_api = "chat_completions"
25982
25983            [providers.models.custom.kimi-k2-5]
25984            api_key = "k2"
25985            model = "kimi-k2.5"
25986            uri = "https://example.com/v1"
25987            wire_api = "chat_completions"
25988
25989            [risk_profiles.default]
25990            level = "supervised"
25991
25992            [agents.default]
25993            enabled = true
25994            model_provider = "custom.default"
25995            risk_profile = "default"
25996            classifier_provider = "custom.kimi-k2-5"
25997        "#;
25998        let cfg: Config = toml::from_str(toml).unwrap();
25999        cfg.validate()
26000            .expect("validate must succeed for resolvable ref");
26001        assert_eq!(
26002            cfg.agents
26003                .get("default")
26004                .unwrap()
26005                .classifier_provider
26006                .as_str(),
26007            "custom.kimi-k2-5"
26008        );
26009    }
26010
26011    #[test]
26012    async fn config_validate_accepts_empty_classifier_provider_as_inheritance_signal() {
26013        // No classifier_provider field at all → must validate, must remain
26014        // the empty default. This pins backward compatibility.
26015        let toml = r#"
26016            [providers.models.custom.default]
26017            api_key = "k"
26018            model = "qwen3.6-plus"
26019            uri = "https://example.com/v1"
26020            wire_api = "chat_completions"
26021
26022            [risk_profiles.default]
26023            level = "supervised"
26024
26025            [agents.default]
26026            enabled = true
26027            model_provider = "custom.default"
26028            risk_profile = "default"
26029        "#;
26030        let cfg: Config = toml::from_str(toml).unwrap();
26031        cfg.validate()
26032            .expect("missing classifier_provider must validate");
26033        assert!(
26034            cfg.agents
26035                .get("default")
26036                .unwrap()
26037                .classifier_provider
26038                .is_empty()
26039        );
26040    }
26041
26042    fn provider_entry_with_fallback(fallback: &[&str]) -> OpenAIModelProviderConfig {
26043        OpenAIModelProviderConfig {
26044            base: ModelProviderConfig {
26045                model: Some("gpt-4o".to_string()),
26046                fallback: fallback
26047                    .iter()
26048                    .map(|s| crate::providers::ModelProviderRef::new(*s))
26049                    .collect(),
26050                ..Default::default()
26051            },
26052        }
26053    }
26054
26055    #[test]
26056    async fn fallback_warns_on_dangling_ref() {
26057        let mut config = Config::default();
26058        config.providers.models.openai.insert(
26059            "primary".to_string(),
26060            provider_entry_with_fallback(&["openai.ghost"]),
26061        );
26062
26063        let warnings = config.collect_warnings();
26064        assert_eq!(warnings.len(), 1);
26065        assert_eq!(warnings[0].code, "dangling_fallback_ref");
26066        assert_eq!(
26067            warnings[0].path,
26068            "providers.models.openai.primary.fallback[0]"
26069        );
26070    }
26071
26072    #[test]
26073    async fn fallback_no_warning_when_ref_resolves() {
26074        let mut config = Config::default();
26075        config.providers.models.openai.insert(
26076            "primary".to_string(),
26077            provider_entry_with_fallback(&["openai.backup"]),
26078        );
26079        config
26080            .providers
26081            .models
26082            .openai
26083            .insert("backup".to_string(), provider_entry_with_fallback(&[]));
26084
26085        assert!(config.collect_warnings().is_empty());
26086    }
26087
26088    #[test]
26089    async fn fallback_warns_on_two_node_cycle() {
26090        let mut config = Config::default();
26091        config
26092            .providers
26093            .models
26094            .openai
26095            .insert("a".to_string(), provider_entry_with_fallback(&["openai.b"]));
26096        config
26097            .providers
26098            .models
26099            .openai
26100            .insert("b".to_string(), provider_entry_with_fallback(&["openai.a"]));
26101
26102        let cycle_warnings: Vec<_> = config
26103            .collect_warnings()
26104            .into_iter()
26105            .filter(|w| w.code == "fallback_cycle")
26106            .collect();
26107        assert!(
26108            !cycle_warnings.is_empty(),
26109            "a->b->a must surface at least one fallback_cycle warning"
26110        );
26111    }
26112
26113    #[test]
26114    async fn fallback_self_reference_is_a_cycle() {
26115        let mut config = Config::default();
26116        config.providers.models.openai.insert(
26117            "loop".to_string(),
26118            provider_entry_with_fallback(&["openai.loop"]),
26119        );
26120
26121        let warnings = config.collect_warnings();
26122        assert_eq!(warnings.len(), 1);
26123        assert_eq!(warnings[0].code, "fallback_cycle");
26124    }
26125
26126    #[test]
26127    async fn fallback_empty_ref_is_skipped() {
26128        let mut config = Config::default();
26129        config
26130            .providers
26131            .models
26132            .openai
26133            .insert("primary".to_string(), provider_entry_with_fallback(&[""]));
26134
26135        assert!(config.collect_warnings().is_empty());
26136    }
26137
26138    #[test]
26139    async fn fallback_warns_when_chain_exceeds_max_depth() {
26140        let mut config = Config::default();
26141        let n = crate::providers::MAX_FALLBACK_DEPTH + 2;
26142        for i in 0..n {
26143            let next = if i + 1 < n {
26144                vec![format!("openai.a{}", i + 1)]
26145            } else {
26146                vec![]
26147            };
26148            let refs: Vec<&str> = next.iter().map(String::as_str).collect();
26149            config
26150                .providers
26151                .models
26152                .openai
26153                .insert(format!("a{i}"), provider_entry_with_fallback(&refs));
26154        }
26155
26156        let depth_warnings: Vec<_> = config
26157            .collect_warnings()
26158            .into_iter()
26159            .filter(|w| w.code == "max_fallback_depth_exceeded")
26160            .collect();
26161        assert!(
26162            !depth_warnings.is_empty(),
26163            "a chain deeper than MAX_FALLBACK_DEPTH must surface a max_fallback_depth_exceeded warning"
26164        );
26165    }
26166
26167    #[test]
26168    async fn fallback_models_warns_on_empty_entry() {
26169        let mut config = Config::default();
26170        let mut entry = provider_entry_with_fallback(&[]);
26171        entry.base.fallback_models = vec!["".to_string()];
26172        config
26173            .providers
26174            .models
26175            .openai
26176            .insert("primary".to_string(), entry);
26177
26178        let warnings = config.collect_warnings();
26179        assert_eq!(warnings.len(), 1);
26180        assert_eq!(warnings[0].code, "empty_fallback_model");
26181    }
26182
26183    #[test]
26184    async fn fallback_models_warns_on_duplicate_of_primary() {
26185        let mut config = Config::default();
26186        let mut entry = provider_entry_with_fallback(&[]);
26187        entry.base.fallback_models = vec!["gpt-4o".to_string()];
26188        config
26189            .providers
26190            .models
26191            .openai
26192            .insert("primary".to_string(), entry);
26193
26194        let warnings = config.collect_warnings();
26195        assert_eq!(warnings.len(), 1);
26196        assert_eq!(warnings[0].code, "fallback_model_duplicates_primary");
26197    }
26198
26199    #[test]
26200    async fn fallback_models_distinct_entries_do_not_warn() {
26201        let mut config = Config::default();
26202        let mut entry = provider_entry_with_fallback(&[]);
26203        entry.base.fallback_models = vec!["gpt-4o-mini".to_string()];
26204        config
26205            .providers
26206            .models
26207            .openai
26208            .insert("primary".to_string(), entry);
26209
26210        assert!(config.collect_warnings().is_empty());
26211    }
26212}