Skip to main content

construct/config/
schema.rs

1use crate::config::traits::ChannelConfig;
2use crate::providers::{is_glm_alias, is_zai_alias};
3use crate::security::{AutonomyLevel, DomainMatcher};
4use anyhow::{Context, Result};
5use directories::UserDirs;
6use schemars::JsonSchema;
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::path::{Path, PathBuf};
10use std::sync::{OnceLock, RwLock};
11#[cfg(unix)]
12use tokio::fs::File;
13use tokio::fs::{self, OpenOptions};
14use tokio::io::AsyncWriteExt;
15
16const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
17    "provider.anthropic",
18    "provider.compatible",
19    "provider.copilot",
20    "provider.gemini",
21    "provider.glm",
22    "provider.ollama",
23    "provider.openai",
24    "provider.openrouter",
25    "channel.dingtalk",
26    "channel.discord",
27    "channel.feishu",
28    "channel.lark",
29    "channel.matrix",
30    "channel.mattermost",
31    "channel.nextcloud_talk",
32    "channel.qq",
33    "channel.signal",
34    "channel.slack",
35    "channel.telegram",
36    "channel.wati",
37    "channel.whatsapp",
38    "tool.browser",
39    "tool.composio",
40    "tool.http_request",
41    "tool.pushover",
42    "tool.web_search",
43    "memory.embeddings",
44    "tunnel.custom",
45    "transcription.groq",
46];
47
48const SUPPORTED_PROXY_SERVICE_SELECTORS: &[&str] = &[
49    "provider.*",
50    "channel.*",
51    "tool.*",
52    "memory.*",
53    "tunnel.*",
54    "transcription.*",
55];
56
57static RUNTIME_PROXY_CONFIG: OnceLock<RwLock<ProxyConfig>> = OnceLock::new();
58static RUNTIME_PROXY_CLIENT_CACHE: OnceLock<RwLock<HashMap<String, reqwest::Client>>> =
59    OnceLock::new();
60
61// ── Top-level config ──────────────────────────────────────────────
62
63/// Top-level Construct configuration, loaded from `config.toml`.
64///
65/// Resolution order: `CONSTRUCT_WORKSPACE` env → `active_workspace.toml` marker → `~/.construct/config.toml`.
66#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
67pub struct Config {
68    /// Workspace directory - computed from home, not serialized
69    #[serde(skip)]
70    pub workspace_dir: PathBuf,
71    /// Path to config.toml - computed from home, not serialized
72    #[serde(skip)]
73    pub config_path: PathBuf,
74    /// API key for the selected provider. Overridden by `CONSTRUCT_API_KEY` or `API_KEY` env vars.
75    pub api_key: Option<String>,
76    /// Base URL override for provider API (e.g. "http://10.0.0.1:11434" for remote Ollama)
77    pub api_url: Option<String>,
78    /// Custom API path suffix for OpenAI-compatible / custom providers
79    /// (e.g. "/v2/generate" instead of the default "/v1/chat/completions").
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub api_path: Option<String>,
82    /// Default provider ID or alias (e.g. `"openrouter"`, `"ollama"`, `"anthropic"`). Default: `"openrouter"`.
83    #[serde(alias = "model_provider")]
84    pub default_provider: Option<String>,
85    /// Default model routed through the selected provider (e.g. `"anthropic/claude-sonnet-4-6"`).
86    #[serde(alias = "model")]
87    pub default_model: Option<String>,
88    /// Optional named provider profiles keyed by id (Codex app-server compatible layout).
89    #[serde(default)]
90    pub model_providers: HashMap<String, ModelProviderConfig>,
91    /// Default model temperature (0.0–2.0). Default: `0.7`.
92    #[serde(
93        default = "default_temperature",
94        deserialize_with = "deserialize_temperature"
95    )]
96    pub default_temperature: f64,
97
98    /// HTTP request timeout in seconds for LLM provider API calls. Default: `120`.
99    ///
100    /// Increase for slower backends (e.g., llama.cpp on constrained hardware)
101    /// that need more time processing large contexts.
102    #[serde(default = "default_provider_timeout_secs")]
103    pub provider_timeout_secs: u64,
104
105    /// Maximum output tokens to include in LLM provider API requests.
106    ///
107    /// When set, overrides each provider's built-in default. This is especially
108    /// important for OpenRouter where the platform default (65536) can cause 402
109    /// errors for models with lower output limits.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub provider_max_tokens: Option<u32>,
112
113    /// Extra HTTP headers to include in LLM provider API requests.
114    ///
115    /// Some providers require specific headers (e.g., `User-Agent`, `HTTP-Referer`,
116    /// `X-Title`) for request routing or policy enforcement. Headers defined here
117    /// augment (and override) the program's default headers.
118    ///
119    /// Can also be set via `CONSTRUCT_EXTRA_HEADERS` environment variable using
120    /// the format `Key:Value,Key2:Value2`. Env var headers override config file headers.
121    #[serde(default)]
122    pub extra_headers: HashMap<String, String>,
123
124    /// Observability backend configuration (`[observability]`).
125    #[serde(default)]
126    pub observability: ObservabilityConfig,
127
128    /// Autonomy and security policy configuration (`[autonomy]`).
129    #[serde(default)]
130    pub autonomy: AutonomyConfig,
131
132    /// Trust scoring and regression detection configuration (`[trust]`).
133    #[serde(default)]
134    pub trust: crate::trust::TrustConfig,
135
136    /// Security subsystem configuration (`[security]`).
137    #[serde(default)]
138    pub security: SecurityConfig,
139
140    /// Backup tool configuration (`[backup]`).
141    #[serde(default)]
142    pub backup: BackupConfig,
143
144    /// Data retention and purge configuration (`[data_retention]`).
145    #[serde(default)]
146    pub data_retention: DataRetentionConfig,
147
148    /// Cloud transformation accelerator configuration (`[cloud_ops]`).
149    #[serde(default)]
150    pub cloud_ops: CloudOpsConfig,
151
152    /// Conversational AI agent builder configuration (`[conversational_ai]`).
153    ///
154    /// Experimental / future feature — not yet wired into the agent runtime.
155    /// Omitted from generated config files when disabled (the default).
156    /// Existing configs that already contain this section will continue to
157    /// deserialize correctly thanks to `#[serde(default)]`.
158    #[serde(default, skip_serializing_if = "ConversationalAiConfig::is_disabled")]
159    pub conversational_ai: ConversationalAiConfig,
160
161    /// Managed cybersecurity service configuration (`[security_ops]`).
162    #[serde(default)]
163    pub security_ops: SecurityOpsConfig,
164
165    /// Runtime adapter configuration (`[runtime]`). Controls native vs Docker execution.
166    #[serde(default)]
167    pub runtime: RuntimeConfig,
168
169    /// Reliability settings: retries, fallback providers, backoff (`[reliability]`).
170    #[serde(default)]
171    pub reliability: ReliabilityConfig,
172
173    /// Scheduler configuration for periodic task execution (`[scheduler]`).
174    #[serde(default)]
175    pub scheduler: SchedulerConfig,
176
177    /// Agent orchestration settings (`[agent]`).
178    #[serde(default)]
179    pub agent: AgentConfig,
180
181    /// Pacing controls for slow/local LLM workloads (`[pacing]`).
182    #[serde(default)]
183    pub pacing: PacingConfig,
184
185    /// Skills loading and community repository behavior (`[skills]`).
186    #[serde(default)]
187    pub skills: SkillsConfig,
188
189    /// Pipeline tool configuration (`[pipeline]`).
190    #[serde(default)]
191    pub pipeline: PipelineConfig,
192
193    /// Model routing rules — route `hint:<name>` to specific provider+model combos.
194    #[serde(default)]
195    pub model_routes: Vec<ModelRouteConfig>,
196
197    /// Embedding routing rules — route `hint:<name>` to specific provider+model combos.
198    #[serde(default)]
199    pub embedding_routes: Vec<EmbeddingRouteConfig>,
200
201    /// Automatic query classification — maps user messages to model hints.
202    #[serde(default)]
203    pub query_classification: QueryClassificationConfig,
204
205    /// Heartbeat configuration for periodic health pings (`[heartbeat]`).
206    #[serde(default)]
207    pub heartbeat: HeartbeatConfig,
208
209    /// Cron job configuration (`[cron]`).
210    #[serde(default)]
211    pub cron: CronConfig,
212
213    /// Channel configurations: Telegram, Discord, Slack, etc. (`[channels_config]`).
214    #[serde(default)]
215    pub channels_config: ChannelsConfig,
216
217    /// Memory backend configuration: sqlite, markdown, embeddings (`[memory]`).
218    #[serde(default)]
219    pub memory: MemoryConfig,
220
221    /// Persistent storage provider configuration (`[storage]`).
222    #[serde(default)]
223    pub storage: StorageConfig,
224
225    /// Tunnel configuration for exposing the gateway publicly (`[tunnel]`).
226    #[serde(default)]
227    pub tunnel: TunnelConfig,
228
229    /// Gateway server configuration: host, port, pairing, rate limits (`[gateway]`).
230    #[serde(default)]
231    pub gateway: GatewayConfig,
232
233    /// Composio managed OAuth tools integration (`[composio]`).
234    #[serde(default)]
235    pub composio: ComposioConfig,
236
237    /// Microsoft 365 Graph API integration (`[microsoft365]`).
238    #[serde(default)]
239    pub microsoft365: Microsoft365Config,
240
241    /// Secrets encryption configuration (`[secrets]`).
242    #[serde(default)]
243    pub secrets: SecretsConfig,
244
245    /// Browser automation configuration (`[browser]`).
246    #[serde(default)]
247    pub browser: BrowserConfig,
248
249    /// Browser delegation configuration (`[browser_delegate]`).
250    ///
251    /// Delegates browser-based tasks to a browser-capable CLI subprocess (e.g.
252    /// Claude Code with `claude-in-chrome` MCP tools). Useful for interacting
253    /// with corporate web apps (Teams, Outlook, Jira, Confluence) that lack
254    /// direct API access. A persistent Chrome profile can be configured so SSO
255    /// sessions survive across invocations.
256    ///
257    /// Fields:
258    /// - `enabled` (`bool`, default `false`) — enable the browser delegation tool.
259    /// - `cli_binary` (`String`, default `"claude"`) — CLI binary to spawn for browser tasks.
260    /// - `chrome_profile_dir` (`String`, default `""`) — Chrome user-data directory for
261    ///   persistent SSO sessions. When empty, a fresh profile is used each invocation.
262    /// - `allowed_domains` (`Vec<String>`, default `[]`) — allowlist of domains the browser
263    ///   may navigate to. Empty means all non-blocked domains are permitted.
264    /// - `blocked_domains` (`Vec<String>`, default `[]`) — denylist of domains. Blocked
265    ///   domains take precedence over allowed domains.
266    /// - `task_timeout_secs` (`u64`, default `120`) — per-task timeout in seconds.
267    ///
268    /// Compatibility: additive and disabled by default; existing configs remain valid when omitted.
269    /// Rollback/migration: remove `[browser_delegate]` or keep `enabled = false` to disable.
270    #[serde(default)]
271    pub browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig,
272
273    /// HTTP request tool configuration (`[http_request]`).
274    #[serde(default)]
275    pub http_request: HttpRequestConfig,
276
277    /// Multimodal (image) handling configuration (`[multimodal]`).
278    #[serde(default)]
279    pub multimodal: MultimodalConfig,
280
281    /// Automatic media understanding pipeline (`[media_pipeline]`).
282    #[serde(default)]
283    pub media_pipeline: MediaPipelineConfig,
284
285    /// Web fetch tool configuration (`[web_fetch]`).
286    #[serde(default)]
287    pub web_fetch: WebFetchConfig,
288
289    /// Link enricher configuration (`[link_enricher]`).
290    #[serde(default)]
291    pub link_enricher: LinkEnricherConfig,
292
293    /// Text browser tool configuration (`[text_browser]`).
294    #[serde(default)]
295    pub text_browser: TextBrowserConfig,
296
297    /// Web search tool configuration (`[web_search]`).
298    #[serde(default)]
299    pub web_search: WebSearchConfig,
300
301    /// Project delivery intelligence configuration (`[project_intel]`).
302    #[serde(default)]
303    pub project_intel: ProjectIntelConfig,
304
305    /// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]`).
306    #[serde(default)]
307    pub google_workspace: GoogleWorkspaceConfig,
308
309    /// Proxy configuration for outbound HTTP/HTTPS/SOCKS5 traffic (`[proxy]`).
310    #[serde(default)]
311    pub proxy: ProxyConfig,
312
313    /// Identity format configuration: OpenClaw or AIEOS (`[identity]`).
314    #[serde(default)]
315    pub identity: IdentityConfig,
316
317    /// Cost tracking and budget enforcement configuration (`[cost]`).
318    #[serde(default)]
319    pub cost: CostConfig,
320
321    /// Peripheral board configuration for hardware integration (`[peripherals]`).
322    #[serde(default)]
323    pub peripherals: PeripheralsConfig,
324
325    /// Delegate tool global default configuration (`[delegate]`).
326    #[serde(default)]
327    pub delegate: DelegateToolConfig,
328
329    /// Delegate agent configurations for multi-agent workflows.
330    #[serde(default)]
331    pub agents: HashMap<String, DelegateAgentConfig>,
332
333    /// Swarm configurations for multi-agent orchestration.
334    #[serde(default)]
335    pub swarms: HashMap<String, SwarmConfig>,
336
337    /// Hooks configuration (lifecycle hooks and built-in hook toggles).
338    #[serde(default)]
339    pub hooks: HooksConfig,
340
341    /// Hardware configuration (wizard-driven physical world setup).
342    #[serde(default)]
343    pub hardware: HardwareConfig,
344
345    /// Voice transcription configuration (Whisper API via Groq).
346    #[serde(default)]
347    pub transcription: TranscriptionConfig,
348
349    /// Text-to-Speech configuration (`[tts]`).
350    #[serde(default)]
351    pub tts: TtsConfig,
352
353    /// External MCP server connections (`[mcp]`).
354    #[serde(default, alias = "mcpServers")]
355    pub mcp: McpConfig,
356
357    /// Kumiho graph-memory integration (`[kumiho]`).
358    ///
359    /// Automatically injects the Kumiho MCP server and session-bootstrap prompt
360    /// into every non-internal agent.  Disable only for testing or air-gapped
361    /// deployments where Kumiho is not installed.
362    #[serde(default)]
363    pub kumiho: KumihoConfig,
364
365    /// Operator multi-agent orchestration (`[operator]`).
366    ///
367    /// Automatically injects the Operator MCP server and system prompt into
368    /// every non-internal agent, enabling plan-delegate-monitor-synthesize
369    /// workflows.
370    #[serde(default)]
371    pub operator: OperatorConfig,
372
373    /// Dynamic node discovery configuration (`[nodes]`).
374    #[serde(default)]
375    pub nodes: NodesConfig,
376
377    /// ClawHub skill marketplace integration (`[clawhub]`).
378    #[serde(default)]
379    pub clawhub: ClawHubConfig,
380
381    /// Multi-client workspace isolation configuration (`[workspace]`).
382    #[serde(default)]
383    pub workspace: WorkspaceConfig,
384
385    /// Notion integration configuration (`[notion]`).
386    #[serde(default)]
387    pub notion: NotionConfig,
388
389    /// Jira integration configuration (`[jira]`).
390    #[serde(default)]
391    pub jira: JiraConfig,
392
393    /// Secure inter-node transport configuration (`[node_transport]`).
394    #[serde(default)]
395    pub node_transport: NodeTransportConfig,
396
397    /// LinkedIn integration configuration (`[linkedin]`).
398    #[serde(default)]
399    pub linkedin: LinkedInConfig,
400
401    /// Standalone image generation tool configuration (`[image_gen]`).
402    #[serde(default)]
403    pub image_gen: ImageGenConfig,
404
405    /// Plugin system configuration (`[plugins]`).
406    #[serde(default)]
407    pub plugins: PluginsConfig,
408
409    /// Locale for tool descriptions (e.g. `"en"`, `"zh-CN"`).
410    ///
411    /// When set, tool descriptions shown in system prompts are loaded from
412    /// `tool_descriptions/<locale>.toml`. Falls back to English, then to
413    /// hardcoded descriptions.
414    ///
415    /// If omitted or empty, the locale is auto-detected from `CONSTRUCT_LOCALE`,
416    /// `LANG`, or `LC_ALL` environment variables (defaulting to `"en"`).
417    #[serde(default)]
418    pub locale: Option<String>,
419
420    /// Verifiable Intent (VI) credential verification and issuance (`[verifiable_intent]`).
421    #[serde(default)]
422    pub verifiable_intent: VerifiableIntentConfig,
423
424    /// Claude Code tool configuration (`[claude_code]`).
425    #[serde(default)]
426    pub claude_code: ClaudeCodeConfig,
427
428    /// Claude Code task runner with Slack progress and SSH session handoff (`[claude_code_runner]`).
429    #[serde(default)]
430    pub claude_code_runner: ClaudeCodeRunnerConfig,
431
432    /// Codex CLI tool configuration (`[codex_cli]`).
433    #[serde(default)]
434    pub codex_cli: CodexCliConfig,
435
436    /// Gemini CLI tool configuration (`[gemini_cli]`).
437    #[serde(default)]
438    pub gemini_cli: GeminiCliConfig,
439
440    /// OpenCode CLI tool configuration (`[opencode_cli]`).
441    #[serde(default)]
442    pub opencode_cli: OpenCodeCliConfig,
443
444    /// Standard Operating Procedures engine configuration (`[sop]`).
445    #[serde(default)]
446    pub sop: SopConfig,
447
448    /// Shell tool configuration (`[shell_tool]`).
449    #[serde(default)]
450    pub shell_tool: ShellToolConfig,
451}
452
453/// Multi-client workspace isolation configuration.
454///
455/// When enabled, each client engagement gets an isolated workspace with
456/// separate memory, audit, secrets, and tool restrictions.
457#[allow(clippy::struct_excessive_bools)]
458#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
459pub struct WorkspaceConfig {
460    /// Enable workspace isolation. Default: false.
461    #[serde(default)]
462    pub enabled: bool,
463    /// Currently active workspace name.
464    #[serde(default)]
465    pub active_workspace: Option<String>,
466    /// Base directory for workspace profiles.
467    #[serde(default = "default_workspaces_dir")]
468    pub workspaces_dir: String,
469    /// Isolate memory databases per workspace. Default: true.
470    #[serde(default = "default_true")]
471    pub isolate_memory: bool,
472    /// Isolate secrets namespaces per workspace. Default: true.
473    #[serde(default = "default_true")]
474    pub isolate_secrets: bool,
475    /// Isolate audit logs per workspace. Default: true.
476    #[serde(default = "default_true")]
477    pub isolate_audit: bool,
478    /// Allow searching across workspaces. Default: false (security).
479    #[serde(default)]
480    pub cross_workspace_search: bool,
481}
482
483fn default_workspaces_dir() -> String {
484    "~/.construct/workspaces".to_string()
485}
486
487impl Default for WorkspaceConfig {
488    fn default() -> Self {
489        Self {
490            enabled: false,
491            active_workspace: None,
492            workspaces_dir: default_workspaces_dir(),
493            isolate_memory: true,
494            isolate_secrets: true,
495            isolate_audit: true,
496            cross_workspace_search: false,
497        }
498    }
499}
500
501/// Named provider profile definition compatible with Codex app-server style config.
502#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
503pub struct ModelProviderConfig {
504    /// Optional provider type/name override (e.g. "openai", "openai-codex", or custom profile id).
505    #[serde(default)]
506    pub name: Option<String>,
507    /// Optional base URL for OpenAI-compatible endpoints.
508    #[serde(default)]
509    pub base_url: Option<String>,
510    /// Optional custom API path suffix (e.g. "/v2/generate" instead of the
511    /// default "/v1/chat/completions"). Only used by OpenAI-compatible / custom providers.
512    #[serde(default, skip_serializing_if = "Option::is_none")]
513    pub api_path: Option<String>,
514    /// Provider protocol variant ("responses" or "chat_completions").
515    #[serde(default)]
516    pub wire_api: Option<String>,
517    /// If true, load OpenAI auth material (OPENAI_API_KEY or ~/.codex/auth.json).
518    #[serde(default)]
519    pub requires_openai_auth: bool,
520    /// Azure OpenAI resource name (e.g. "my-resource" in https://my-resource.openai.azure.com).
521    #[serde(default, skip_serializing_if = "Option::is_none")]
522    pub azure_openai_resource: Option<String>,
523    /// Azure OpenAI deployment name (e.g. "gpt-4o").
524    #[serde(default, skip_serializing_if = "Option::is_none")]
525    pub azure_openai_deployment: Option<String>,
526    /// Azure OpenAI API version (defaults to "2024-08-01-preview").
527    #[serde(default, skip_serializing_if = "Option::is_none")]
528    pub azure_openai_api_version: Option<String>,
529    /// Optional maximum output tokens to send in API requests.
530    /// When set, overrides the provider's default `max_tokens` value.
531    /// Useful for providers like OpenRouter where the platform default (65536)
532    /// may exceed a model's actual limit.
533    #[serde(default, skip_serializing_if = "Option::is_none")]
534    pub max_tokens: Option<u32>,
535}
536
537// ── Delegate Tool Configuration ─────────────────────────────────
538
539/// Global delegate tool configuration for default timeout values.
540#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
541pub struct DelegateToolConfig {
542    /// Default timeout in seconds for non-agentic sub-agent provider calls.
543    /// Can be overridden per-agent in `[agents.<name>]` config.
544    /// Default: 120 seconds.
545    #[serde(default = "default_delegate_timeout_secs")]
546    pub timeout_secs: u64,
547    /// Default timeout in seconds for agentic sub-agent runs.
548    /// Can be overridden per-agent in `[agents.<name>]` config.
549    /// Default: 300 seconds.
550    #[serde(default = "default_delegate_agentic_timeout_secs")]
551    pub agentic_timeout_secs: u64,
552}
553
554impl Default for DelegateToolConfig {
555    fn default() -> Self {
556        Self {
557            timeout_secs: DEFAULT_DELEGATE_TIMEOUT_SECS,
558            agentic_timeout_secs: DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS,
559        }
560    }
561}
562
563// ── Delegate Agents ──────────────────────────────────────────────
564
565/// Configuration for a delegate sub-agent used by the `delegate` tool.
566#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
567pub struct DelegateAgentConfig {
568    /// Provider name (e.g. "ollama", "openrouter", "anthropic")
569    pub provider: String,
570    /// Model name
571    pub model: String,
572    /// Optional system prompt for the sub-agent
573    #[serde(default)]
574    pub system_prompt: Option<String>,
575    /// Optional API key override
576    #[serde(default)]
577    pub api_key: Option<String>,
578    /// Temperature override
579    #[serde(default)]
580    pub temperature: Option<f64>,
581    /// Max recursion depth for nested delegation
582    #[serde(default = "default_max_depth")]
583    pub max_depth: u32,
584    /// Enable agentic sub-agent mode (multi-turn tool-call loop).
585    #[serde(default)]
586    pub agentic: bool,
587    /// Allowlist of tool names available to the sub-agent in agentic mode.
588    #[serde(default)]
589    pub allowed_tools: Vec<String>,
590    /// Maximum tool-call iterations in agentic mode.
591    #[serde(default = "default_max_tool_iterations")]
592    pub max_iterations: usize,
593    /// Optional timeout in seconds for non-agentic sub-agent provider calls.
594    /// When `None`, falls back to `[delegate].timeout_secs` (default: 120).
595    #[serde(default)]
596    pub timeout_secs: Option<u64>,
597    /// Optional timeout in seconds for agentic sub-agent runs.
598    /// When `None`, falls back to `[delegate].agentic_timeout_secs` (default: 300).
599    #[serde(default)]
600    pub agentic_timeout_secs: Option<u64>,
601    /// Optional skills directory path (relative to workspace root) for scoped skill loading.
602    /// When unset or empty, the sub-agent falls back to the default workspace `skills/` directory.
603    #[serde(default)]
604    pub skills_directory: Option<String>,
605}
606
607fn default_delegate_timeout_secs() -> u64 {
608    DEFAULT_DELEGATE_TIMEOUT_SECS
609}
610
611fn default_delegate_agentic_timeout_secs() -> u64 {
612    DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS
613}
614
615// ── Swarms ──────────────────────────────────────────────────────
616
617/// Orchestration strategy for a swarm of agents.
618#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
619#[serde(rename_all = "snake_case")]
620pub enum SwarmStrategy {
621    /// Run agents sequentially; each agent's output feeds into the next.
622    Sequential,
623    /// Run agents in parallel; collect all outputs.
624    Parallel,
625    /// Use the LLM to pick the best agent for the task.
626    Router,
627}
628
629/// Configuration for a swarm of coordinated agents.
630#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
631pub struct SwarmConfig {
632    /// Ordered list of agent names (must reference keys in `agents`).
633    pub agents: Vec<String>,
634    /// Orchestration strategy.
635    pub strategy: SwarmStrategy,
636    /// System prompt for router strategy (used to pick the best agent).
637    #[serde(default, skip_serializing_if = "Option::is_none")]
638    pub router_prompt: Option<String>,
639    /// Optional description shown to the LLM when choosing swarms.
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub description: Option<String>,
642    /// Maximum total timeout for the swarm execution in seconds.
643    #[serde(default = "default_swarm_timeout_secs")]
644    pub timeout_secs: u64,
645}
646
647const DEFAULT_SWARM_TIMEOUT_SECS: u64 = 300;
648
649fn default_swarm_timeout_secs() -> u64 {
650    DEFAULT_SWARM_TIMEOUT_SECS
651}
652
653/// Valid temperature range for all paths (config, CLI, env override).
654pub const TEMPERATURE_RANGE: std::ops::RangeInclusive<f64> = 0.0..=2.0;
655
656/// Default temperature when the field is absent from config.
657const DEFAULT_TEMPERATURE: f64 = 0.7;
658
659fn default_temperature() -> f64 {
660    DEFAULT_TEMPERATURE
661}
662
663/// Default provider HTTP request timeout: 120 seconds.
664const DEFAULT_PROVIDER_TIMEOUT_SECS: u64 = 120;
665
666fn default_provider_timeout_secs() -> u64 {
667    DEFAULT_PROVIDER_TIMEOUT_SECS
668}
669
670/// Default delegate tool timeout for non-agentic calls: 120 seconds.
671pub const DEFAULT_DELEGATE_TIMEOUT_SECS: u64 = 120;
672
673/// Default delegate tool timeout for agentic runs: 300 seconds.
674pub const DEFAULT_DELEGATE_AGENTIC_TIMEOUT_SECS: u64 = 300;
675
676/// Validate that a temperature value is within the allowed range.
677pub fn validate_temperature(value: f64) -> std::result::Result<f64, String> {
678    if TEMPERATURE_RANGE.contains(&value) {
679        Ok(value)
680    } else {
681        Err(format!(
682            "temperature {value} is out of range (expected {}..={})",
683            TEMPERATURE_RANGE.start(),
684            TEMPERATURE_RANGE.end()
685        ))
686    }
687}
688
689/// Custom serde deserializer that rejects out-of-range temperature values at parse time.
690fn deserialize_temperature<'de, D>(deserializer: D) -> std::result::Result<f64, D::Error>
691where
692    D: serde::Deserializer<'de>,
693{
694    let value: f64 = serde::Deserialize::deserialize(deserializer)?;
695    validate_temperature(value).map_err(serde::de::Error::custom)
696}
697
698fn normalize_reasoning_effort(value: &str) -> std::result::Result<String, String> {
699    let normalized = value.trim().to_ascii_lowercase();
700    match normalized.as_str() {
701        "minimal" | "low" | "medium" | "high" | "xhigh" => Ok(normalized),
702        _ => Err(format!(
703            "reasoning_effort {value:?} is invalid (expected one of: minimal, low, medium, high, xhigh)"
704        )),
705    }
706}
707
708fn deserialize_reasoning_effort_opt<'de, D>(
709    deserializer: D,
710) -> std::result::Result<Option<String>, D::Error>
711where
712    D: serde::Deserializer<'de>,
713{
714    let value: Option<String> = Option::deserialize(deserializer)?;
715    value
716        .map(|raw| normalize_reasoning_effort(&raw).map_err(serde::de::Error::custom))
717        .transpose()
718}
719
720fn default_max_depth() -> u32 {
721    3
722}
723
724fn default_max_tool_iterations() -> usize {
725    10
726}
727
728// ── Hardware Config (wizard-driven) ─────────────────────────────
729
730/// Hardware transport mode.
731#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
732pub enum HardwareTransport {
733    #[default]
734    None,
735    Native,
736    Serial,
737    Probe,
738}
739
740impl std::fmt::Display for HardwareTransport {
741    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
742        match self {
743            Self::None => write!(f, "none"),
744            Self::Native => write!(f, "native"),
745            Self::Serial => write!(f, "serial"),
746            Self::Probe => write!(f, "probe"),
747        }
748    }
749}
750
751/// Wizard-driven hardware configuration for physical world interaction.
752#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
753pub struct HardwareConfig {
754    /// Whether hardware access is enabled
755    #[serde(default)]
756    pub enabled: bool,
757    /// Transport mode
758    #[serde(default)]
759    pub transport: HardwareTransport,
760    /// Serial port path (e.g. "/dev/ttyACM0")
761    #[serde(default)]
762    pub serial_port: Option<String>,
763    /// Serial baud rate
764    #[serde(default = "default_baud_rate")]
765    pub baud_rate: u32,
766    /// Probe target chip (e.g. "STM32F401RE")
767    #[serde(default)]
768    pub probe_target: Option<String>,
769    /// Enable workspace datasheet RAG (index PDF schematics for AI pin lookups)
770    #[serde(default)]
771    pub workspace_datasheets: bool,
772}
773
774fn default_baud_rate() -> u32 {
775    115_200
776}
777
778impl HardwareConfig {
779    /// Return the active transport mode.
780    pub fn transport_mode(&self) -> HardwareTransport {
781        self.transport.clone()
782    }
783}
784
785impl Default for HardwareConfig {
786    fn default() -> Self {
787        Self {
788            enabled: false,
789            transport: HardwareTransport::None,
790            serial_port: None,
791            baud_rate: default_baud_rate(),
792            probe_target: None,
793            workspace_datasheets: false,
794        }
795    }
796}
797
798// ── Transcription ────────────────────────────────────────────────
799
800fn default_transcription_api_url() -> String {
801    "https://api.groq.com/openai/v1/audio/transcriptions".into()
802}
803
804fn default_transcription_model() -> String {
805    "whisper-large-v3-turbo".into()
806}
807
808fn default_transcription_max_duration_secs() -> u64 {
809    120
810}
811
812fn default_transcription_provider() -> String {
813    "groq".into()
814}
815
816fn default_openai_stt_model() -> String {
817    "whisper-1".into()
818}
819
820fn default_deepgram_stt_model() -> String {
821    "nova-2".into()
822}
823
824fn default_google_stt_language_code() -> String {
825    "en-US".into()
826}
827
828/// Voice transcription configuration with multi-provider support.
829///
830/// The top-level `api_url`, `model`, and `api_key` fields remain for backward
831/// compatibility with existing Groq-based configurations.
832#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
833pub struct TranscriptionConfig {
834    /// Enable voice transcription for channels that support it.
835    #[serde(default)]
836    pub enabled: bool,
837    /// Default STT provider: "groq", "openai", "deepgram", "assemblyai", "google".
838    #[serde(default = "default_transcription_provider")]
839    pub default_provider: String,
840    /// API key used for transcription requests (Groq provider).
841    ///
842    /// If unset, runtime falls back to `GROQ_API_KEY` for backward compatibility.
843    #[serde(default)]
844    pub api_key: Option<String>,
845    /// Whisper API endpoint URL (Groq provider).
846    #[serde(default = "default_transcription_api_url")]
847    pub api_url: String,
848    /// Whisper model name (Groq provider).
849    #[serde(default = "default_transcription_model")]
850    pub model: String,
851    /// Optional language hint (ISO-639-1, e.g. "en", "ru") for Groq provider.
852    #[serde(default)]
853    pub language: Option<String>,
854    /// Optional initial prompt to bias transcription toward expected vocabulary
855    /// (proper nouns, technical terms, etc.). Sent as the `prompt` field in the
856    /// Whisper API request.
857    #[serde(default)]
858    pub initial_prompt: Option<String>,
859    /// Maximum voice duration in seconds (messages longer than this are skipped).
860    #[serde(default = "default_transcription_max_duration_secs")]
861    pub max_duration_secs: u64,
862    /// OpenAI Whisper STT provider configuration.
863    #[serde(default)]
864    pub openai: Option<OpenAiSttConfig>,
865    /// Deepgram STT provider configuration.
866    #[serde(default)]
867    pub deepgram: Option<DeepgramSttConfig>,
868    /// AssemblyAI STT provider configuration.
869    #[serde(default)]
870    pub assemblyai: Option<AssemblyAiSttConfig>,
871    /// Google Cloud Speech-to-Text provider configuration.
872    #[serde(default)]
873    pub google: Option<GoogleSttConfig>,
874    /// Local/self-hosted Whisper-compatible STT provider.
875    #[serde(default)]
876    pub local_whisper: Option<LocalWhisperConfig>,
877    /// Also transcribe non-PTT (forwarded/regular) audio messages on WhatsApp,
878    /// not just voice notes.  Default: `false` (preserves legacy behavior).
879    #[serde(default)]
880    pub transcribe_non_ptt_audio: bool,
881}
882
883impl Default for TranscriptionConfig {
884    fn default() -> Self {
885        Self {
886            enabled: false,
887            default_provider: default_transcription_provider(),
888            api_key: None,
889            api_url: default_transcription_api_url(),
890            model: default_transcription_model(),
891            language: None,
892            initial_prompt: None,
893            max_duration_secs: default_transcription_max_duration_secs(),
894            openai: None,
895            deepgram: None,
896            assemblyai: None,
897            google: None,
898            local_whisper: None,
899            transcribe_non_ptt_audio: false,
900        }
901    }
902}
903
904// ── MCP ─────────────────────────────────────────────────────────
905
906/// Transport type for MCP server connections.
907#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
908#[serde(rename_all = "lowercase")]
909pub enum McpTransport {
910    /// Spawn a local process and communicate over stdin/stdout.
911    #[default]
912    Stdio,
913    /// Connect via HTTP POST.
914    Http,
915    /// Connect via HTTP + Server-Sent Events.
916    Sse,
917}
918
919/// Configuration for a single external MCP server.
920#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
921pub struct McpServerConfig {
922    /// Display name used as a tool prefix (`<server>__<tool>`).
923    pub name: String,
924    /// Transport type (default: stdio).
925    #[serde(default)]
926    pub transport: McpTransport,
927    /// URL for HTTP/SSE transports.
928    #[serde(default)]
929    pub url: Option<String>,
930    /// Executable to spawn for stdio transport.
931    #[serde(default)]
932    pub command: String,
933    /// Command arguments for stdio transport.
934    #[serde(default)]
935    pub args: Vec<String>,
936    /// Optional environment variables for stdio transport.
937    #[serde(default)]
938    pub env: HashMap<String, String>,
939    /// Optional HTTP headers for HTTP/SSE transports.
940    #[serde(default)]
941    pub headers: HashMap<String, String>,
942    /// Optional per-call timeout in seconds (hard capped in validation).
943    #[serde(default)]
944    pub tool_timeout_secs: Option<u64>,
945}
946
947/// External MCP client configuration (`[mcp]` section).
948#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
949pub struct McpConfig {
950    /// Enable MCP tool loading.
951    #[serde(default)]
952    pub enabled: bool,
953    /// Load MCP tool schemas on-demand via `tool_search` instead of eagerly
954    /// including them in the LLM context window. When `true` (the default),
955    /// only tool names are listed in the system prompt; the LLM must call
956    /// `tool_search` to fetch full schemas before invoking a deferred tool.
957    #[serde(default = "default_deferred_loading")]
958    pub deferred_loading: bool,
959    /// Configured MCP servers.
960    #[serde(default, alias = "mcpServers")]
961    pub servers: Vec<McpServerConfig>,
962}
963
964fn default_deferred_loading() -> bool {
965    true
966}
967
968impl Default for McpConfig {
969    fn default() -> Self {
970        Self {
971            enabled: false,
972            deferred_loading: default_deferred_loading(),
973            servers: Vec::new(),
974        }
975    }
976}
977
978// ── Kumiho ───────────────────────────────────────────────────────
979
980/// Kumiho graph-memory integration (`[kumiho]` section).
981///
982/// Controls automatic injection of the Kumiho MCP server and session-bootstrap
983/// system prompt into every non-internal agent.  Kumiho is Construct's canonical
984/// persistent memory store; disable only for testing or air-gapped deployments.
985#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
986pub struct KumihoConfig {
987    /// Enable Kumiho memory injection for non-internal agents. Default: `true`.
988    #[serde(default = "default_true")]
989    pub enabled: bool,
990
991    /// Absolute path to the `run_kumiho_mcp.py` script.
992    ///
993    /// Supports tilde expansion.  Defaults to
994    /// `~/.construct/kumiho/run_kumiho_mcp.py`.
995    #[serde(default = "default_kumiho_mcp_path")]
996    pub mcp_path: String,
997
998    /// Kumiho project / space prefix used to scope memories.
999    ///
1000    /// Memories are stored under `<space_prefix>/<context>`.  Defaults to
1001    /// `"Construct"`.
1002    #[serde(default = "default_kumiho_space_prefix")]
1003    pub space_prefix: String,
1004
1005    /// Base URL for the Kumiho FastAPI REST API.
1006    ///
1007    /// Used by the agent management proxy to call Kumiho endpoints directly.
1008    /// Defaults to `"http://localhost:8000"`.
1009    #[serde(default = "default_kumiho_api_url")]
1010    pub api_url: String,
1011
1012    /// Kumiho project for user memories, sessions, and compactions.
1013    #[serde(default = "default_kumiho_memory_project")]
1014    pub memory_project: String,
1015
1016    /// Kumiho project for skills, operational data, and ClawHub installs.
1017    #[serde(default = "default_kumiho_harness_project")]
1018    pub harness_project: String,
1019}
1020
1021fn default_kumiho_mcp_path() -> String {
1022    "~/.construct/kumiho/run_kumiho_mcp.py".to_string()
1023}
1024
1025fn default_kumiho_space_prefix() -> String {
1026    "Construct".to_string()
1027}
1028
1029fn default_kumiho_api_url() -> String {
1030    "https://api.kumiho.cloud".to_string()
1031}
1032
1033fn default_kumiho_memory_project() -> String {
1034    "CognitiveMemory".to_string()
1035}
1036
1037fn default_kumiho_harness_project() -> String {
1038    "Construct".to_string()
1039}
1040
1041impl Default for KumihoConfig {
1042    fn default() -> Self {
1043        Self {
1044            enabled: true,
1045            mcp_path: default_kumiho_mcp_path(),
1046            space_prefix: default_kumiho_space_prefix(),
1047            api_url: default_kumiho_api_url(),
1048            memory_project: default_kumiho_memory_project(),
1049            harness_project: default_kumiho_harness_project(),
1050        }
1051    }
1052}
1053
1054// -- Operator ---------------------------------------------------------------
1055
1056/// Operator multi-agent orchestration (`[operator]` section).
1057///
1058/// Controls automatic injection of the Operator MCP server and system prompt
1059/// into every non-internal agent.  The operator layer enables the lead agent
1060/// to spawn, monitor, and synthesize results from worker sub-agents.
1061#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1062pub struct OperatorConfig {
1063    /// Enable Operator injection for non-internal agents. Default: `true`.
1064    #[serde(default = "default_true")]
1065    pub enabled: bool,
1066
1067    /// Absolute path to the `run_operator_mcp.py` script.
1068    ///
1069    /// Supports tilde expansion.  Defaults to
1070    /// `~/.construct/operator_mcp/run_operator_mcp.py`.
1071    #[serde(default)]
1072    pub mcp_path: String,
1073
1074    /// Override max tool-call iterations when the operator is active.
1075    ///
1076    /// Operator tasks (research → plan → spawn → monitor) are inherently
1077    /// multi-step and typically require more iterations than normal agent
1078    /// turns.  When non-zero this value replaces
1079    /// `agent.max_tool_iterations` for operator-enabled sessions.
1080    /// Default: 80.
1081    #[serde(default = "default_operator_max_tool_iterations")]
1082    pub max_tool_iterations: usize,
1083}
1084
1085fn default_operator_max_tool_iterations() -> usize {
1086    80
1087}
1088
1089impl Default for OperatorConfig {
1090    fn default() -> Self {
1091        Self {
1092            enabled: default_true(),
1093            mcp_path: String::new(),
1094            max_tool_iterations: default_operator_max_tool_iterations(),
1095        }
1096    }
1097}
1098
1099// -- ClawHub ------------------------------------------------------------------
1100
1101/// ClawHub skill marketplace integration (`[clawhub]` section).
1102///
1103/// Enables browsing, searching, and installing skills from the public
1104/// ClawHub registry (clawhub.ai).  An API token is only required for
1105/// publishing; anonymous browsing and installing is free.
1106#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1107pub struct ClawHubConfig {
1108    /// Enable ClawHub integration. Default: `true`.
1109    #[serde(default = "default_true")]
1110    pub enabled: bool,
1111
1112    /// ClawHub API token (`clh_…`) for publishing skills.
1113    /// Optional — browsing and installing work without a token.
1114    #[serde(default)]
1115    pub api_token: Option<String>,
1116
1117    /// Base URL for the ClawHub API. Default: `"https://clawhub.ai"`.
1118    #[serde(default = "default_clawhub_api_url")]
1119    pub api_url: String,
1120}
1121
1122fn default_clawhub_api_url() -> String {
1123    "https://clawhub.ai".to_string()
1124}
1125
1126impl Default for ClawHubConfig {
1127    fn default() -> Self {
1128        Self {
1129            enabled: true,
1130            api_token: None,
1131            api_url: default_clawhub_api_url(),
1132        }
1133    }
1134}
1135
1136/// Verifiable Intent (VI) credential verification and issuance (`[verifiable_intent]` section).
1137#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1138pub struct VerifiableIntentConfig {
1139    /// Enable VI credential verification on commerce tool calls (default: false).
1140    #[serde(default)]
1141    pub enabled: bool,
1142
1143    /// Strictness mode for constraint evaluation: "strict" (fail-closed on unknown
1144    /// constraint types) or "permissive" (skip unknown types with a warning).
1145    /// Default: "strict".
1146    #[serde(default = "default_vi_strictness")]
1147    pub strictness: String,
1148}
1149
1150fn default_vi_strictness() -> String {
1151    "strict".to_owned()
1152}
1153
1154impl Default for VerifiableIntentConfig {
1155    fn default() -> Self {
1156        Self {
1157            enabled: false,
1158            strictness: default_vi_strictness(),
1159        }
1160    }
1161}
1162
1163// ── Nodes (Dynamic Node Discovery) ───────────────────────────────
1164
1165/// Configuration for the dynamic node discovery system (`[nodes]`).
1166///
1167/// When enabled, external processes/devices can connect via WebSocket
1168/// at `/ws/nodes` and advertise their capabilities at runtime.
1169#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1170pub struct NodesConfig {
1171    /// Enable dynamic node discovery endpoint.
1172    #[serde(default)]
1173    pub enabled: bool,
1174    /// Maximum number of concurrent node connections.
1175    #[serde(default = "default_max_nodes")]
1176    pub max_nodes: usize,
1177    /// Optional bearer token for node authentication.
1178    #[serde(default)]
1179    pub auth_token: Option<String>,
1180}
1181
1182fn default_max_nodes() -> usize {
1183    16
1184}
1185
1186impl Default for NodesConfig {
1187    fn default() -> Self {
1188        Self {
1189            enabled: false,
1190            max_nodes: default_max_nodes(),
1191            auth_token: None,
1192        }
1193    }
1194}
1195
1196// ── TTS (Text-to-Speech) ─────────────────────────────────────────
1197
1198fn default_tts_provider() -> String {
1199    "openai".into()
1200}
1201
1202fn default_tts_voice() -> String {
1203    "alloy".into()
1204}
1205
1206fn default_tts_format() -> String {
1207    "mp3".into()
1208}
1209
1210fn default_tts_max_text_length() -> usize {
1211    4096
1212}
1213
1214fn default_openai_tts_model() -> String {
1215    "tts-1".into()
1216}
1217
1218fn default_openai_tts_speed() -> f64 {
1219    1.0
1220}
1221
1222fn default_elevenlabs_model_id() -> String {
1223    "eleven_monolingual_v1".into()
1224}
1225
1226fn default_elevenlabs_stability() -> f64 {
1227    0.5
1228}
1229
1230fn default_elevenlabs_similarity_boost() -> f64 {
1231    0.5
1232}
1233
1234fn default_google_tts_language_code() -> String {
1235    "en-US".into()
1236}
1237
1238fn default_edge_tts_binary_path() -> String {
1239    "edge-tts".into()
1240}
1241
1242fn default_piper_tts_api_url() -> String {
1243    "http://127.0.0.1:5000/v1/audio/speech".into()
1244}
1245
1246/// Text-to-Speech configuration (`[tts]`).
1247#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1248pub struct TtsConfig {
1249    /// Enable TTS synthesis.
1250    #[serde(default)]
1251    pub enabled: bool,
1252    /// Default TTS provider (`"openai"`, `"elevenlabs"`, `"google"`, `"edge"`).
1253    #[serde(default = "default_tts_provider")]
1254    pub default_provider: String,
1255    /// Default voice ID passed to the selected provider.
1256    #[serde(default = "default_tts_voice")]
1257    pub default_voice: String,
1258    /// Default audio output format (`"mp3"`, `"opus"`, `"wav"`).
1259    #[serde(default = "default_tts_format")]
1260    pub default_format: String,
1261    /// Maximum input text length in characters (default 4096).
1262    #[serde(default = "default_tts_max_text_length")]
1263    pub max_text_length: usize,
1264    /// OpenAI TTS provider configuration (`[tts.openai]`).
1265    #[serde(default)]
1266    pub openai: Option<OpenAiTtsConfig>,
1267    /// ElevenLabs TTS provider configuration (`[tts.elevenlabs]`).
1268    #[serde(default)]
1269    pub elevenlabs: Option<ElevenLabsTtsConfig>,
1270    /// Google Cloud TTS provider configuration (`[tts.google]`).
1271    #[serde(default)]
1272    pub google: Option<GoogleTtsConfig>,
1273    /// Edge TTS provider configuration (`[tts.edge]`).
1274    #[serde(default)]
1275    pub edge: Option<EdgeTtsConfig>,
1276    /// Piper TTS provider configuration (`[tts.piper]`).
1277    #[serde(default)]
1278    pub piper: Option<PiperTtsConfig>,
1279}
1280
1281impl Default for TtsConfig {
1282    fn default() -> Self {
1283        Self {
1284            enabled: false,
1285            default_provider: default_tts_provider(),
1286            default_voice: default_tts_voice(),
1287            default_format: default_tts_format(),
1288            max_text_length: default_tts_max_text_length(),
1289            openai: None,
1290            elevenlabs: None,
1291            google: None,
1292            edge: None,
1293            piper: None,
1294        }
1295    }
1296}
1297
1298/// OpenAI TTS provider configuration.
1299#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1300pub struct OpenAiTtsConfig {
1301    /// API key for OpenAI TTS.
1302    #[serde(default)]
1303    pub api_key: Option<String>,
1304    /// Model name (default `"tts-1"`).
1305    #[serde(default = "default_openai_tts_model")]
1306    pub model: String,
1307    /// Playback speed multiplier (default `1.0`).
1308    #[serde(default = "default_openai_tts_speed")]
1309    pub speed: f64,
1310}
1311
1312/// ElevenLabs TTS provider configuration.
1313#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1314pub struct ElevenLabsTtsConfig {
1315    /// API key for ElevenLabs.
1316    #[serde(default)]
1317    pub api_key: Option<String>,
1318    /// Model ID (default `"eleven_monolingual_v1"`).
1319    #[serde(default = "default_elevenlabs_model_id")]
1320    pub model_id: String,
1321    /// Voice stability (0.0-1.0, default `0.5`).
1322    #[serde(default = "default_elevenlabs_stability")]
1323    pub stability: f64,
1324    /// Similarity boost (0.0-1.0, default `0.5`).
1325    #[serde(default = "default_elevenlabs_similarity_boost")]
1326    pub similarity_boost: f64,
1327}
1328
1329/// Google Cloud TTS provider configuration.
1330#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1331pub struct GoogleTtsConfig {
1332    /// API key for Google Cloud TTS.
1333    #[serde(default)]
1334    pub api_key: Option<String>,
1335    /// Language code (default `"en-US"`).
1336    #[serde(default = "default_google_tts_language_code")]
1337    pub language_code: String,
1338}
1339
1340/// Edge TTS provider configuration (free, subprocess-based).
1341#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1342pub struct EdgeTtsConfig {
1343    /// Path to the `edge-tts` binary (default `"edge-tts"`).
1344    #[serde(default = "default_edge_tts_binary_path")]
1345    pub binary_path: String,
1346}
1347
1348/// Piper TTS provider configuration (local GPU-accelerated, OpenAI-compatible endpoint).
1349#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1350pub struct PiperTtsConfig {
1351    /// Base URL for the Piper TTS HTTP server (e.g. `"http://127.0.0.1:5000/v1/audio/speech"`).
1352    #[serde(default = "default_piper_tts_api_url")]
1353    pub api_url: String,
1354}
1355
1356/// Determines when a `ToolFilterGroup` is active.
1357#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1358#[serde(rename_all = "snake_case")]
1359pub enum ToolFilterGroupMode {
1360    /// Tools in this group are always included in every turn.
1361    Always,
1362    /// Tools in this group are included only when the user message contains
1363    /// at least one of the configured `keywords` (case-insensitive substring match).
1364    #[default]
1365    Dynamic,
1366}
1367
1368/// A named group of MCP tool patterns with an activation mode.
1369///
1370/// Each group lists glob patterns for MCP tool names (prefix `mcp_`) and an
1371/// optional set of keywords that trigger inclusion in `dynamic` mode.
1372/// Built-in (non-MCP) tools always pass through and are never affected by
1373/// `tool_filter_groups`.
1374///
1375/// # Example
1376/// ```toml
1377/// [[agent.tool_filter_groups]]
1378/// mode = "always"
1379/// tools = ["mcp_filesystem_*"]
1380/// keywords = []
1381///
1382/// [[agent.tool_filter_groups]]
1383/// mode = "dynamic"
1384/// tools = ["mcp_browser_*"]
1385/// keywords = ["browse", "website", "url", "search"]
1386/// ```
1387#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1388pub struct ToolFilterGroup {
1389    /// Activation mode: `"always"` or `"dynamic"`.
1390    #[serde(default)]
1391    pub mode: ToolFilterGroupMode,
1392    /// Glob patterns matching MCP tool names (single `*` wildcard supported).
1393    #[serde(default)]
1394    pub tools: Vec<String>,
1395    /// Keywords that activate this group in `dynamic` mode (case-insensitive substring).
1396    /// Ignored when `mode = "always"`.
1397    #[serde(default)]
1398    pub keywords: Vec<String>,
1399    /// When true, also filter built-in tools (not just MCP tools).
1400    #[serde(default)]
1401    pub filter_builtins: bool,
1402}
1403
1404/// OpenAI Whisper STT provider configuration (`[transcription.openai]`).
1405#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1406pub struct OpenAiSttConfig {
1407    /// OpenAI API key for Whisper transcription.
1408    #[serde(default)]
1409    pub api_key: Option<String>,
1410    /// Whisper model name (default: "whisper-1").
1411    #[serde(default = "default_openai_stt_model")]
1412    pub model: String,
1413}
1414
1415/// Deepgram STT provider configuration (`[transcription.deepgram]`).
1416#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1417pub struct DeepgramSttConfig {
1418    /// Deepgram API key.
1419    #[serde(default)]
1420    pub api_key: Option<String>,
1421    /// Deepgram model name (default: "nova-2").
1422    #[serde(default = "default_deepgram_stt_model")]
1423    pub model: String,
1424}
1425
1426/// AssemblyAI STT provider configuration (`[transcription.assemblyai]`).
1427#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1428pub struct AssemblyAiSttConfig {
1429    /// AssemblyAI API key.
1430    #[serde(default)]
1431    pub api_key: Option<String>,
1432}
1433
1434/// Google Cloud Speech-to-Text provider configuration (`[transcription.google]`).
1435#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1436pub struct GoogleSttConfig {
1437    /// Google Cloud API key.
1438    #[serde(default)]
1439    pub api_key: Option<String>,
1440    /// BCP-47 language code (default: "en-US").
1441    #[serde(default = "default_google_stt_language_code")]
1442    pub language_code: String,
1443}
1444
1445/// Local/self-hosted Whisper-compatible STT endpoint (`[transcription.local_whisper]`).
1446///
1447/// Configures a self-hosted STT endpoint. Can be on localhost, a private network host, or any reachable URL.
1448#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1449pub struct LocalWhisperConfig {
1450    /// HTTP or HTTPS endpoint URL, e.g. `"http://10.10.0.1:8001/v1/transcribe"`.
1451    pub url: String,
1452    /// Bearer token for endpoint authentication.
1453    /// Omit for unauthenticated local endpoints.
1454    #[serde(default)]
1455    pub bearer_token: Option<String>,
1456    /// Maximum audio file size in bytes accepted by this endpoint.
1457    /// Defaults to 25 MB — matching the cloud API cap for a safe out-of-the-box
1458    /// experience. Self-hosted endpoints can accept much larger files; raise this
1459    /// as needed, but note that each transcription call clones the audio buffer
1460    /// into a multipart payload, so peak memory per request is ~2× this value.
1461    #[serde(default = "default_local_whisper_max_audio_bytes")]
1462    pub max_audio_bytes: usize,
1463    /// Request timeout in seconds. Defaults to 300 (large files on local GPU).
1464    #[serde(default = "default_local_whisper_timeout_secs")]
1465    pub timeout_secs: u64,
1466}
1467
1468fn default_local_whisper_max_audio_bytes() -> usize {
1469    25 * 1024 * 1024
1470}
1471
1472fn default_local_whisper_timeout_secs() -> u64 {
1473    300
1474}
1475
1476/// Agent orchestration configuration (`[agent]` section).
1477#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1478pub struct AgentConfig {
1479    /// When true: bootstrap_max_chars=6000, rag_chunk_limit=2. Use for 13B or smaller models.
1480    #[serde(default)]
1481    pub compact_context: bool,
1482    /// Maximum tool-call loop turns per user message. Default: `10`.
1483    /// Setting to `0` falls back to the safe default of `10`.
1484    #[serde(default = "default_agent_max_tool_iterations")]
1485    pub max_tool_iterations: usize,
1486    /// Maximum conversation history messages retained per session. Default: `50`.
1487    #[serde(default = "default_agent_max_history_messages")]
1488    pub max_history_messages: usize,
1489    /// Maximum estimated tokens for conversation history before compaction triggers.
1490    /// Uses ~4 chars/token heuristic. When this threshold is exceeded, older messages
1491    /// are summarized to preserve context while staying within budget. Default: `32000`.
1492    #[serde(default = "default_agent_max_context_tokens")]
1493    pub max_context_tokens: usize,
1494    /// Enable parallel tool execution within a single iteration. Default: `false`.
1495    #[serde(default)]
1496    pub parallel_tools: bool,
1497    /// Tool dispatch strategy (e.g. `"auto"`). Default: `"auto"`.
1498    #[serde(default = "default_agent_tool_dispatcher")]
1499    pub tool_dispatcher: String,
1500    /// Tools exempt from the within-turn duplicate-call dedup check. Default: `[]`.
1501    #[serde(default)]
1502    pub tool_call_dedup_exempt: Vec<String>,
1503    /// Per-turn MCP tool schema filtering groups.
1504    ///
1505    /// When non-empty, only MCP tools matched by an active group are included in the
1506    /// tool schema sent to the LLM for that turn. Built-in tools always pass through.
1507    /// Default: `[]` (no filtering — all tools included).
1508    #[serde(default)]
1509    pub tool_filter_groups: Vec<ToolFilterGroup>,
1510    /// Maximum characters for the assembled system prompt. When `> 0`, the prompt
1511    /// is truncated to this limit after assembly (keeping the top portion which
1512    /// contains identity and safety instructions). `0` means unlimited.
1513    /// Useful for small-context models (e.g. glm-4.5-air ~8K tokens → set to 8000).
1514    #[serde(default = "default_max_system_prompt_chars")]
1515    pub max_system_prompt_chars: usize,
1516    /// Thinking/reasoning level control. Configures how deeply the model reasons
1517    /// per message. Users can override per-message with `/think:<level>` directives.
1518    #[serde(default)]
1519    pub thinking: crate::agent::thinking::ThinkingConfig,
1520
1521    /// History pruning configuration for token efficiency.
1522    #[serde(default)]
1523    pub history_pruning: crate::agent::history_pruner::HistoryPrunerConfig,
1524
1525    /// Enable context-aware tool filtering (only surface relevant tools per iteration).
1526    #[serde(default)]
1527    pub context_aware_tools: bool,
1528
1529    /// Post-response quality evaluator configuration.
1530    #[serde(default)]
1531    pub eval: crate::agent::eval::EvalConfig,
1532
1533    /// Automatic complexity-based classification fallback.
1534    #[serde(default)]
1535    pub auto_classify: Option<crate::agent::eval::AutoClassifyConfig>,
1536
1537    /// Context compression configuration for automatic conversation compaction.
1538    #[serde(default)]
1539    pub context_compression: crate::agent::context_compressor::ContextCompressionConfig,
1540
1541    /// Maximum characters for a single tool result before truncation.
1542    /// Head (2/3) and tail (1/3) are preserved with a truncation marker in the
1543    /// middle. Set to `0` to disable truncation. Default: `50000`.
1544    #[serde(default = "default_max_tool_result_chars")]
1545    pub max_tool_result_chars: usize,
1546
1547    /// Number of most recent conversation turns whose full tool-call/result
1548    /// messages are preserved in channel conversation history. Older turns
1549    /// keep only the final assistant text. Set to `0` to disable (previous
1550    /// behavior). Default: `2`.
1551    #[serde(default = "default_keep_tool_context_turns")]
1552    pub keep_tool_context_turns: usize,
1553}
1554
1555fn default_max_tool_result_chars() -> usize {
1556    50_000
1557}
1558
1559fn default_keep_tool_context_turns() -> usize {
1560    2
1561}
1562
1563fn default_agent_max_tool_iterations() -> usize {
1564    10
1565}
1566
1567fn default_agent_max_history_messages() -> usize {
1568    50
1569}
1570
1571fn default_agent_max_context_tokens() -> usize {
1572    32_000
1573}
1574
1575fn default_agent_tool_dispatcher() -> String {
1576    "auto".into()
1577}
1578
1579fn default_max_system_prompt_chars() -> usize {
1580    0
1581}
1582
1583impl Default for AgentConfig {
1584    fn default() -> Self {
1585        Self {
1586            compact_context: true,
1587            max_tool_iterations: default_agent_max_tool_iterations(),
1588            max_history_messages: default_agent_max_history_messages(),
1589            max_context_tokens: default_agent_max_context_tokens(),
1590            parallel_tools: false,
1591            tool_dispatcher: default_agent_tool_dispatcher(),
1592            tool_call_dedup_exempt: Vec::new(),
1593            tool_filter_groups: Vec::new(),
1594            max_system_prompt_chars: default_max_system_prompt_chars(),
1595            thinking: crate::agent::thinking::ThinkingConfig::default(),
1596            history_pruning: crate::agent::history_pruner::HistoryPrunerConfig::default(),
1597            context_aware_tools: false,
1598            eval: crate::agent::eval::EvalConfig::default(),
1599            auto_classify: None,
1600            context_compression:
1601                crate::agent::context_compressor::ContextCompressionConfig::default(),
1602            max_tool_result_chars: default_max_tool_result_chars(),
1603            keep_tool_context_turns: default_keep_tool_context_turns(),
1604        }
1605    }
1606}
1607
1608// ── Pacing ────────────────────────────────────────────────────────
1609
1610/// Pacing controls for slow/local LLM workloads (`[pacing]` section).
1611///
1612/// All fields are optional and default to values that preserve existing
1613/// behavior. When set, they extend — not replace — the existing timeout
1614/// and loop-detection subsystems.
1615#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1616pub struct PacingConfig {
1617    /// Per-step timeout in seconds: the maximum time allowed for a single
1618    /// LLM inference turn, independent of the total message budget.
1619    /// `None` means no per-step timeout (existing behavior).
1620    #[serde(default)]
1621    pub step_timeout_secs: Option<u64>,
1622
1623    /// Minimum elapsed seconds before loop detection activates.
1624    /// Tasks completing under this threshold get aggressive loop protection;
1625    /// longer-running tasks receive a grace period before the detector starts
1626    /// counting. `None` means loop detection is always active (existing behavior).
1627    #[serde(default)]
1628    pub loop_detection_min_elapsed_secs: Option<u64>,
1629
1630    /// Tool names excluded from identical-output / alternating-pattern loop
1631    /// detection. Useful for browser workflows where `browser_screenshot`
1632    /// structurally resembles a loop even when making progress.
1633    #[serde(default)]
1634    pub loop_ignore_tools: Vec<String>,
1635
1636    /// Override for the hardcoded timeout scaling cap (default: 4).
1637    /// The channel message timeout budget is computed as:
1638    ///   `message_timeout_secs * min(max_tool_iterations, message_timeout_scale_max)`
1639    /// Raising this value lets long multi-step tasks with slow local models
1640    /// receive a proportionally larger budget without inflating the base timeout.
1641    #[serde(default)]
1642    pub message_timeout_scale_max: Option<u64>,
1643
1644    /// Enable pattern-based loop detection (exact repeat, ping-pong,
1645    /// no-progress). Defaults to `true`.
1646    #[serde(default = "default_loop_detection_enabled")]
1647    pub loop_detection_enabled: bool,
1648
1649    /// Sliding window size for the pattern-based loop detector.
1650    /// Defaults to 20.
1651    #[serde(default = "default_loop_detection_window_size")]
1652    pub loop_detection_window_size: usize,
1653
1654    /// Number of consecutive identical tool+args calls before the first
1655    /// escalation (Warning). Defaults to 3.
1656    #[serde(default = "default_loop_detection_max_repeats")]
1657    pub loop_detection_max_repeats: usize,
1658}
1659
1660fn default_loop_detection_enabled() -> bool {
1661    true
1662}
1663
1664fn default_loop_detection_window_size() -> usize {
1665    20
1666}
1667
1668fn default_loop_detection_max_repeats() -> usize {
1669    3
1670}
1671
1672impl Default for PacingConfig {
1673    fn default() -> Self {
1674        Self {
1675            step_timeout_secs: None,
1676            loop_detection_min_elapsed_secs: None,
1677            loop_ignore_tools: Vec::new(),
1678            message_timeout_scale_max: None,
1679            loop_detection_enabled: default_loop_detection_enabled(),
1680            loop_detection_window_size: default_loop_detection_window_size(),
1681            loop_detection_max_repeats: default_loop_detection_max_repeats(),
1682        }
1683    }
1684}
1685
1686/// Skills loading configuration (`[skills]` section).
1687#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
1688#[serde(rename_all = "snake_case")]
1689pub enum SkillsPromptInjectionMode {
1690    /// Inline full skill instructions and tool metadata into the system prompt.
1691    #[default]
1692    Full,
1693    /// Inline only compact skill metadata (name/description/location) and load details on demand.
1694    Compact,
1695}
1696
1697fn parse_skills_prompt_injection_mode(raw: &str) -> Option<SkillsPromptInjectionMode> {
1698    match raw.trim().to_ascii_lowercase().as_str() {
1699        "full" => Some(SkillsPromptInjectionMode::Full),
1700        "compact" => Some(SkillsPromptInjectionMode::Compact),
1701        _ => None,
1702    }
1703}
1704
1705/// Skills loading configuration (`[skills]` section).
1706#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
1707pub struct SkillsConfig {
1708    /// Enable loading and syncing the community open-skills repository.
1709    /// Default: `false` (opt-in).
1710    #[serde(default)]
1711    pub open_skills_enabled: bool,
1712    /// Optional path to a local open-skills repository.
1713    /// If unset, defaults to `$HOME/open-skills` when enabled.
1714    #[serde(default)]
1715    pub open_skills_dir: Option<String>,
1716    /// Allow script-like files in skills (`.sh`, `.bash`, `.ps1`, shebang shell files).
1717    /// Default: `false` (secure by default).
1718    #[serde(default)]
1719    pub allow_scripts: bool,
1720    /// Controls how skills are injected into the system prompt.
1721    /// `full` preserves legacy behavior. `compact` keeps context small and loads skills on demand.
1722    #[serde(default)]
1723    pub prompt_injection_mode: SkillsPromptInjectionMode,
1724    /// Autonomous skill creation from successful multi-step task executions.
1725    #[serde(default)]
1726    pub skill_creation: SkillCreationConfig,
1727    /// Automatic skill self-improvement after successful skill usage.
1728    #[serde(default)]
1729    pub skill_improvement: SkillImprovementConfig,
1730}
1731
1732/// Autonomous skill creation configuration (`[skills.skill_creation]` section).
1733#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1734#[serde(default)]
1735pub struct SkillCreationConfig {
1736    /// Enable automatic skill creation after successful multi-step tasks.
1737    /// Default: `false`.
1738    pub enabled: bool,
1739    /// Maximum number of auto-generated skills to keep.
1740    /// When exceeded, the oldest auto-generated skill is removed (LRU eviction).
1741    pub max_skills: usize,
1742    /// Embedding similarity threshold for deduplication.
1743    /// Skills with descriptions more similar than this value are skipped.
1744    pub similarity_threshold: f64,
1745}
1746
1747impl Default for SkillCreationConfig {
1748    fn default() -> Self {
1749        Self {
1750            enabled: false,
1751            max_skills: 500,
1752            similarity_threshold: 0.85,
1753        }
1754    }
1755}
1756
1757/// Skill self-improvement configuration (`[skills.auto_improve]` section).
1758#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1759pub struct SkillImprovementConfig {
1760    /// Enable automatic skill improvement after successful skill usage.
1761    /// Default: `true`.
1762    #[serde(default = "default_true")]
1763    pub enabled: bool,
1764    /// Minimum interval (in seconds) between improvements for the same skill.
1765    /// Default: `3600` (1 hour).
1766    #[serde(default = "default_skill_improvement_cooldown")]
1767    pub cooldown_secs: u64,
1768}
1769
1770fn default_skill_improvement_cooldown() -> u64 {
1771    3600
1772}
1773
1774impl Default for SkillImprovementConfig {
1775    fn default() -> Self {
1776        Self {
1777            enabled: true,
1778            cooldown_secs: 3600,
1779        }
1780    }
1781}
1782
1783/// Pipeline tool configuration (`[pipeline]` section).
1784#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1785pub struct PipelineConfig {
1786    /// Enable the `execute_pipeline` meta-tool.
1787    /// Default: `false`.
1788    #[serde(default)]
1789    pub enabled: bool,
1790    /// Maximum number of steps allowed in a single pipeline invocation.
1791    /// Default: `20`.
1792    #[serde(default = "default_pipeline_max_steps")]
1793    pub max_steps: usize,
1794    /// Tools allowed in pipeline steps. Steps referencing tools not on this
1795    /// list are rejected before execution.
1796    #[serde(default)]
1797    pub allowed_tools: Vec<String>,
1798}
1799
1800fn default_pipeline_max_steps() -> usize {
1801    20
1802}
1803
1804impl Default for PipelineConfig {
1805    fn default() -> Self {
1806        Self {
1807            enabled: false,
1808            max_steps: 20,
1809            allowed_tools: Vec::new(),
1810        }
1811    }
1812}
1813
1814/// Multimodal (image) handling configuration (`[multimodal]` section).
1815#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1816pub struct MultimodalConfig {
1817    /// Maximum number of image attachments accepted per request.
1818    #[serde(default = "default_multimodal_max_images")]
1819    pub max_images: usize,
1820    /// Maximum image payload size in MiB before base64 encoding.
1821    #[serde(default = "default_multimodal_max_image_size_mb")]
1822    pub max_image_size_mb: usize,
1823    /// Allow fetching remote image URLs (http/https). Disabled by default.
1824    #[serde(default)]
1825    pub allow_remote_fetch: bool,
1826    /// Provider name to use for vision/image messages (e.g. `"ollama"`).
1827    /// When set, messages containing `[IMAGE:]` markers are routed to this
1828    /// provider instead of the default text provider.
1829    #[serde(default)]
1830    pub vision_provider: Option<String>,
1831    /// Model to use when routing to the vision provider (e.g. `"llava:7b"`).
1832    /// Only used when `vision_provider` is set.
1833    #[serde(default)]
1834    pub vision_model: Option<String>,
1835}
1836
1837fn default_multimodal_max_images() -> usize {
1838    4
1839}
1840
1841fn default_multimodal_max_image_size_mb() -> usize {
1842    5
1843}
1844
1845impl MultimodalConfig {
1846    /// Clamp configured values to safe runtime bounds.
1847    pub fn effective_limits(&self) -> (usize, usize) {
1848        let max_images = self.max_images.clamp(1, 16);
1849        let max_image_size_mb = self.max_image_size_mb.clamp(1, 20);
1850        (max_images, max_image_size_mb)
1851    }
1852}
1853
1854impl Default for MultimodalConfig {
1855    fn default() -> Self {
1856        Self {
1857            max_images: default_multimodal_max_images(),
1858            max_image_size_mb: default_multimodal_max_image_size_mb(),
1859            allow_remote_fetch: false,
1860            vision_provider: None,
1861            vision_model: None,
1862        }
1863    }
1864}
1865
1866// ── Media Pipeline ──────────────────────────────────────────────
1867
1868/// Automatic media understanding pipeline configuration (`[media_pipeline]`).
1869///
1870/// When enabled, inbound channel messages with media attachments are
1871/// pre-processed before reaching the agent: audio is transcribed, images are
1872/// annotated, and videos are summarised.
1873#[allow(clippy::struct_excessive_bools)]
1874#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1875pub struct MediaPipelineConfig {
1876    /// Master toggle for the media pipeline (default: false).
1877    #[serde(default)]
1878    pub enabled: bool,
1879
1880    /// Transcribe audio attachments using the configured transcription provider.
1881    #[serde(default = "default_true")]
1882    pub transcribe_audio: bool,
1883
1884    /// Add image descriptions when a vision-capable model is active.
1885    #[serde(default = "default_true")]
1886    pub describe_images: bool,
1887
1888    /// Summarize video attachments (placeholder — requires external API).
1889    #[serde(default = "default_true")]
1890    pub summarize_video: bool,
1891}
1892
1893impl Default for MediaPipelineConfig {
1894    fn default() -> Self {
1895        Self {
1896            enabled: false,
1897            transcribe_audio: true,
1898            describe_images: true,
1899            summarize_video: true,
1900        }
1901    }
1902}
1903
1904// ── Identity (AIEOS / OpenClaw format) ──────────────────────────
1905
1906/// Identity format configuration (`[identity]` section).
1907///
1908/// Supports `"openclaw"` (default) or `"aieos"` identity documents.
1909#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1910pub struct IdentityConfig {
1911    /// Identity format: "openclaw" (default) or "aieos"
1912    #[serde(default = "default_identity_format")]
1913    pub format: String,
1914    /// Path to AIEOS JSON file (relative to workspace)
1915    #[serde(default)]
1916    pub aieos_path: Option<String>,
1917    /// Inline AIEOS JSON (alternative to file path)
1918    #[serde(default)]
1919    pub aieos_inline: Option<String>,
1920}
1921
1922fn default_identity_format() -> String {
1923    "openclaw".into()
1924}
1925
1926impl Default for IdentityConfig {
1927    fn default() -> Self {
1928        Self {
1929            format: default_identity_format(),
1930            aieos_path: None,
1931            aieos_inline: None,
1932        }
1933    }
1934}
1935
1936// ── Cost tracking and budget enforcement ───────────────────────────
1937
1938/// Cost tracking and budget enforcement configuration (`[cost]` section).
1939#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1940pub struct CostConfig {
1941    /// Enable cost tracking (default: true)
1942    #[serde(default = "default_cost_enabled")]
1943    pub enabled: bool,
1944
1945    /// Daily spending limit in USD (default: 10.00)
1946    #[serde(default = "default_daily_limit")]
1947    pub daily_limit_usd: f64,
1948
1949    /// Monthly spending limit in USD (default: 100.00)
1950    #[serde(default = "default_monthly_limit")]
1951    pub monthly_limit_usd: f64,
1952
1953    /// Warn when spending reaches this percentage of limit (default: 80)
1954    #[serde(default = "default_warn_percent")]
1955    pub warn_at_percent: u8,
1956
1957    /// Allow requests to exceed budget with --override flag (default: false)
1958    #[serde(default)]
1959    pub allow_override: bool,
1960
1961    /// Per-model pricing (USD per 1M tokens)
1962    #[serde(default)]
1963    pub prices: std::collections::HashMap<String, ModelPricing>,
1964
1965    /// Cost enforcement behavior when budget limits are approached or exceeded.
1966    #[serde(default)]
1967    pub enforcement: CostEnforcementConfig,
1968}
1969
1970/// Configuration for cost enforcement behavior when budget limits are reached.
1971#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
1972pub struct CostEnforcementConfig {
1973    /// Enforcement mode: "warn", "block", or "route_down".
1974    #[serde(default = "default_cost_enforcement_mode")]
1975    pub mode: String,
1976    /// Model hint to route to when budget is exceeded (used with "route_down" mode).
1977    #[serde(default)]
1978    pub route_down_model: Option<String>,
1979    /// Reserve this percentage of budget for critical operations.
1980    #[serde(default = "default_reserve_percent")]
1981    pub reserve_percent: u8,
1982}
1983
1984fn default_cost_enforcement_mode() -> String {
1985    "warn".to_string()
1986}
1987
1988fn default_reserve_percent() -> u8 {
1989    10
1990}
1991
1992impl Default for CostEnforcementConfig {
1993    fn default() -> Self {
1994        Self {
1995            mode: default_cost_enforcement_mode(),
1996            route_down_model: None,
1997            reserve_percent: default_reserve_percent(),
1998        }
1999    }
2000}
2001
2002/// Per-model pricing entry (USD per 1M tokens).
2003#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2004pub struct ModelPricing {
2005    /// Input price per 1M tokens
2006    #[serde(default)]
2007    pub input: f64,
2008
2009    /// Output price per 1M tokens
2010    #[serde(default)]
2011    pub output: f64,
2012}
2013
2014fn default_daily_limit() -> f64 {
2015    10.0
2016}
2017
2018fn default_monthly_limit() -> f64 {
2019    100.0
2020}
2021
2022fn default_warn_percent() -> u8 {
2023    80
2024}
2025
2026fn default_cost_enabled() -> bool {
2027    true
2028}
2029
2030impl Default for CostConfig {
2031    fn default() -> Self {
2032        Self {
2033            enabled: true,
2034            daily_limit_usd: default_daily_limit(),
2035            monthly_limit_usd: default_monthly_limit(),
2036            warn_at_percent: default_warn_percent(),
2037            allow_override: false,
2038            prices: get_default_pricing(),
2039            enforcement: CostEnforcementConfig::default(),
2040        }
2041    }
2042}
2043
2044/// Default pricing for popular models (USD per 1M tokens)
2045fn get_default_pricing() -> std::collections::HashMap<String, ModelPricing> {
2046    let mut prices = std::collections::HashMap::new();
2047
2048    // Anthropic models
2049    prices.insert(
2050        "anthropic/claude-sonnet-4-20250514".into(),
2051        ModelPricing {
2052            input: 3.0,
2053            output: 15.0,
2054        },
2055    );
2056    prices.insert(
2057        "anthropic/claude-opus-4-20250514".into(),
2058        ModelPricing {
2059            input: 15.0,
2060            output: 75.0,
2061        },
2062    );
2063    prices.insert(
2064        "anthropic/claude-3.5-sonnet".into(),
2065        ModelPricing {
2066            input: 3.0,
2067            output: 15.0,
2068        },
2069    );
2070    prices.insert(
2071        "anthropic/claude-3-haiku".into(),
2072        ModelPricing {
2073            input: 0.25,
2074            output: 1.25,
2075        },
2076    );
2077
2078    // OpenAI models
2079    prices.insert(
2080        "openai/gpt-4o".into(),
2081        ModelPricing {
2082            input: 5.0,
2083            output: 15.0,
2084        },
2085    );
2086    prices.insert(
2087        "openai/gpt-4o-mini".into(),
2088        ModelPricing {
2089            input: 0.15,
2090            output: 0.60,
2091        },
2092    );
2093    prices.insert(
2094        "openai/o1-preview".into(),
2095        ModelPricing {
2096            input: 15.0,
2097            output: 60.0,
2098        },
2099    );
2100
2101    // OpenAI Codex (ChatGPT Responses API — GPT-5 family)
2102    prices.insert(
2103        "openai-codex/gpt-5.4".into(),
2104        ModelPricing {
2105            input: 1.25,
2106            output: 10.0,
2107        },
2108    );
2109    prices.insert(
2110        "openai-codex/gpt-5".into(),
2111        ModelPricing {
2112            input: 1.25,
2113            output: 10.0,
2114        },
2115    );
2116
2117    // Google models
2118    prices.insert(
2119        "google/gemini-2.0-flash".into(),
2120        ModelPricing {
2121            input: 0.10,
2122            output: 0.40,
2123        },
2124    );
2125    prices.insert(
2126        "google/gemini-1.5-pro".into(),
2127        ModelPricing {
2128            input: 1.25,
2129            output: 5.0,
2130        },
2131    );
2132
2133    prices
2134}
2135
2136// ── Peripherals (hardware: STM32, RPi GPIO, etc.) ────────────────────────
2137
2138/// Peripheral board integration configuration (`[peripherals]` section).
2139///
2140/// Boards become agent tools when enabled.
2141#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
2142pub struct PeripheralsConfig {
2143    /// Enable peripheral support (boards become agent tools)
2144    #[serde(default)]
2145    pub enabled: bool,
2146    /// Board configurations (nucleo-f401re, rpi-gpio, etc.)
2147    #[serde(default)]
2148    pub boards: Vec<PeripheralBoardConfig>,
2149    /// Path to datasheet docs (relative to workspace) for RAG retrieval.
2150    /// Place .md/.txt files named by board (e.g. nucleo-f401re.md, rpi-gpio.md).
2151    #[serde(default)]
2152    pub datasheet_dir: Option<String>,
2153}
2154
2155/// Configuration for a single peripheral board (e.g. STM32, RPi GPIO).
2156#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2157pub struct PeripheralBoardConfig {
2158    /// Board type: "nucleo-f401re", "rpi-gpio", "esp32", etc.
2159    pub board: String,
2160    /// Transport: "serial", "native", "websocket"
2161    #[serde(default = "default_peripheral_transport")]
2162    pub transport: String,
2163    /// Path for serial: "/dev/ttyACM0", "/dev/ttyUSB0"
2164    #[serde(default)]
2165    pub path: Option<String>,
2166    /// Baud rate for serial (default: 115200)
2167    #[serde(default = "default_peripheral_baud")]
2168    pub baud: u32,
2169}
2170
2171fn default_peripheral_transport() -> String {
2172    "serial".into()
2173}
2174
2175fn default_peripheral_baud() -> u32 {
2176    115_200
2177}
2178
2179impl Default for PeripheralBoardConfig {
2180    fn default() -> Self {
2181        Self {
2182            board: String::new(),
2183            transport: default_peripheral_transport(),
2184            path: None,
2185            baud: default_peripheral_baud(),
2186        }
2187    }
2188}
2189
2190// ── Gateway security ─────────────────────────────────────────────
2191
2192/// Gateway server configuration (`[gateway]` section).
2193///
2194/// Controls the HTTP gateway for webhook and pairing endpoints.
2195#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2196#[allow(clippy::struct_excessive_bools)]
2197pub struct GatewayConfig {
2198    /// Gateway port (default: 42617)
2199    #[serde(default = "default_gateway_port")]
2200    pub port: u16,
2201    /// Gateway host (default: 127.0.0.1)
2202    #[serde(default = "default_gateway_host")]
2203    pub host: String,
2204    /// Require pairing before accepting requests (default: true)
2205    #[serde(default = "default_true")]
2206    pub require_pairing: bool,
2207    /// Allow binding to non-localhost without a tunnel (default: false)
2208    #[serde(default)]
2209    pub allow_public_bind: bool,
2210    /// Paired bearer tokens (managed automatically, not user-edited)
2211    #[serde(default)]
2212    pub paired_tokens: Vec<String>,
2213
2214    /// Max `/pair` requests per minute per client key.
2215    #[serde(default = "default_pair_rate_limit")]
2216    pub pair_rate_limit_per_minute: u32,
2217
2218    /// Max `/webhook` requests per minute per client key.
2219    #[serde(default = "default_webhook_rate_limit")]
2220    pub webhook_rate_limit_per_minute: u32,
2221
2222    /// Trust proxy-forwarded client IP headers (`X-Forwarded-For`, `X-Real-IP`).
2223    /// Disabled by default; enable only behind a trusted reverse proxy.
2224    #[serde(default)]
2225    pub trust_forwarded_headers: bool,
2226
2227    /// Optional URL path prefix for reverse-proxy deployments.
2228    /// When set, all gateway routes are served under this prefix.
2229    /// Must start with `/` and must not end with `/`.
2230    #[serde(default)]
2231    pub path_prefix: Option<String>,
2232
2233    /// Maximum distinct client keys tracked by gateway rate limiter maps.
2234    #[serde(default = "default_gateway_rate_limit_max_keys")]
2235    pub rate_limit_max_keys: usize,
2236
2237    /// TTL for webhook idempotency keys.
2238    #[serde(default = "default_idempotency_ttl_secs")]
2239    pub idempotency_ttl_secs: u64,
2240
2241    /// Maximum distinct idempotency keys retained in memory.
2242    #[serde(default = "default_gateway_idempotency_max_keys")]
2243    pub idempotency_max_keys: usize,
2244
2245    /// Persist gateway WebSocket chat sessions to SQLite. Default: true.
2246    #[serde(default = "default_true")]
2247    pub session_persistence: bool,
2248
2249    /// Auto-archive stale gateway sessions older than N hours. 0 = disabled. Default: 0.
2250    #[serde(default)]
2251    pub session_ttl_hours: u32,
2252
2253    /// Pairing dashboard configuration
2254    #[serde(default)]
2255    pub pairing_dashboard: PairingDashboardConfig,
2256
2257    /// TLS configuration for the gateway server (`[gateway.tls]`).
2258    #[serde(default)]
2259    pub tls: Option<GatewayTlsConfig>,
2260}
2261
2262fn default_gateway_port() -> u16 {
2263    42617
2264}
2265
2266fn default_gateway_host() -> String {
2267    "127.0.0.1".into()
2268}
2269
2270fn default_pair_rate_limit() -> u32 {
2271    10
2272}
2273
2274fn default_webhook_rate_limit() -> u32 {
2275    60
2276}
2277
2278fn default_idempotency_ttl_secs() -> u64 {
2279    300
2280}
2281
2282fn default_gateway_rate_limit_max_keys() -> usize {
2283    10_000
2284}
2285
2286fn default_gateway_idempotency_max_keys() -> usize {
2287    10_000
2288}
2289
2290fn default_true() -> bool {
2291    true
2292}
2293
2294fn default_false() -> bool {
2295    false
2296}
2297
2298impl Default for GatewayConfig {
2299    fn default() -> Self {
2300        Self {
2301            port: default_gateway_port(),
2302            host: default_gateway_host(),
2303            require_pairing: true,
2304            allow_public_bind: false,
2305            paired_tokens: Vec::new(),
2306            pair_rate_limit_per_minute: default_pair_rate_limit(),
2307            webhook_rate_limit_per_minute: default_webhook_rate_limit(),
2308            trust_forwarded_headers: false,
2309            path_prefix: None,
2310            rate_limit_max_keys: default_gateway_rate_limit_max_keys(),
2311            idempotency_ttl_secs: default_idempotency_ttl_secs(),
2312            idempotency_max_keys: default_gateway_idempotency_max_keys(),
2313            session_persistence: true,
2314            session_ttl_hours: 0,
2315            pairing_dashboard: PairingDashboardConfig::default(),
2316            tls: None,
2317        }
2318    }
2319}
2320
2321/// Pairing dashboard configuration (`[gateway.pairing_dashboard]`).
2322#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2323pub struct PairingDashboardConfig {
2324    /// Length of pairing codes (default: 8)
2325    #[serde(default = "default_pairing_code_length")]
2326    pub code_length: usize,
2327    /// Time-to-live for pending pairing codes in seconds (default: 3600)
2328    #[serde(default = "default_pairing_ttl")]
2329    pub code_ttl_secs: u64,
2330    /// Maximum concurrent pending pairing codes (default: 3)
2331    #[serde(default = "default_max_pending_codes")]
2332    pub max_pending_codes: usize,
2333    /// Maximum failed pairing attempts before lockout (default: 5)
2334    #[serde(default = "default_max_failed_attempts")]
2335    pub max_failed_attempts: u32,
2336    /// Lockout duration in seconds after max attempts (default: 300)
2337    #[serde(default = "default_pairing_lockout_secs")]
2338    pub lockout_secs: u64,
2339}
2340
2341fn default_pairing_code_length() -> usize {
2342    8
2343}
2344fn default_pairing_ttl() -> u64 {
2345    3600
2346}
2347fn default_max_pending_codes() -> usize {
2348    3
2349}
2350fn default_max_failed_attempts() -> u32 {
2351    5
2352}
2353fn default_pairing_lockout_secs() -> u64 {
2354    300
2355}
2356
2357impl Default for PairingDashboardConfig {
2358    fn default() -> Self {
2359        Self {
2360            code_length: default_pairing_code_length(),
2361            code_ttl_secs: default_pairing_ttl(),
2362            max_pending_codes: default_max_pending_codes(),
2363            max_failed_attempts: default_max_failed_attempts(),
2364            lockout_secs: default_pairing_lockout_secs(),
2365        }
2366    }
2367}
2368
2369/// TLS configuration for the gateway server (`[gateway.tls]`).
2370#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2371pub struct GatewayTlsConfig {
2372    /// Enable TLS for the gateway (default: false).
2373    #[serde(default)]
2374    pub enabled: bool,
2375    /// Path to the PEM-encoded server certificate file.
2376    pub cert_path: String,
2377    /// Path to the PEM-encoded server private key file.
2378    pub key_path: String,
2379    /// Client certificate authentication (mutual TLS) settings.
2380    #[serde(default)]
2381    pub client_auth: Option<GatewayClientAuthConfig>,
2382}
2383
2384/// Client certificate authentication (mTLS) configuration (`[gateway.tls.client_auth]`).
2385#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2386pub struct GatewayClientAuthConfig {
2387    /// Enable client certificate verification (default: false).
2388    #[serde(default)]
2389    pub enabled: bool,
2390    /// Path to the PEM-encoded CA certificate used to verify client certs.
2391    pub ca_cert_path: String,
2392    /// Reject connections that do not present a valid client certificate (default: true).
2393    #[serde(default = "default_true")]
2394    pub require_client_cert: bool,
2395    /// Optional SHA-256 fingerprints for certificate pinning.
2396    /// When non-empty, only client certs matching one of these fingerprints are accepted.
2397    #[serde(default)]
2398    pub pinned_certs: Vec<String>,
2399}
2400
2401/// Secure transport configuration for inter-node communication (`[node_transport]`).
2402#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2403pub struct NodeTransportConfig {
2404    /// Enable the secure transport layer.
2405    #[serde(default = "default_node_transport_enabled")]
2406    pub enabled: bool,
2407    /// Shared secret for HMAC authentication between nodes.
2408    #[serde(default)]
2409    pub shared_secret: String,
2410    /// Maximum age of signed requests in seconds (replay protection).
2411    #[serde(default = "default_max_request_age")]
2412    pub max_request_age_secs: i64,
2413    /// Require HTTPS for all node communication.
2414    #[serde(default = "default_require_https")]
2415    pub require_https: bool,
2416    /// Allow specific node IPs/CIDRs.
2417    #[serde(default)]
2418    pub allowed_peers: Vec<String>,
2419    /// Path to TLS certificate file.
2420    #[serde(default)]
2421    pub tls_cert_path: Option<String>,
2422    /// Path to TLS private key file.
2423    #[serde(default)]
2424    pub tls_key_path: Option<String>,
2425    /// Require client certificates (mutual TLS).
2426    #[serde(default)]
2427    pub mutual_tls: bool,
2428    /// Maximum number of connections per peer.
2429    #[serde(default = "default_connection_pool_size")]
2430    pub connection_pool_size: usize,
2431}
2432
2433fn default_node_transport_enabled() -> bool {
2434    true
2435}
2436fn default_max_request_age() -> i64 {
2437    300
2438}
2439fn default_require_https() -> bool {
2440    true
2441}
2442fn default_connection_pool_size() -> usize {
2443    4
2444}
2445
2446impl Default for NodeTransportConfig {
2447    fn default() -> Self {
2448        Self {
2449            enabled: default_node_transport_enabled(),
2450            shared_secret: String::new(),
2451            max_request_age_secs: default_max_request_age(),
2452            require_https: default_require_https(),
2453            allowed_peers: Vec::new(),
2454            tls_cert_path: None,
2455            tls_key_path: None,
2456            mutual_tls: false,
2457            connection_pool_size: default_connection_pool_size(),
2458        }
2459    }
2460}
2461
2462// ── Composio (managed tool surface) ─────────────────────────────
2463
2464/// Composio managed OAuth tools integration (`[composio]` section).
2465///
2466/// Provides access to 1000+ OAuth-connected tools via the Composio platform.
2467#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2468pub struct ComposioConfig {
2469    /// Enable Composio integration for 1000+ OAuth tools
2470    #[serde(default, alias = "enable")]
2471    pub enabled: bool,
2472    /// Composio API key (stored encrypted when secrets.encrypt = true)
2473    #[serde(default)]
2474    pub api_key: Option<String>,
2475    /// Default entity ID for multi-user setups
2476    #[serde(default = "default_entity_id")]
2477    pub entity_id: String,
2478}
2479
2480fn default_entity_id() -> String {
2481    "default".into()
2482}
2483
2484impl Default for ComposioConfig {
2485    fn default() -> Self {
2486        Self {
2487            enabled: false,
2488            api_key: None,
2489            entity_id: default_entity_id(),
2490        }
2491    }
2492}
2493
2494// ── Microsoft 365 (Graph API integration) ───────────────────────
2495
2496/// Microsoft 365 integration via Microsoft Graph API (`[microsoft365]` section).
2497///
2498/// Provides access to Outlook mail, Teams messages, Calendar events,
2499/// OneDrive files, and SharePoint search.
2500#[derive(Clone, Serialize, Deserialize, JsonSchema)]
2501pub struct Microsoft365Config {
2502    /// Enable Microsoft 365 integration
2503    #[serde(default, alias = "enable")]
2504    pub enabled: bool,
2505    /// Azure AD tenant ID
2506    #[serde(default)]
2507    pub tenant_id: Option<String>,
2508    /// Azure AD application (client) ID
2509    #[serde(default)]
2510    pub client_id: Option<String>,
2511    /// Azure AD client secret (stored encrypted when secrets.encrypt = true)
2512    #[serde(default)]
2513    pub client_secret: Option<String>,
2514    /// Authentication flow: "client_credentials" or "device_code"
2515    #[serde(default = "default_ms365_auth_flow")]
2516    pub auth_flow: String,
2517    /// OAuth scopes to request
2518    #[serde(default = "default_ms365_scopes")]
2519    pub scopes: Vec<String>,
2520    /// Encrypt the token cache file on disk
2521    #[serde(default = "default_true")]
2522    pub token_cache_encrypted: bool,
2523    /// User principal name or "me" (for delegated flows)
2524    #[serde(default)]
2525    pub user_id: Option<String>,
2526}
2527
2528fn default_ms365_auth_flow() -> String {
2529    "client_credentials".to_string()
2530}
2531
2532fn default_ms365_scopes() -> Vec<String> {
2533    vec!["https://graph.microsoft.com/.default".to_string()]
2534}
2535
2536impl std::fmt::Debug for Microsoft365Config {
2537    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2538        f.debug_struct("Microsoft365Config")
2539            .field("enabled", &self.enabled)
2540            .field("tenant_id", &self.tenant_id)
2541            .field("client_id", &self.client_id)
2542            .field("client_secret", &self.client_secret.as_ref().map(|_| "***"))
2543            .field("auth_flow", &self.auth_flow)
2544            .field("scopes", &self.scopes)
2545            .field("token_cache_encrypted", &self.token_cache_encrypted)
2546            .field("user_id", &self.user_id)
2547            .finish()
2548    }
2549}
2550
2551impl Default for Microsoft365Config {
2552    fn default() -> Self {
2553        Self {
2554            enabled: false,
2555            tenant_id: None,
2556            client_id: None,
2557            client_secret: None,
2558            auth_flow: default_ms365_auth_flow(),
2559            scopes: default_ms365_scopes(),
2560            token_cache_encrypted: true,
2561            user_id: None,
2562        }
2563    }
2564}
2565
2566// ── Secrets (encrypted credential store) ────────────────────────
2567
2568/// Secrets encryption configuration (`[secrets]` section).
2569#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2570pub struct SecretsConfig {
2571    /// Enable encryption for API keys and tokens in config.toml
2572    #[serde(default = "default_true")]
2573    pub encrypt: bool,
2574}
2575
2576impl Default for SecretsConfig {
2577    fn default() -> Self {
2578        Self { encrypt: true }
2579    }
2580}
2581
2582// ── Browser (friendly-service browsing only) ───────────────────
2583
2584/// Computer-use sidecar configuration (`[browser.computer_use]` section).
2585///
2586/// Delegates OS-level mouse, keyboard, and screenshot actions to a local sidecar.
2587#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2588pub struct BrowserComputerUseConfig {
2589    /// Sidecar endpoint for computer-use actions (OS-level mouse/keyboard/screenshot)
2590    #[serde(default = "default_browser_computer_use_endpoint")]
2591    pub endpoint: String,
2592    /// Optional bearer token for computer-use sidecar
2593    #[serde(default)]
2594    pub api_key: Option<String>,
2595    /// Per-action request timeout in milliseconds
2596    #[serde(default = "default_browser_computer_use_timeout_ms")]
2597    pub timeout_ms: u64,
2598    /// Allow remote/public endpoint for computer-use sidecar (default: false)
2599    #[serde(default)]
2600    pub allow_remote_endpoint: bool,
2601    /// Optional window title/process allowlist forwarded to sidecar policy
2602    #[serde(default)]
2603    pub window_allowlist: Vec<String>,
2604    /// Optional X-axis boundary for coordinate-based actions
2605    #[serde(default)]
2606    pub max_coordinate_x: Option<i64>,
2607    /// Optional Y-axis boundary for coordinate-based actions
2608    #[serde(default)]
2609    pub max_coordinate_y: Option<i64>,
2610}
2611
2612fn default_browser_computer_use_endpoint() -> String {
2613    "http://127.0.0.1:8787/v1/actions".into()
2614}
2615
2616fn default_browser_computer_use_timeout_ms() -> u64 {
2617    15_000
2618}
2619
2620impl Default for BrowserComputerUseConfig {
2621    fn default() -> Self {
2622        Self {
2623            endpoint: default_browser_computer_use_endpoint(),
2624            api_key: None,
2625            timeout_ms: default_browser_computer_use_timeout_ms(),
2626            allow_remote_endpoint: false,
2627            window_allowlist: Vec::new(),
2628            max_coordinate_x: None,
2629            max_coordinate_y: None,
2630        }
2631    }
2632}
2633
2634/// Browser automation configuration (`[browser]` section).
2635///
2636/// Controls the `browser_open` tool and browser automation backends.
2637#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2638pub struct BrowserConfig {
2639    /// Enable `browser_open` tool (opens URLs in the system browser without scraping)
2640    #[serde(default)]
2641    pub enabled: bool,
2642    /// Allowed domains for `browser_open` (exact or subdomain match)
2643    #[serde(default)]
2644    pub allowed_domains: Vec<String>,
2645    /// Browser session name (for agent-browser automation)
2646    #[serde(default)]
2647    pub session_name: Option<String>,
2648    /// Browser automation backend: "agent_browser" | "rust_native" | "computer_use" | "auto"
2649    #[serde(default = "default_browser_backend")]
2650    pub backend: String,
2651    /// Headless mode for rust-native backend
2652    #[serde(default = "default_true")]
2653    pub native_headless: bool,
2654    /// WebDriver endpoint URL for rust-native backend (e.g. http://127.0.0.1:9515)
2655    #[serde(default = "default_browser_webdriver_url")]
2656    pub native_webdriver_url: String,
2657    /// Optional Chrome/Chromium executable path for rust-native backend
2658    #[serde(default)]
2659    pub native_chrome_path: Option<String>,
2660    /// Computer-use sidecar configuration
2661    #[serde(default)]
2662    pub computer_use: BrowserComputerUseConfig,
2663}
2664
2665fn default_browser_backend() -> String {
2666    "agent_browser".into()
2667}
2668
2669fn default_browser_webdriver_url() -> String {
2670    "http://127.0.0.1:9515".into()
2671}
2672
2673impl Default for BrowserConfig {
2674    fn default() -> Self {
2675        Self {
2676            enabled: true,
2677            allowed_domains: vec!["*".into()],
2678            session_name: None,
2679            backend: default_browser_backend(),
2680            native_headless: default_true(),
2681            native_webdriver_url: default_browser_webdriver_url(),
2682            native_chrome_path: None,
2683            computer_use: BrowserComputerUseConfig::default(),
2684        }
2685    }
2686}
2687
2688// ── HTTP request tool ───────────────────────────────────────────
2689
2690/// HTTP request tool configuration (`[http_request]` section).
2691///
2692/// Domain filtering: `allowed_domains` controls which hosts are reachable (use `["*"]`
2693/// for all public hosts, which is the default). If `allowed_domains` is empty, all
2694/// requests are rejected.
2695#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2696pub struct HttpRequestConfig {
2697    /// Enable `http_request` tool for API interactions
2698    #[serde(default)]
2699    pub enabled: bool,
2700    /// Allowed domains for HTTP requests (exact or subdomain match)
2701    #[serde(default)]
2702    pub allowed_domains: Vec<String>,
2703    /// Maximum response size in bytes (default: 1MB, 0 = unlimited)
2704    #[serde(default = "default_http_max_response_size")]
2705    pub max_response_size: usize,
2706    /// Request timeout in seconds (default: 30)
2707    #[serde(default = "default_http_timeout_secs")]
2708    pub timeout_secs: u64,
2709    /// Allow requests to private/LAN hosts (RFC 1918, loopback, link-local, .local).
2710    /// Default: false (deny private hosts for SSRF protection).
2711    #[serde(default)]
2712    pub allow_private_hosts: bool,
2713}
2714
2715impl Default for HttpRequestConfig {
2716    fn default() -> Self {
2717        Self {
2718            enabled: true,
2719            allowed_domains: vec!["*".into()],
2720            max_response_size: default_http_max_response_size(),
2721            timeout_secs: default_http_timeout_secs(),
2722            allow_private_hosts: false,
2723        }
2724    }
2725}
2726
2727fn default_http_max_response_size() -> usize {
2728    1_000_000 // 1MB
2729}
2730
2731fn default_http_timeout_secs() -> u64 {
2732    30
2733}
2734
2735// ── Web fetch ────────────────────────────────────────────────────
2736
2737/// Web fetch tool configuration (`[web_fetch]` section).
2738///
2739/// Fetches web pages and converts HTML to plain text for LLM consumption.
2740/// Domain filtering: `allowed_domains` controls which hosts are reachable (use `["*"]`
2741/// for all public hosts). `blocked_domains` takes priority over `allowed_domains`.
2742/// If `allowed_domains` is empty, all requests are rejected (deny-by-default).
2743#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2744pub struct WebFetchConfig {
2745    /// Enable `web_fetch` tool for fetching web page content
2746    #[serde(default)]
2747    pub enabled: bool,
2748    /// Allowed domains for web fetch (exact or subdomain match; `["*"]` = all public hosts)
2749    #[serde(default = "default_web_fetch_allowed_domains")]
2750    pub allowed_domains: Vec<String>,
2751    /// Blocked domains (exact or subdomain match; always takes priority over allowed_domains)
2752    #[serde(default)]
2753    pub blocked_domains: Vec<String>,
2754    /// Private/internal hosts allowed to bypass SSRF protection (e.g. `["192.168.1.10", "internal.local"]`)
2755    #[serde(default)]
2756    pub allowed_private_hosts: Vec<String>,
2757    /// Maximum response size in bytes (default: 500KB, plain text is much smaller than raw HTML)
2758    #[serde(default = "default_web_fetch_max_response_size")]
2759    pub max_response_size: usize,
2760    /// Request timeout in seconds (default: 30)
2761    #[serde(default = "default_web_fetch_timeout_secs")]
2762    pub timeout_secs: u64,
2763    /// Firecrawl fallback configuration (`[web_fetch.firecrawl]`)
2764    #[serde(default)]
2765    pub firecrawl: FirecrawlConfig,
2766}
2767
2768/// Firecrawl fallback mode: scrape a single page or crawl linked pages.
2769#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
2770#[serde(rename_all = "lowercase")]
2771pub enum FirecrawlMode {
2772    #[default]
2773    Scrape,
2774    /// Reserved for future multi-page crawl support. Accepted in config
2775    /// deserialization to avoid breaking existing files, but not yet
2776    /// implemented — `fetch_via_firecrawl` always uses the `/scrape` endpoint.
2777    Crawl,
2778}
2779
2780/// Firecrawl fallback configuration for JS-heavy and bot-blocked sites.
2781///
2782/// When enabled, if the standard web fetch fails (HTTP error, empty body, or
2783/// body shorter than 100 characters suggesting a JS-only page), the tool
2784/// falls back to the Firecrawl API for stealth content extraction.
2785#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2786pub struct FirecrawlConfig {
2787    /// Enable Firecrawl fallback
2788    #[serde(default)]
2789    pub enabled: bool,
2790    /// Environment variable name for the Firecrawl API key
2791    #[serde(default = "default_firecrawl_api_key_env")]
2792    pub api_key_env: String,
2793    /// Firecrawl API base URL
2794    #[serde(default = "default_firecrawl_api_url")]
2795    pub api_url: String,
2796    /// Firecrawl extraction mode
2797    #[serde(default)]
2798    pub mode: FirecrawlMode,
2799}
2800
2801fn default_firecrawl_api_key_env() -> String {
2802    "FIRECRAWL_API_KEY".into()
2803}
2804
2805fn default_firecrawl_api_url() -> String {
2806    "https://api.firecrawl.dev/v1".into()
2807}
2808
2809impl Default for FirecrawlConfig {
2810    fn default() -> Self {
2811        Self {
2812            enabled: false,
2813            api_key_env: default_firecrawl_api_key_env(),
2814            api_url: default_firecrawl_api_url(),
2815            mode: FirecrawlMode::default(),
2816        }
2817    }
2818}
2819
2820fn default_web_fetch_max_response_size() -> usize {
2821    500_000 // 500KB
2822}
2823
2824fn default_web_fetch_timeout_secs() -> u64 {
2825    30
2826}
2827
2828fn default_web_fetch_allowed_domains() -> Vec<String> {
2829    vec!["*".into()]
2830}
2831
2832impl Default for WebFetchConfig {
2833    fn default() -> Self {
2834        Self {
2835            enabled: true,
2836            allowed_domains: vec!["*".into()],
2837            blocked_domains: vec![],
2838            allowed_private_hosts: vec![],
2839            max_response_size: default_web_fetch_max_response_size(),
2840            timeout_secs: default_web_fetch_timeout_secs(),
2841            firecrawl: FirecrawlConfig::default(),
2842        }
2843    }
2844}
2845
2846// ── Link enricher ─────────────────────────────────────────────────
2847
2848/// Automatic link understanding for inbound channel messages (`[link_enricher]`).
2849///
2850/// When enabled, URLs in incoming messages are automatically fetched and
2851/// summarised. The summary is prepended to the message before the agent
2852/// processes it, giving the LLM context about linked pages without an
2853/// explicit tool call.
2854#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2855pub struct LinkEnricherConfig {
2856    /// Enable the link enricher pipeline stage (default: false)
2857    #[serde(default)]
2858    pub enabled: bool,
2859    /// Maximum number of links to fetch per message (default: 3)
2860    #[serde(default = "default_link_enricher_max_links")]
2861    pub max_links: usize,
2862    /// Per-link fetch timeout in seconds (default: 10)
2863    #[serde(default = "default_link_enricher_timeout_secs")]
2864    pub timeout_secs: u64,
2865}
2866
2867fn default_link_enricher_max_links() -> usize {
2868    3
2869}
2870
2871fn default_link_enricher_timeout_secs() -> u64 {
2872    10
2873}
2874
2875impl Default for LinkEnricherConfig {
2876    fn default() -> Self {
2877        Self {
2878            enabled: false,
2879            max_links: default_link_enricher_max_links(),
2880            timeout_secs: default_link_enricher_timeout_secs(),
2881        }
2882    }
2883}
2884
2885// ── Text browser ─────────────────────────────────────────────────
2886
2887/// Text browser tool configuration (`[text_browser]` section).
2888///
2889/// Uses text-based browsers (lynx, links, w3m) to render web pages as plain
2890/// text. Designed for headless/SSH environments without graphical browsers.
2891#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2892pub struct TextBrowserConfig {
2893    /// Enable `text_browser` tool
2894    #[serde(default)]
2895    pub enabled: bool,
2896    /// Preferred text browser ("lynx", "links", or "w3m"). If unset, auto-detects.
2897    #[serde(default)]
2898    pub preferred_browser: Option<String>,
2899    /// Request timeout in seconds (default: 30)
2900    #[serde(default = "default_text_browser_timeout_secs")]
2901    pub timeout_secs: u64,
2902}
2903
2904fn default_text_browser_timeout_secs() -> u64 {
2905    30
2906}
2907
2908impl Default for TextBrowserConfig {
2909    fn default() -> Self {
2910        Self {
2911            enabled: false,
2912            preferred_browser: None,
2913            timeout_secs: default_text_browser_timeout_secs(),
2914        }
2915    }
2916}
2917
2918// ── Shell tool ───────────────────────────────────────────────────
2919
2920/// Shell tool configuration (`[shell_tool]` section).
2921///
2922/// Controls the behaviour of the `shell` execution tool. The main
2923/// tunable is `timeout_secs` — the maximum wall-clock time a single
2924/// shell command may run before it is killed.
2925#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2926pub struct ShellToolConfig {
2927    /// Maximum shell command execution time in seconds (default: 60).
2928    #[serde(default = "default_shell_tool_timeout_secs")]
2929    pub timeout_secs: u64,
2930}
2931
2932fn default_shell_tool_timeout_secs() -> u64 {
2933    60
2934}
2935
2936impl Default for ShellToolConfig {
2937    fn default() -> Self {
2938        Self {
2939            timeout_secs: default_shell_tool_timeout_secs(),
2940        }
2941    }
2942}
2943
2944// ── Web search ───────────────────────────────────────────────────
2945
2946/// Web search tool configuration (`[web_search]` section).
2947#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2948pub struct WebSearchConfig {
2949    /// Enable `web_search_tool` for web searches
2950    #[serde(default)]
2951    pub enabled: bool,
2952    /// Search provider: "duckduckgo" (free), "brave" (requires API key), or "searxng" (self-hosted)
2953    #[serde(default = "default_web_search_provider")]
2954    pub provider: String,
2955    /// Brave Search API key (required if provider is "brave")
2956    #[serde(default)]
2957    pub brave_api_key: Option<String>,
2958    /// SearXNG instance URL (required if provider is "searxng"), e.g. "https://searx.example.com"
2959    #[serde(default)]
2960    pub searxng_instance_url: Option<String>,
2961    /// Maximum results per search (1-10)
2962    #[serde(default = "default_web_search_max_results")]
2963    pub max_results: usize,
2964    /// Request timeout in seconds
2965    #[serde(default = "default_web_search_timeout_secs")]
2966    pub timeout_secs: u64,
2967}
2968
2969fn default_web_search_provider() -> String {
2970    "duckduckgo".into()
2971}
2972
2973fn default_web_search_max_results() -> usize {
2974    5
2975}
2976
2977fn default_web_search_timeout_secs() -> u64 {
2978    15
2979}
2980
2981impl Default for WebSearchConfig {
2982    fn default() -> Self {
2983        Self {
2984            enabled: true,
2985            provider: default_web_search_provider(),
2986            brave_api_key: None,
2987            searxng_instance_url: None,
2988            max_results: default_web_search_max_results(),
2989            timeout_secs: default_web_search_timeout_secs(),
2990        }
2991    }
2992}
2993
2994// ── Project Intelligence ────────────────────────────────────────
2995
2996/// Project delivery intelligence configuration (`[project_intel]` section).
2997#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
2998pub struct ProjectIntelConfig {
2999    /// Enable the project_intel tool. Default: false.
3000    #[serde(default)]
3001    pub enabled: bool,
3002    /// Default report language (en, de, fr, it). Default: "en".
3003    #[serde(default = "default_project_intel_language")]
3004    pub default_language: String,
3005    /// Output directory for generated reports.
3006    #[serde(default = "default_project_intel_report_dir")]
3007    pub report_output_dir: String,
3008    /// Optional custom templates directory.
3009    #[serde(default)]
3010    pub templates_dir: Option<String>,
3011    /// Risk detection sensitivity: low, medium, high. Default: "medium".
3012    #[serde(default = "default_project_intel_risk_sensitivity")]
3013    pub risk_sensitivity: String,
3014    /// Include git log data in reports. Default: true.
3015    #[serde(default = "default_true")]
3016    pub include_git_data: bool,
3017    /// Include Jira data in reports. Default: false.
3018    #[serde(default)]
3019    pub include_jira_data: bool,
3020    /// Jira instance base URL (required if include_jira_data is true).
3021    #[serde(default)]
3022    pub jira_base_url: Option<String>,
3023}
3024
3025fn default_project_intel_language() -> String {
3026    "en".into()
3027}
3028
3029fn default_project_intel_report_dir() -> String {
3030    "~/.construct/project-reports".into()
3031}
3032
3033fn default_project_intel_risk_sensitivity() -> String {
3034    "medium".into()
3035}
3036
3037impl Default for ProjectIntelConfig {
3038    fn default() -> Self {
3039        Self {
3040            enabled: false,
3041            default_language: default_project_intel_language(),
3042            report_output_dir: default_project_intel_report_dir(),
3043            templates_dir: None,
3044            risk_sensitivity: default_project_intel_risk_sensitivity(),
3045            include_git_data: true,
3046            include_jira_data: false,
3047            jira_base_url: None,
3048        }
3049    }
3050}
3051
3052// ── Backup ──────────────────────────────────────────────────────
3053
3054/// Backup tool configuration (`[backup]` section).
3055#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3056pub struct BackupConfig {
3057    /// Enable the `backup` tool.
3058    #[serde(default = "default_true")]
3059    pub enabled: bool,
3060    /// Maximum number of backups to keep (oldest are pruned).
3061    #[serde(default = "default_backup_max_keep")]
3062    pub max_keep: usize,
3063    /// Workspace subdirectories to include in backups.
3064    #[serde(default = "default_backup_include_dirs")]
3065    pub include_dirs: Vec<String>,
3066    /// Output directory for backup archives (relative to workspace root).
3067    #[serde(default = "default_backup_destination_dir")]
3068    pub destination_dir: String,
3069    /// Optional cron expression for scheduled automatic backups.
3070    #[serde(default)]
3071    pub schedule_cron: Option<String>,
3072    /// IANA timezone for `schedule_cron`.
3073    #[serde(default)]
3074    pub schedule_timezone: Option<String>,
3075    /// Compress backup archives.
3076    #[serde(default = "default_true")]
3077    pub compress: bool,
3078    /// Encrypt backup archives (requires a configured secret store key).
3079    #[serde(default)]
3080    pub encrypt: bool,
3081}
3082
3083fn default_backup_max_keep() -> usize {
3084    10
3085}
3086
3087fn default_backup_include_dirs() -> Vec<String> {
3088    vec![
3089        "config".into(),
3090        "memory".into(),
3091        "audit".into(),
3092        "knowledge".into(),
3093    ]
3094}
3095
3096fn default_backup_destination_dir() -> String {
3097    "state/backups".into()
3098}
3099
3100impl Default for BackupConfig {
3101    fn default() -> Self {
3102        Self {
3103            enabled: true,
3104            max_keep: default_backup_max_keep(),
3105            include_dirs: default_backup_include_dirs(),
3106            destination_dir: default_backup_destination_dir(),
3107            schedule_cron: None,
3108            schedule_timezone: None,
3109            compress: true,
3110            encrypt: false,
3111        }
3112    }
3113}
3114
3115// ── Data Retention ──────────────────────────────────────────────
3116
3117/// Data retention and purge configuration (`[data_retention]` section).
3118#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3119pub struct DataRetentionConfig {
3120    /// Enable the `data_management` tool.
3121    #[serde(default)]
3122    pub enabled: bool,
3123    /// Days of data to retain before purge eligibility.
3124    #[serde(default = "default_retention_days")]
3125    pub retention_days: u64,
3126    /// Preview what would be deleted without actually removing anything.
3127    #[serde(default)]
3128    pub dry_run: bool,
3129    /// Limit retention enforcement to specific data categories (empty = all).
3130    #[serde(default)]
3131    pub categories: Vec<String>,
3132}
3133
3134fn default_retention_days() -> u64 {
3135    90
3136}
3137
3138impl Default for DataRetentionConfig {
3139    fn default() -> Self {
3140        Self {
3141            enabled: false,
3142            retention_days: default_retention_days(),
3143            dry_run: false,
3144            categories: Vec::new(),
3145        }
3146    }
3147}
3148
3149// ── Google Workspace ─────────────────────────────────────────────
3150
3151/// Built-in default service allowlist for the `google_workspace` tool.
3152///
3153/// Applied when `allowed_services` is empty. Defined here (not in the tool layer)
3154/// so that config validation can cross-check `allowed_operations` entries against
3155/// the effective service set in all cases, including when the operator relies on
3156/// the default.
3157pub const DEFAULT_GWS_SERVICES: &[&str] = &[
3158    "drive",
3159    "sheets",
3160    "gmail",
3161    "calendar",
3162    "docs",
3163    "slides",
3164    "tasks",
3165    "people",
3166    "chat",
3167    "classroom",
3168    "forms",
3169    "keep",
3170    "meet",
3171    "events",
3172];
3173
3174/// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]` section).
3175///
3176/// ## Defaults
3177/// - `enabled`: `false` (tool is not registered unless explicitly opted-in).
3178/// - `allowed_services`: empty vector, which grants access to the full default
3179///   service set: `drive`, `sheets`, `gmail`, `calendar`, `docs`, `slides`,
3180///   `tasks`, `people`, `chat`, `classroom`, `forms`, `keep`, `meet`, `events`.
3181/// - `credentials_path`: `None` (uses default `gws` credential discovery).
3182/// - `default_account`: `None` (uses the `gws` active account).
3183/// - `rate_limit_per_minute`: `60`.
3184/// - `timeout_secs`: `30`.
3185/// - `audit_log`: `false`.
3186/// - `credentials_path`: `None` (uses default `gws` credential discovery).
3187/// - `default_account`: `None` (uses the `gws` active account).
3188/// - `rate_limit_per_minute`: `60`.
3189/// - `timeout_secs`: `30`.
3190/// - `audit_log`: `false`.
3191///
3192/// ## Compatibility
3193/// Configs that omit the `[google_workspace]` section entirely are treated as
3194/// `GoogleWorkspaceConfig::default()` (disabled, all defaults allowed). Adding
3195/// the section is purely opt-in and does not affect other config sections.
3196///
3197/// ## Rollback / Migration
3198/// To revert, remove the `[google_workspace]` section from the config file (or
3199/// set `enabled = false`). No data migration is required; the tool simply stops
3200/// being registered.
3201#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3202pub struct GoogleWorkspaceAllowedOperation {
3203    /// Google Workspace service ID (for example `gmail` or `drive`).
3204    pub service: String,
3205    /// Top-level resource name for the service (for example `users` for Gmail or `files` for Drive).
3206    pub resource: String,
3207    /// Optional sub-resource for 4-segment gws commands
3208    /// (for example `messages` or `drafts` under `gmail users`).
3209    /// When present, the entry only matches calls that include this exact sub_resource.
3210    /// When absent, the entry only matches calls with no sub_resource.
3211    #[serde(default)]
3212    pub sub_resource: Option<String>,
3213    /// Allowed methods for the service/resource/sub_resource combination.
3214    #[serde(default)]
3215    pub methods: Vec<String>,
3216}
3217
3218/// Google Workspace CLI (`gws`) tool configuration (`[google_workspace]` section).
3219///
3220/// ## Defaults
3221/// - `enabled`: `false` (tool is not registered unless explicitly opted-in).
3222/// - `allowed_services`: empty vector, which grants access to the full default
3223///   service set: `drive`, `sheets`, `gmail`, `calendar`, `docs`, `slides`,
3224///   `tasks`, `people`, `chat`, `classroom`, `forms`, `keep`, `meet`, `events`.
3225/// - `allowed_operations`: empty vector, which preserves the legacy behavior of
3226///   allowing any resource/method under the allowed service set.
3227/// - `credentials_path`: `None` (uses default `gws` credential discovery).
3228/// - `default_account`: `None` (uses the `gws` active account).
3229/// - `rate_limit_per_minute`: `60`.
3230/// - `timeout_secs`: `30`.
3231/// - `audit_log`: `false`.
3232///
3233/// ## Compatibility
3234/// Configs that omit the `[google_workspace]` section entirely are treated as
3235/// `GoogleWorkspaceConfig::default()` (disabled, all defaults allowed). Adding
3236/// the section is purely opt-in and does not affect other config sections.
3237///
3238/// ## Rollback / Migration
3239/// To revert, remove the `[google_workspace]` section from the config file (or
3240/// set `enabled = false`). No data migration is required; the tool simply stops
3241/// being registered.
3242#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3243pub struct GoogleWorkspaceConfig {
3244    /// Enable the `google_workspace` tool. Default: `false`.
3245    #[serde(default)]
3246    pub enabled: bool,
3247    /// Restrict which Google Workspace services the agent can access.
3248    ///
3249    /// When empty (the default), the full default service set is allowed (see
3250    /// struct-level docs). When non-empty, only the listed service IDs are
3251    /// permitted. Each entry must be non-empty, lowercase alphanumeric with
3252    /// optional underscores/hyphens, and unique.
3253    #[serde(default)]
3254    pub allowed_services: Vec<String>,
3255    /// Restrict which resource/method combinations the agent can access.
3256    ///
3257    /// When empty (the default), all methods under `allowed_services` remain
3258    /// available for backward compatibility. When non-empty, the runtime denies
3259    /// any `(service, resource, sub_resource, method)` combination that is not
3260    /// explicitly listed. `sub_resource` is optional per entry: an entry without
3261    /// it matches only 3-segment `gws` calls; an entry with it matches only calls
3262    /// that supply that exact sub_resource value.
3263    ///
3264    /// Each entry's `service` must appear in `allowed_services` when that list is
3265    /// non-empty; config validation rejects entries that would never match at
3266    /// runtime.
3267    #[serde(default)]
3268    pub allowed_operations: Vec<GoogleWorkspaceAllowedOperation>,
3269    /// Path to service account JSON or OAuth client credentials file.
3270    ///
3271    /// When `None`, the tool relies on the default `gws` credential discovery
3272    /// (`gws auth login`). Set this to point at a service-account key or an
3273    /// OAuth client-secrets JSON for headless / CI environments.
3274    #[serde(default)]
3275    pub credentials_path: Option<String>,
3276    /// Default Google account email to pass to `gws --account`.
3277    ///
3278    /// When `None`, the currently active `gws` account is used.
3279    #[serde(default)]
3280    pub default_account: Option<String>,
3281    /// Maximum number of `gws` API calls allowed per minute. Default: `60`.
3282    #[serde(default = "default_gws_rate_limit")]
3283    pub rate_limit_per_minute: u32,
3284    /// Command execution timeout in seconds. Default: `30`.
3285    #[serde(default = "default_gws_timeout_secs")]
3286    pub timeout_secs: u64,
3287    /// Enable audit logging of every `gws` invocation (service, resource,
3288    /// method, timestamp). Default: `false`.
3289    #[serde(default)]
3290    pub audit_log: bool,
3291}
3292
3293fn default_gws_rate_limit() -> u32 {
3294    60
3295}
3296
3297fn default_gws_timeout_secs() -> u64 {
3298    30
3299}
3300
3301impl Default for GoogleWorkspaceConfig {
3302    fn default() -> Self {
3303        Self {
3304            enabled: false,
3305            allowed_services: Vec::new(),
3306            allowed_operations: Vec::new(),
3307            credentials_path: None,
3308            default_account: None,
3309            rate_limit_per_minute: default_gws_rate_limit(),
3310            timeout_secs: default_gws_timeout_secs(),
3311            audit_log: false,
3312        }
3313    }
3314}
3315
3316// ── LinkedIn ────────────────────────────────────────────────────
3317
3318/// LinkedIn integration configuration (`[linkedin]` section).
3319///
3320/// When enabled, the `linkedin` tool is registered in the agent tool surface.
3321/// Requires `LINKEDIN_*` credentials in the workspace `.env` file.
3322#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3323pub struct LinkedInConfig {
3324    /// Enable the LinkedIn tool.
3325    #[serde(default)]
3326    pub enabled: bool,
3327
3328    /// LinkedIn REST API version header (YYYYMM format).
3329    #[serde(default = "default_linkedin_api_version")]
3330    pub api_version: String,
3331
3332    /// Content strategy for automated posting.
3333    #[serde(default)]
3334    pub content: LinkedInContentConfig,
3335
3336    /// Image generation for posts (`[linkedin.image]`).
3337    #[serde(default)]
3338    pub image: LinkedInImageConfig,
3339}
3340
3341impl Default for LinkedInConfig {
3342    fn default() -> Self {
3343        Self {
3344            enabled: false,
3345            api_version: default_linkedin_api_version(),
3346            content: LinkedInContentConfig::default(),
3347            image: LinkedInImageConfig::default(),
3348        }
3349    }
3350}
3351
3352fn default_linkedin_api_version() -> String {
3353    "202602".to_string()
3354}
3355
3356/// Plugin system configuration.
3357#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3358pub struct PluginsConfig {
3359    /// Enable the plugin system (default: false)
3360    #[serde(default)]
3361    pub enabled: bool,
3362    /// Directory where plugins are stored
3363    #[serde(default = "default_plugins_dir")]
3364    pub plugins_dir: String,
3365    /// Auto-discover and load plugins on startup
3366    #[serde(default)]
3367    pub auto_discover: bool,
3368    /// Maximum number of plugins that can be loaded
3369    #[serde(default = "default_max_plugins")]
3370    pub max_plugins: usize,
3371    /// Plugin signature verification security settings
3372    #[serde(default)]
3373    pub security: PluginSecurityConfig,
3374}
3375
3376/// Plugin signature verification configuration (`[plugins.security]`).
3377///
3378/// Controls Ed25519 signature verification for plugin manifests.
3379/// In `strict` mode, only plugins signed by a trusted publisher key are loaded.
3380/// In `permissive` mode, unsigned or untrusted plugins produce warnings but are
3381/// still loaded. In `disabled` mode (the default), no signature checking occurs.
3382#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3383pub struct PluginSecurityConfig {
3384    /// Signature enforcement mode: "disabled", "permissive", or "strict".
3385    #[serde(default = "default_signature_mode")]
3386    pub signature_mode: String,
3387    /// Hex-encoded Ed25519 public keys of trusted plugin publishers.
3388    #[serde(default)]
3389    pub trusted_publisher_keys: Vec<String>,
3390}
3391
3392fn default_signature_mode() -> String {
3393    "disabled".to_string()
3394}
3395
3396impl Default for PluginSecurityConfig {
3397    fn default() -> Self {
3398        Self {
3399            signature_mode: default_signature_mode(),
3400            trusted_publisher_keys: Vec::new(),
3401        }
3402    }
3403}
3404
3405fn default_plugins_dir() -> String {
3406    "~/.construct/plugins".to_string()
3407}
3408
3409fn default_max_plugins() -> usize {
3410    50
3411}
3412
3413impl Default for PluginsConfig {
3414    fn default() -> Self {
3415        Self {
3416            enabled: false,
3417            plugins_dir: default_plugins_dir(),
3418            auto_discover: false,
3419            max_plugins: default_max_plugins(),
3420            security: PluginSecurityConfig::default(),
3421        }
3422    }
3423}
3424
3425/// Content strategy configuration for LinkedIn auto-posting (`[linkedin.content]`).
3426///
3427/// The agent reads this via the `linkedin get_content_strategy` action to know
3428/// what feeds to check, which repos to highlight, and how to write posts.
3429#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
3430pub struct LinkedInContentConfig {
3431    /// RSS feed URLs to monitor for topic inspiration (titles only).
3432    #[serde(default)]
3433    pub rss_feeds: Vec<String>,
3434
3435    /// GitHub usernames whose public activity to reference.
3436    #[serde(default)]
3437    pub github_users: Vec<String>,
3438
3439    /// GitHub repositories to highlight (format: `owner/repo`).
3440    #[serde(default)]
3441    pub github_repos: Vec<String>,
3442
3443    /// Topics of expertise and interest for post themes.
3444    #[serde(default)]
3445    pub topics: Vec<String>,
3446
3447    /// Professional persona description (name, role, expertise).
3448    #[serde(default)]
3449    pub persona: String,
3450
3451    /// Freeform posting instructions for the AI agent.
3452    #[serde(default)]
3453    pub instructions: String,
3454}
3455
3456/// Image generation configuration for LinkedIn posts (`[linkedin.image]`).
3457#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3458pub struct LinkedInImageConfig {
3459    /// Enable image generation for posts.
3460    #[serde(default)]
3461    pub enabled: bool,
3462
3463    /// Provider priority order. Tried in sequence; first success wins.
3464    #[serde(default = "default_image_providers")]
3465    pub providers: Vec<String>,
3466
3467    /// Generate a branded SVG text card when all AI providers fail.
3468    #[serde(default = "default_true")]
3469    pub fallback_card: bool,
3470
3471    /// Accent color for the fallback card (CSS hex).
3472    #[serde(default = "default_card_accent_color")]
3473    pub card_accent_color: String,
3474
3475    /// Temp directory for generated images, relative to workspace.
3476    #[serde(default = "default_image_temp_dir")]
3477    pub temp_dir: String,
3478
3479    /// Stability AI provider settings.
3480    #[serde(default)]
3481    pub stability: ImageProviderStabilityConfig,
3482
3483    /// Google Imagen (Vertex AI) provider settings.
3484    #[serde(default)]
3485    pub imagen: ImageProviderImagenConfig,
3486
3487    /// OpenAI DALL-E provider settings.
3488    #[serde(default)]
3489    pub dalle: ImageProviderDalleConfig,
3490
3491    /// Flux (fal.ai) provider settings.
3492    #[serde(default)]
3493    pub flux: ImageProviderFluxConfig,
3494}
3495
3496fn default_image_providers() -> Vec<String> {
3497    vec![
3498        "stability".into(),
3499        "imagen".into(),
3500        "dalle".into(),
3501        "flux".into(),
3502    ]
3503}
3504
3505fn default_card_accent_color() -> String {
3506    "#0A66C2".into()
3507}
3508
3509fn default_image_temp_dir() -> String {
3510    "linkedin/images".into()
3511}
3512
3513impl Default for LinkedInImageConfig {
3514    fn default() -> Self {
3515        Self {
3516            enabled: false,
3517            providers: default_image_providers(),
3518            fallback_card: true,
3519            card_accent_color: default_card_accent_color(),
3520            temp_dir: default_image_temp_dir(),
3521            stability: ImageProviderStabilityConfig::default(),
3522            imagen: ImageProviderImagenConfig::default(),
3523            dalle: ImageProviderDalleConfig::default(),
3524            flux: ImageProviderFluxConfig::default(),
3525        }
3526    }
3527}
3528
3529/// Stability AI image generation settings (`[linkedin.image.stability]`).
3530#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3531pub struct ImageProviderStabilityConfig {
3532    /// Environment variable name holding the API key.
3533    #[serde(default = "default_stability_api_key_env")]
3534    pub api_key_env: String,
3535    /// Stability model identifier.
3536    #[serde(default = "default_stability_model")]
3537    pub model: String,
3538}
3539
3540fn default_stability_api_key_env() -> String {
3541    "STABILITY_API_KEY".into()
3542}
3543fn default_stability_model() -> String {
3544    "stable-diffusion-xl-1024-v1-0".into()
3545}
3546
3547impl Default for ImageProviderStabilityConfig {
3548    fn default() -> Self {
3549        Self {
3550            api_key_env: default_stability_api_key_env(),
3551            model: default_stability_model(),
3552        }
3553    }
3554}
3555
3556/// Google Imagen (Vertex AI) settings (`[linkedin.image.imagen]`).
3557#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3558pub struct ImageProviderImagenConfig {
3559    /// Environment variable name holding the API key.
3560    #[serde(default = "default_imagen_api_key_env")]
3561    pub api_key_env: String,
3562    /// Environment variable for the Google Cloud project ID.
3563    #[serde(default = "default_imagen_project_id_env")]
3564    pub project_id_env: String,
3565    /// Vertex AI region.
3566    #[serde(default = "default_imagen_region")]
3567    pub region: String,
3568}
3569
3570fn default_imagen_api_key_env() -> String {
3571    "GOOGLE_VERTEX_API_KEY".into()
3572}
3573fn default_imagen_project_id_env() -> String {
3574    "GOOGLE_CLOUD_PROJECT".into()
3575}
3576fn default_imagen_region() -> String {
3577    "us-central1".into()
3578}
3579
3580impl Default for ImageProviderImagenConfig {
3581    fn default() -> Self {
3582        Self {
3583            api_key_env: default_imagen_api_key_env(),
3584            project_id_env: default_imagen_project_id_env(),
3585            region: default_imagen_region(),
3586        }
3587    }
3588}
3589
3590/// OpenAI DALL-E settings (`[linkedin.image.dalle]`).
3591#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3592pub struct ImageProviderDalleConfig {
3593    /// Environment variable name holding the OpenAI API key.
3594    #[serde(default = "default_dalle_api_key_env")]
3595    pub api_key_env: String,
3596    /// DALL-E model identifier.
3597    #[serde(default = "default_dalle_model")]
3598    pub model: String,
3599    /// Image dimensions.
3600    #[serde(default = "default_dalle_size")]
3601    pub size: String,
3602}
3603
3604fn default_dalle_api_key_env() -> String {
3605    "OPENAI_API_KEY".into()
3606}
3607fn default_dalle_model() -> String {
3608    "dall-e-3".into()
3609}
3610fn default_dalle_size() -> String {
3611    "1024x1024".into()
3612}
3613
3614impl Default for ImageProviderDalleConfig {
3615    fn default() -> Self {
3616        Self {
3617            api_key_env: default_dalle_api_key_env(),
3618            model: default_dalle_model(),
3619            size: default_dalle_size(),
3620        }
3621    }
3622}
3623
3624/// Flux (fal.ai) image generation settings (`[linkedin.image.flux]`).
3625#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3626pub struct ImageProviderFluxConfig {
3627    /// Environment variable name holding the fal.ai API key.
3628    #[serde(default = "default_flux_api_key_env")]
3629    pub api_key_env: String,
3630    /// Flux model identifier.
3631    #[serde(default = "default_flux_model")]
3632    pub model: String,
3633}
3634
3635fn default_flux_api_key_env() -> String {
3636    "FAL_API_KEY".into()
3637}
3638fn default_flux_model() -> String {
3639    "fal-ai/flux/schnell".into()
3640}
3641
3642impl Default for ImageProviderFluxConfig {
3643    fn default() -> Self {
3644        Self {
3645            api_key_env: default_flux_api_key_env(),
3646            model: default_flux_model(),
3647        }
3648    }
3649}
3650
3651// ── Standalone Image Generation ─────────────────────────────────
3652
3653/// Standalone image generation tool configuration (`[image_gen]`).
3654///
3655/// When enabled, registers an `image_gen` tool that generates images via
3656/// fal.ai's synchronous API (Flux / Nano Banana models) and saves them
3657/// to the workspace `images/` directory.
3658#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3659pub struct ImageGenConfig {
3660    /// Enable the standalone image generation tool. Default: false.
3661    #[serde(default)]
3662    pub enabled: bool,
3663
3664    /// Default fal.ai model identifier.
3665    #[serde(default = "default_image_gen_model")]
3666    pub default_model: String,
3667
3668    /// Environment variable name holding the fal.ai API key.
3669    #[serde(default = "default_image_gen_api_key_env")]
3670    pub api_key_env: String,
3671}
3672
3673fn default_image_gen_model() -> String {
3674    "fal-ai/flux/schnell".into()
3675}
3676
3677fn default_image_gen_api_key_env() -> String {
3678    "FAL_API_KEY".into()
3679}
3680
3681impl Default for ImageGenConfig {
3682    fn default() -> Self {
3683        Self {
3684            enabled: false,
3685            default_model: default_image_gen_model(),
3686            api_key_env: default_image_gen_api_key_env(),
3687        }
3688    }
3689}
3690
3691// ── Claude Code ─────────────────────────────────────────────────
3692
3693/// Claude Code CLI tool configuration (`[claude_code]` section).
3694///
3695/// Delegates coding tasks to the `claude -p` CLI. Authentication uses the
3696/// binary's own OAuth session (Max subscription) by default — no API key
3697/// needed unless `env_passthrough` includes `ANTHROPIC_API_KEY`.
3698#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3699pub struct ClaudeCodeConfig {
3700    /// Enable the `claude_code` tool
3701    #[serde(default)]
3702    pub enabled: bool,
3703    /// Maximum execution time in seconds (coding tasks can be long)
3704    #[serde(default = "default_claude_code_timeout_secs")]
3705    pub timeout_secs: u64,
3706    /// Claude Code tools the subprocess is allowed to use
3707    #[serde(default = "default_claude_code_allowed_tools")]
3708    pub allowed_tools: Vec<String>,
3709    /// Optional system prompt appended to Claude Code invocations
3710    #[serde(default)]
3711    pub system_prompt: Option<String>,
3712    /// Maximum output size in bytes (2MB default)
3713    #[serde(default = "default_claude_code_max_output_bytes")]
3714    pub max_output_bytes: usize,
3715    /// Extra env vars passed to the claude subprocess (e.g. ANTHROPIC_API_KEY for API-key billing)
3716    #[serde(default)]
3717    pub env_passthrough: Vec<String>,
3718}
3719
3720fn default_claude_code_timeout_secs() -> u64 {
3721    600
3722}
3723
3724fn default_claude_code_allowed_tools() -> Vec<String> {
3725    vec!["Read".into(), "Edit".into(), "Bash".into(), "Write".into()]
3726}
3727
3728fn default_claude_code_max_output_bytes() -> usize {
3729    2_097_152
3730}
3731
3732impl Default for ClaudeCodeConfig {
3733    fn default() -> Self {
3734        Self {
3735            enabled: false,
3736            timeout_secs: default_claude_code_timeout_secs(),
3737            allowed_tools: default_claude_code_allowed_tools(),
3738            system_prompt: None,
3739            max_output_bytes: default_claude_code_max_output_bytes(),
3740            env_passthrough: Vec::new(),
3741        }
3742    }
3743}
3744
3745// ── Claude Code Runner ──────────────────────────────────────────
3746
3747/// Claude Code task runner configuration (`[claude_code_runner]` section).
3748///
3749/// Spawns Claude Code in a tmux session with HTTP hooks that POST tool
3750/// execution events back to Construct's gateway, updating a Slack message
3751/// in-place with progress plus an SSH handoff link.
3752#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3753pub struct ClaudeCodeRunnerConfig {
3754    /// Enable the `claude_code_runner` tool
3755    #[serde(default)]
3756    pub enabled: bool,
3757    /// SSH host for session handoff links (e.g. "myhost.example.com")
3758    #[serde(default)]
3759    pub ssh_host: Option<String>,
3760    /// Prefix for tmux session names (default: "zc-claude-")
3761    #[serde(default = "default_claude_code_runner_tmux_prefix")]
3762    pub tmux_prefix: String,
3763    /// Session time-to-live in seconds before auto-cleanup (default: 3600)
3764    #[serde(default = "default_claude_code_runner_session_ttl")]
3765    pub session_ttl: u64,
3766}
3767
3768fn default_claude_code_runner_tmux_prefix() -> String {
3769    "zc-claude-".into()
3770}
3771
3772fn default_claude_code_runner_session_ttl() -> u64 {
3773    3600
3774}
3775
3776impl Default for ClaudeCodeRunnerConfig {
3777    fn default() -> Self {
3778        Self {
3779            enabled: false,
3780            ssh_host: None,
3781            tmux_prefix: default_claude_code_runner_tmux_prefix(),
3782            session_ttl: default_claude_code_runner_session_ttl(),
3783        }
3784    }
3785}
3786
3787// ── Codex CLI ───────────────────────────────────────────────────
3788
3789/// Codex CLI tool configuration (`[codex_cli]` section).
3790///
3791/// Delegates coding tasks to the `codex -q` CLI. Authentication uses the
3792/// binary's own session by default — no API key needed unless
3793/// `env_passthrough` includes `OPENAI_API_KEY`.
3794#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3795pub struct CodexCliConfig {
3796    /// Enable the `codex_cli` tool
3797    #[serde(default)]
3798    pub enabled: bool,
3799    /// Maximum execution time in seconds (coding tasks can be long)
3800    #[serde(default = "default_codex_cli_timeout_secs")]
3801    pub timeout_secs: u64,
3802    /// Maximum output size in bytes (2MB default)
3803    #[serde(default = "default_codex_cli_max_output_bytes")]
3804    pub max_output_bytes: usize,
3805    /// Extra env vars passed to the codex subprocess (e.g. OPENAI_API_KEY)
3806    #[serde(default)]
3807    pub env_passthrough: Vec<String>,
3808}
3809
3810fn default_codex_cli_timeout_secs() -> u64 {
3811    600
3812}
3813
3814fn default_codex_cli_max_output_bytes() -> usize {
3815    2_097_152
3816}
3817
3818impl Default for CodexCliConfig {
3819    fn default() -> Self {
3820        Self {
3821            enabled: false,
3822            timeout_secs: default_codex_cli_timeout_secs(),
3823            max_output_bytes: default_codex_cli_max_output_bytes(),
3824            env_passthrough: Vec::new(),
3825        }
3826    }
3827}
3828
3829// ── Gemini CLI ──────────────────────────────────────────────────
3830
3831/// Gemini CLI tool configuration (`[gemini_cli]` section).
3832///
3833/// Delegates coding tasks to the `gemini -p` CLI. Authentication uses the
3834/// binary's own session by default — no API key needed unless
3835/// `env_passthrough` includes `GOOGLE_API_KEY`.
3836#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3837pub struct GeminiCliConfig {
3838    /// Enable the `gemini_cli` tool
3839    #[serde(default)]
3840    pub enabled: bool,
3841    /// Maximum execution time in seconds (coding tasks can be long)
3842    #[serde(default = "default_gemini_cli_timeout_secs")]
3843    pub timeout_secs: u64,
3844    /// Maximum output size in bytes (2MB default)
3845    #[serde(default = "default_gemini_cli_max_output_bytes")]
3846    pub max_output_bytes: usize,
3847    /// Extra env vars passed to the gemini subprocess (e.g. GOOGLE_API_KEY)
3848    #[serde(default)]
3849    pub env_passthrough: Vec<String>,
3850}
3851
3852fn default_gemini_cli_timeout_secs() -> u64 {
3853    600
3854}
3855
3856fn default_gemini_cli_max_output_bytes() -> usize {
3857    2_097_152
3858}
3859
3860impl Default for GeminiCliConfig {
3861    fn default() -> Self {
3862        Self {
3863            enabled: false,
3864            timeout_secs: default_gemini_cli_timeout_secs(),
3865            max_output_bytes: default_gemini_cli_max_output_bytes(),
3866            env_passthrough: Vec::new(),
3867        }
3868    }
3869}
3870
3871// ── OpenCode CLI ───────────────────────────────────────────────
3872
3873/// OpenCode CLI tool configuration (`[opencode_cli]` section).
3874///
3875/// Delegates coding tasks to the `opencode run` CLI. Authentication uses the
3876/// binary's own session by default — no API key needed unless
3877/// `env_passthrough` includes provider-specific keys.
3878#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3879pub struct OpenCodeCliConfig {
3880    /// Enable the `opencode_cli` tool
3881    #[serde(default)]
3882    pub enabled: bool,
3883    /// Maximum execution time in seconds (coding tasks can be long)
3884    #[serde(default = "default_opencode_cli_timeout_secs")]
3885    pub timeout_secs: u64,
3886    /// Maximum output size in bytes (2MB default)
3887    #[serde(default = "default_opencode_cli_max_output_bytes")]
3888    pub max_output_bytes: usize,
3889    /// Extra env vars passed to the opencode subprocess
3890    #[serde(default)]
3891    pub env_passthrough: Vec<String>,
3892}
3893
3894fn default_opencode_cli_timeout_secs() -> u64 {
3895    600
3896}
3897
3898fn default_opencode_cli_max_output_bytes() -> usize {
3899    2_097_152
3900}
3901
3902impl Default for OpenCodeCliConfig {
3903    fn default() -> Self {
3904        Self {
3905            enabled: false,
3906            timeout_secs: default_opencode_cli_timeout_secs(),
3907            max_output_bytes: default_opencode_cli_max_output_bytes(),
3908            env_passthrough: Vec::new(),
3909        }
3910    }
3911}
3912
3913// ── Proxy ───────────────────────────────────────────────────────
3914
3915/// Proxy application scope — determines which outbound traffic uses the proxy.
3916#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, JsonSchema)]
3917#[serde(rename_all = "snake_case")]
3918pub enum ProxyScope {
3919    /// Use system environment proxy variables only.
3920    Environment,
3921    /// Apply proxy to all Construct-managed HTTP traffic (default).
3922    #[default]
3923    Construct,
3924    /// Apply proxy only to explicitly listed service selectors.
3925    Services,
3926}
3927
3928/// Proxy configuration for outbound HTTP/HTTPS/SOCKS5 traffic (`[proxy]` section).
3929#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
3930pub struct ProxyConfig {
3931    /// Enable proxy support for selected scope.
3932    #[serde(default)]
3933    pub enabled: bool,
3934    /// Proxy URL for HTTP requests (supports http, https, socks5, socks5h).
3935    #[serde(default)]
3936    pub http_proxy: Option<String>,
3937    /// Proxy URL for HTTPS requests (supports http, https, socks5, socks5h).
3938    #[serde(default)]
3939    pub https_proxy: Option<String>,
3940    /// Fallback proxy URL for all schemes.
3941    #[serde(default)]
3942    pub all_proxy: Option<String>,
3943    /// No-proxy bypass list. Same format as NO_PROXY.
3944    #[serde(default)]
3945    pub no_proxy: Vec<String>,
3946    /// Proxy application scope.
3947    #[serde(default)]
3948    pub scope: ProxyScope,
3949    /// Service selectors used when scope = "services".
3950    #[serde(default)]
3951    pub services: Vec<String>,
3952}
3953
3954impl Default for ProxyConfig {
3955    fn default() -> Self {
3956        Self {
3957            enabled: false,
3958            http_proxy: None,
3959            https_proxy: None,
3960            all_proxy: None,
3961            no_proxy: Vec::new(),
3962            scope: ProxyScope::Construct,
3963            services: Vec::new(),
3964        }
3965    }
3966}
3967
3968impl ProxyConfig {
3969    pub fn supported_service_keys() -> &'static [&'static str] {
3970        SUPPORTED_PROXY_SERVICE_KEYS
3971    }
3972
3973    pub fn supported_service_selectors() -> &'static [&'static str] {
3974        SUPPORTED_PROXY_SERVICE_SELECTORS
3975    }
3976
3977    pub fn has_any_proxy_url(&self) -> bool {
3978        normalize_proxy_url_option(self.http_proxy.as_deref()).is_some()
3979            || normalize_proxy_url_option(self.https_proxy.as_deref()).is_some()
3980            || normalize_proxy_url_option(self.all_proxy.as_deref()).is_some()
3981    }
3982
3983    pub fn normalized_services(&self) -> Vec<String> {
3984        normalize_service_list(self.services.clone())
3985    }
3986
3987    pub fn normalized_no_proxy(&self) -> Vec<String> {
3988        normalize_no_proxy_list(self.no_proxy.clone())
3989    }
3990
3991    pub fn validate(&self) -> Result<()> {
3992        for (field, value) in [
3993            ("http_proxy", self.http_proxy.as_deref()),
3994            ("https_proxy", self.https_proxy.as_deref()),
3995            ("all_proxy", self.all_proxy.as_deref()),
3996        ] {
3997            if let Some(url) = normalize_proxy_url_option(value) {
3998                validate_proxy_url(field, &url)?;
3999            }
4000        }
4001
4002        for selector in self.normalized_services() {
4003            if !is_supported_proxy_service_selector(&selector) {
4004                anyhow::bail!(
4005                    "Unsupported proxy service selector '{selector}'. Use tool `proxy_config` action `list_services` for valid values"
4006                );
4007            }
4008        }
4009
4010        if self.enabled && !self.has_any_proxy_url() {
4011            anyhow::bail!(
4012                "Proxy is enabled but no proxy URL is configured. Set at least one of http_proxy, https_proxy, or all_proxy"
4013            );
4014        }
4015
4016        if self.enabled
4017            && self.scope == ProxyScope::Services
4018            && self.normalized_services().is_empty()
4019        {
4020            anyhow::bail!(
4021                "proxy.scope='services' requires a non-empty proxy.services list when proxy is enabled"
4022            );
4023        }
4024
4025        Ok(())
4026    }
4027
4028    pub fn should_apply_to_service(&self, service_key: &str) -> bool {
4029        if !self.enabled {
4030            return false;
4031        }
4032
4033        match self.scope {
4034            ProxyScope::Environment => false,
4035            ProxyScope::Construct => true,
4036            ProxyScope::Services => {
4037                let service_key = service_key.trim().to_ascii_lowercase();
4038                if service_key.is_empty() {
4039                    return false;
4040                }
4041
4042                self.normalized_services()
4043                    .iter()
4044                    .any(|selector| service_selector_matches(selector, &service_key))
4045            }
4046        }
4047    }
4048
4049    pub fn apply_to_reqwest_builder(
4050        &self,
4051        mut builder: reqwest::ClientBuilder,
4052        service_key: &str,
4053    ) -> reqwest::ClientBuilder {
4054        if !self.should_apply_to_service(service_key) {
4055            return builder;
4056        }
4057
4058        let no_proxy = self.no_proxy_value();
4059
4060        if let Some(url) = normalize_proxy_url_option(self.all_proxy.as_deref()) {
4061            match reqwest::Proxy::all(&url) {
4062                Ok(proxy) => {
4063                    builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
4064                }
4065                Err(error) => {
4066                    tracing::warn!(
4067                        proxy_url = %url,
4068                        service_key,
4069                        "Ignoring invalid all_proxy URL: {error}"
4070                    );
4071                }
4072            }
4073        }
4074
4075        if let Some(url) = normalize_proxy_url_option(self.http_proxy.as_deref()) {
4076            match reqwest::Proxy::http(&url) {
4077                Ok(proxy) => {
4078                    builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
4079                }
4080                Err(error) => {
4081                    tracing::warn!(
4082                        proxy_url = %url,
4083                        service_key,
4084                        "Ignoring invalid http_proxy URL: {error}"
4085                    );
4086                }
4087            }
4088        }
4089
4090        if let Some(url) = normalize_proxy_url_option(self.https_proxy.as_deref()) {
4091            match reqwest::Proxy::https(&url) {
4092                Ok(proxy) => {
4093                    builder = builder.proxy(apply_no_proxy(proxy, no_proxy));
4094                }
4095                Err(error) => {
4096                    tracing::warn!(
4097                        proxy_url = %url,
4098                        service_key,
4099                        "Ignoring invalid https_proxy URL: {error}"
4100                    );
4101                }
4102            }
4103        }
4104
4105        builder
4106    }
4107
4108    pub fn apply_to_process_env(&self) {
4109        set_proxy_env_pair("HTTP_PROXY", self.http_proxy.as_deref());
4110        set_proxy_env_pair("HTTPS_PROXY", self.https_proxy.as_deref());
4111        set_proxy_env_pair("ALL_PROXY", self.all_proxy.as_deref());
4112
4113        let no_proxy_joined = {
4114            let list = self.normalized_no_proxy();
4115            (!list.is_empty()).then(|| list.join(","))
4116        };
4117        set_proxy_env_pair("NO_PROXY", no_proxy_joined.as_deref());
4118    }
4119
4120    pub fn clear_process_env() {
4121        clear_proxy_env_pair("HTTP_PROXY");
4122        clear_proxy_env_pair("HTTPS_PROXY");
4123        clear_proxy_env_pair("ALL_PROXY");
4124        clear_proxy_env_pair("NO_PROXY");
4125    }
4126
4127    fn no_proxy_value(&self) -> Option<reqwest::NoProxy> {
4128        let joined = {
4129            let list = self.normalized_no_proxy();
4130            (!list.is_empty()).then(|| list.join(","))
4131        };
4132        joined.as_deref().and_then(reqwest::NoProxy::from_string)
4133    }
4134}
4135
4136fn apply_no_proxy(proxy: reqwest::Proxy, no_proxy: Option<reqwest::NoProxy>) -> reqwest::Proxy {
4137    proxy.no_proxy(no_proxy)
4138}
4139
4140fn normalize_proxy_url_option(raw: Option<&str>) -> Option<String> {
4141    let value = raw?.trim();
4142    (!value.is_empty()).then(|| value.to_string())
4143}
4144
4145fn normalize_no_proxy_list(values: Vec<String>) -> Vec<String> {
4146    normalize_comma_values(values)
4147}
4148
4149fn normalize_service_list(values: Vec<String>) -> Vec<String> {
4150    let mut normalized = normalize_comma_values(values)
4151        .into_iter()
4152        .map(|value| value.to_ascii_lowercase())
4153        .collect::<Vec<_>>();
4154    normalized.sort_unstable();
4155    normalized.dedup();
4156    normalized
4157}
4158
4159fn normalize_comma_values(values: Vec<String>) -> Vec<String> {
4160    let mut output = Vec::new();
4161    for value in values {
4162        for part in value.split(',') {
4163            let normalized = part.trim();
4164            if normalized.is_empty() {
4165                continue;
4166            }
4167            output.push(normalized.to_string());
4168        }
4169    }
4170    output.sort_unstable();
4171    output.dedup();
4172    output
4173}
4174
4175fn is_supported_proxy_service_selector(selector: &str) -> bool {
4176    if SUPPORTED_PROXY_SERVICE_KEYS
4177        .iter()
4178        .any(|known| known.eq_ignore_ascii_case(selector))
4179    {
4180        return true;
4181    }
4182
4183    SUPPORTED_PROXY_SERVICE_SELECTORS
4184        .iter()
4185        .any(|known| known.eq_ignore_ascii_case(selector))
4186}
4187
4188fn service_selector_matches(selector: &str, service_key: &str) -> bool {
4189    if selector == service_key {
4190        return true;
4191    }
4192
4193    if let Some(prefix) = selector.strip_suffix(".*") {
4194        return service_key.starts_with(prefix)
4195            && service_key
4196                .strip_prefix(prefix)
4197                .is_some_and(|suffix| suffix.starts_with('.'));
4198    }
4199
4200    false
4201}
4202
4203const MCP_MAX_TOOL_TIMEOUT_SECS: u64 = 600;
4204
4205fn validate_mcp_config(config: &McpConfig) -> Result<()> {
4206    let mut seen_names = std::collections::HashSet::new();
4207    for (i, server) in config.servers.iter().enumerate() {
4208        let name = server.name.trim();
4209        if name.is_empty() {
4210            anyhow::bail!("mcp.servers[{i}].name must not be empty");
4211        }
4212        if !seen_names.insert(name.to_ascii_lowercase()) {
4213            anyhow::bail!("mcp.servers contains duplicate name: {name}");
4214        }
4215
4216        if let Some(timeout) = server.tool_timeout_secs {
4217            if timeout == 0 {
4218                anyhow::bail!("mcp.servers[{i}].tool_timeout_secs must be greater than 0");
4219            }
4220            if timeout > MCP_MAX_TOOL_TIMEOUT_SECS {
4221                anyhow::bail!(
4222                    "mcp.servers[{i}].tool_timeout_secs exceeds max {MCP_MAX_TOOL_TIMEOUT_SECS}"
4223                );
4224            }
4225        }
4226
4227        match server.transport {
4228            McpTransport::Stdio => {
4229                if server.command.trim().is_empty() {
4230                    anyhow::bail!(
4231                        "mcp.servers[{i}] with transport=stdio requires non-empty command"
4232                    );
4233                }
4234            }
4235            McpTransport::Http | McpTransport::Sse => {
4236                let url = server
4237                    .url
4238                    .as_deref()
4239                    .map(str::trim)
4240                    .filter(|value| !value.is_empty())
4241                    .ok_or_else(|| {
4242                        anyhow::anyhow!(
4243                            "mcp.servers[{i}] with transport={} requires url",
4244                            match server.transport {
4245                                McpTransport::Http => "http",
4246                                McpTransport::Sse => "sse",
4247                                McpTransport::Stdio => "stdio",
4248                            }
4249                        )
4250                    })?;
4251                let parsed = reqwest::Url::parse(url)
4252                    .with_context(|| format!("mcp.servers[{i}].url is not a valid URL"))?;
4253                if !matches!(parsed.scheme(), "http" | "https") {
4254                    anyhow::bail!("mcp.servers[{i}].url must use http/https");
4255                }
4256            }
4257        }
4258    }
4259    Ok(())
4260}
4261
4262fn validate_proxy_url(field: &str, url: &str) -> Result<()> {
4263    let parsed = reqwest::Url::parse(url)
4264        .with_context(|| format!("Invalid {field} URL: '{url}' is not a valid URL"))?;
4265
4266    match parsed.scheme() {
4267        "http" | "https" | "socks5" | "socks5h" | "socks" => {}
4268        scheme => {
4269            anyhow::bail!(
4270                "Invalid {field} URL scheme '{scheme}'. Allowed: http, https, socks5, socks5h, socks"
4271            );
4272        }
4273    }
4274
4275    if parsed.host_str().is_none() {
4276        anyhow::bail!("Invalid {field} URL: host is required");
4277    }
4278
4279    Ok(())
4280}
4281
4282fn set_proxy_env_pair(key: &str, value: Option<&str>) {
4283    let lowercase_key = key.to_ascii_lowercase();
4284    if let Some(value) = value.and_then(|candidate| normalize_proxy_url_option(Some(candidate))) {
4285        // SAFETY: called during single-threaded config init before async runtime starts.
4286        unsafe {
4287            std::env::set_var(key, &value);
4288            std::env::set_var(lowercase_key, value);
4289        }
4290    } else {
4291        // SAFETY: called during single-threaded config init before async runtime starts.
4292        unsafe {
4293            std::env::remove_var(key);
4294            std::env::remove_var(lowercase_key);
4295        }
4296    }
4297}
4298
4299fn clear_proxy_env_pair(key: &str) {
4300    // SAFETY: called during single-threaded config init before async runtime starts.
4301    unsafe {
4302        std::env::remove_var(key);
4303        std::env::remove_var(key.to_ascii_lowercase());
4304    }
4305}
4306
4307fn runtime_proxy_state() -> &'static RwLock<ProxyConfig> {
4308    RUNTIME_PROXY_CONFIG.get_or_init(|| RwLock::new(ProxyConfig::default()))
4309}
4310
4311fn runtime_proxy_client_cache() -> &'static RwLock<HashMap<String, reqwest::Client>> {
4312    RUNTIME_PROXY_CLIENT_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
4313}
4314
4315fn clear_runtime_proxy_client_cache() {
4316    match runtime_proxy_client_cache().write() {
4317        Ok(mut guard) => {
4318            guard.clear();
4319        }
4320        Err(poisoned) => {
4321            poisoned.into_inner().clear();
4322        }
4323    }
4324}
4325
4326fn runtime_proxy_cache_key(
4327    service_key: &str,
4328    timeout_secs: Option<u64>,
4329    connect_timeout_secs: Option<u64>,
4330) -> String {
4331    format!(
4332        "{}|timeout={}|connect_timeout={}",
4333        service_key.trim().to_ascii_lowercase(),
4334        timeout_secs
4335            .map(|value| value.to_string())
4336            .unwrap_or_else(|| "none".to_string()),
4337        connect_timeout_secs
4338            .map(|value| value.to_string())
4339            .unwrap_or_else(|| "none".to_string())
4340    )
4341}
4342
4343fn runtime_proxy_cached_client(cache_key: &str) -> Option<reqwest::Client> {
4344    match runtime_proxy_client_cache().read() {
4345        Ok(guard) => guard.get(cache_key).cloned(),
4346        Err(poisoned) => poisoned.into_inner().get(cache_key).cloned(),
4347    }
4348}
4349
4350fn set_runtime_proxy_cached_client(cache_key: String, client: reqwest::Client) {
4351    match runtime_proxy_client_cache().write() {
4352        Ok(mut guard) => {
4353            guard.insert(cache_key, client);
4354        }
4355        Err(poisoned) => {
4356            poisoned.into_inner().insert(cache_key, client);
4357        }
4358    }
4359}
4360
4361pub fn set_runtime_proxy_config(config: ProxyConfig) {
4362    match runtime_proxy_state().write() {
4363        Ok(mut guard) => {
4364            *guard = config;
4365        }
4366        Err(poisoned) => {
4367            *poisoned.into_inner() = config;
4368        }
4369    }
4370
4371    clear_runtime_proxy_client_cache();
4372}
4373
4374pub fn runtime_proxy_config() -> ProxyConfig {
4375    match runtime_proxy_state().read() {
4376        Ok(guard) => guard.clone(),
4377        Err(poisoned) => poisoned.into_inner().clone(),
4378    }
4379}
4380
4381pub fn apply_runtime_proxy_to_builder(
4382    builder: reqwest::ClientBuilder,
4383    service_key: &str,
4384) -> reqwest::ClientBuilder {
4385    runtime_proxy_config().apply_to_reqwest_builder(builder, service_key)
4386}
4387
4388pub fn build_runtime_proxy_client(service_key: &str) -> reqwest::Client {
4389    let cache_key = runtime_proxy_cache_key(service_key, None, None);
4390    if let Some(client) = runtime_proxy_cached_client(&cache_key) {
4391        return client;
4392    }
4393
4394    let builder = apply_runtime_proxy_to_builder(reqwest::Client::builder(), service_key);
4395    let client = builder.build().unwrap_or_else(|error| {
4396        tracing::warn!(service_key, "Failed to build proxied client: {error}");
4397        reqwest::Client::new()
4398    });
4399    set_runtime_proxy_cached_client(cache_key, client.clone());
4400    client
4401}
4402
4403pub fn build_runtime_proxy_client_with_timeouts(
4404    service_key: &str,
4405    timeout_secs: u64,
4406    connect_timeout_secs: u64,
4407) -> reqwest::Client {
4408    let cache_key =
4409        runtime_proxy_cache_key(service_key, Some(timeout_secs), Some(connect_timeout_secs));
4410    if let Some(client) = runtime_proxy_cached_client(&cache_key) {
4411        return client;
4412    }
4413
4414    let builder = reqwest::Client::builder()
4415        .timeout(std::time::Duration::from_secs(timeout_secs))
4416        .connect_timeout(std::time::Duration::from_secs(connect_timeout_secs));
4417    let builder = apply_runtime_proxy_to_builder(builder, service_key);
4418    let client = builder.build().unwrap_or_else(|error| {
4419        tracing::warn!(
4420            service_key,
4421            "Failed to build proxied timeout client: {error}"
4422        );
4423        reqwest::Client::new()
4424    });
4425    set_runtime_proxy_cached_client(cache_key, client.clone());
4426    client
4427}
4428
4429/// Build an HTTP client for a channel, using an explicit per-channel proxy URL
4430/// when configured.  Falls back to the global runtime proxy when `proxy_url` is
4431/// `None` or empty.
4432pub fn build_channel_proxy_client(service_key: &str, proxy_url: Option<&str>) -> reqwest::Client {
4433    match normalize_proxy_url_option(proxy_url) {
4434        Some(url) => build_explicit_proxy_client(service_key, &url, None, None),
4435        None => build_runtime_proxy_client(service_key),
4436    }
4437}
4438
4439/// Build an HTTP client for a channel with custom timeouts, using an explicit
4440/// per-channel proxy URL when configured.  Falls back to the global runtime
4441/// proxy when `proxy_url` is `None` or empty.
4442pub fn build_channel_proxy_client_with_timeouts(
4443    service_key: &str,
4444    proxy_url: Option<&str>,
4445    timeout_secs: u64,
4446    connect_timeout_secs: u64,
4447) -> reqwest::Client {
4448    match normalize_proxy_url_option(proxy_url) {
4449        Some(url) => build_explicit_proxy_client(
4450            service_key,
4451            &url,
4452            Some(timeout_secs),
4453            Some(connect_timeout_secs),
4454        ),
4455        None => build_runtime_proxy_client_with_timeouts(
4456            service_key,
4457            timeout_secs,
4458            connect_timeout_secs,
4459        ),
4460    }
4461}
4462
4463/// Apply an explicit proxy URL to a `reqwest::ClientBuilder`, returning the
4464/// modified builder.  Used by channels that specify a per-channel `proxy_url`.
4465pub fn apply_channel_proxy_to_builder(
4466    builder: reqwest::ClientBuilder,
4467    service_key: &str,
4468    proxy_url: Option<&str>,
4469) -> reqwest::ClientBuilder {
4470    match normalize_proxy_url_option(proxy_url) {
4471        Some(url) => apply_explicit_proxy_to_builder(builder, service_key, &url),
4472        None => apply_runtime_proxy_to_builder(builder, service_key),
4473    }
4474}
4475
4476/// Build a client with a single explicit proxy URL (http+https via `Proxy::all`).
4477fn build_explicit_proxy_client(
4478    service_key: &str,
4479    proxy_url: &str,
4480    timeout_secs: Option<u64>,
4481    connect_timeout_secs: Option<u64>,
4482) -> reqwest::Client {
4483    let cache_key = format!(
4484        "explicit|{}|{}|timeout={}|connect_timeout={}",
4485        service_key.trim().to_ascii_lowercase(),
4486        proxy_url,
4487        timeout_secs
4488            .map(|v| v.to_string())
4489            .unwrap_or_else(|| "none".to_string()),
4490        connect_timeout_secs
4491            .map(|v| v.to_string())
4492            .unwrap_or_else(|| "none".to_string()),
4493    );
4494    if let Some(client) = runtime_proxy_cached_client(&cache_key) {
4495        return client;
4496    }
4497
4498    let mut builder = reqwest::Client::builder();
4499    if let Some(t) = timeout_secs {
4500        builder = builder.timeout(std::time::Duration::from_secs(t));
4501    }
4502    if let Some(ct) = connect_timeout_secs {
4503        builder = builder.connect_timeout(std::time::Duration::from_secs(ct));
4504    }
4505    builder = apply_explicit_proxy_to_builder(builder, service_key, proxy_url);
4506    let client = builder.build().unwrap_or_else(|error| {
4507        tracing::warn!(
4508            service_key,
4509            proxy_url,
4510            "Failed to build channel proxy client: {error}"
4511        );
4512        reqwest::Client::new()
4513    });
4514    set_runtime_proxy_cached_client(cache_key, client.clone());
4515    client
4516}
4517
4518/// Apply a single explicit proxy URL to a builder via `Proxy::all`.
4519fn apply_explicit_proxy_to_builder(
4520    mut builder: reqwest::ClientBuilder,
4521    service_key: &str,
4522    proxy_url: &str,
4523) -> reqwest::ClientBuilder {
4524    match reqwest::Proxy::all(proxy_url) {
4525        Ok(proxy) => {
4526            builder = builder.proxy(proxy);
4527        }
4528        Err(error) => {
4529            tracing::warn!(
4530                proxy_url,
4531                service_key,
4532                "Ignoring invalid channel proxy_url: {error}"
4533            );
4534        }
4535    }
4536    builder
4537}
4538
4539// ── Proxy-aware WebSocket connect ────────────────────────────────
4540//
4541// `tokio_tungstenite::connect_async` does not honour proxy settings.
4542// The helpers below resolve the effective proxy URL for a given service
4543// key and, when a proxy is active, establish a tunnelled TCP connection
4544// (HTTP CONNECT for http/https proxies, SOCKS5 for socks5/socks5h)
4545// before handing the stream to `tokio_tungstenite` for the WebSocket
4546// handshake.
4547
4548/// Combined async IO trait for boxed WebSocket transport streams.
4549trait AsyncReadWrite: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send {}
4550impl<T: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send> AsyncReadWrite for T {}
4551
4552/// A boxed async IO stream used when a WebSocket connection is tunnelled
4553/// through a proxy.  The concrete type varies depending on the proxy
4554/// kind (HTTP CONNECT vs SOCKS5) and the target scheme (ws vs wss).
4555///
4556/// We wrap in a newtype so we can implement `AsyncRead` and `AsyncWrite`
4557/// via delegation, since Rust trait objects cannot combine multiple
4558/// non-auto traits.
4559pub struct BoxedIo(Box<dyn AsyncReadWrite>);
4560
4561impl tokio::io::AsyncRead for BoxedIo {
4562    fn poll_read(
4563        mut self: std::pin::Pin<&mut Self>,
4564        cx: &mut std::task::Context<'_>,
4565        buf: &mut tokio::io::ReadBuf<'_>,
4566    ) -> std::task::Poll<std::io::Result<()>> {
4567        std::pin::Pin::new(&mut *self.0).poll_read(cx, buf)
4568    }
4569}
4570
4571impl tokio::io::AsyncWrite for BoxedIo {
4572    fn poll_write(
4573        mut self: std::pin::Pin<&mut Self>,
4574        cx: &mut std::task::Context<'_>,
4575        buf: &[u8],
4576    ) -> std::task::Poll<std::io::Result<usize>> {
4577        std::pin::Pin::new(&mut *self.0).poll_write(cx, buf)
4578    }
4579
4580    fn poll_flush(
4581        mut self: std::pin::Pin<&mut Self>,
4582        cx: &mut std::task::Context<'_>,
4583    ) -> std::task::Poll<std::io::Result<()>> {
4584        std::pin::Pin::new(&mut *self.0).poll_flush(cx)
4585    }
4586
4587    fn poll_shutdown(
4588        mut self: std::pin::Pin<&mut Self>,
4589        cx: &mut std::task::Context<'_>,
4590    ) -> std::task::Poll<std::io::Result<()>> {
4591        std::pin::Pin::new(&mut *self.0).poll_shutdown(cx)
4592    }
4593}
4594
4595impl Unpin for BoxedIo {}
4596
4597/// Convenience alias for the WebSocket stream returned by the proxy-aware
4598/// connect helpers.
4599pub type ProxiedWsStream = tokio_tungstenite::WebSocketStream<BoxedIo>;
4600
4601/// Resolve the effective proxy URL for a WebSocket connection to the
4602/// given `ws_url`, taking into account the per-channel `proxy_url`
4603/// override, the runtime proxy config, scope and no_proxy list.
4604fn resolve_ws_proxy_url(
4605    service_key: &str,
4606    ws_url: &str,
4607    channel_proxy_url: Option<&str>,
4608) -> Option<String> {
4609    // 1. Explicit per-channel proxy always wins.
4610    if let Some(url) = normalize_proxy_url_option(channel_proxy_url) {
4611        return Some(url);
4612    }
4613
4614    // 2. Consult the runtime proxy config.
4615    let cfg = runtime_proxy_config();
4616    if !cfg.should_apply_to_service(service_key) {
4617        return None;
4618    }
4619
4620    // Check the no_proxy list against the WebSocket target host.
4621    if let Ok(parsed) = reqwest::Url::parse(ws_url) {
4622        if let Some(host) = parsed.host_str() {
4623            let no_proxy_entries = cfg.normalized_no_proxy();
4624            if !no_proxy_entries.is_empty() {
4625                let host_lower = host.to_ascii_lowercase();
4626                let matches_no_proxy = no_proxy_entries.iter().any(|entry| {
4627                    let entry = entry.trim().to_ascii_lowercase();
4628                    if entry == "*" {
4629                        return true;
4630                    }
4631                    if host_lower == entry {
4632                        return true;
4633                    }
4634                    // Support ".example.com" matching "foo.example.com"
4635                    if let Some(suffix) = entry.strip_prefix('.') {
4636                        return host_lower.ends_with(suffix) || host_lower == suffix;
4637                    }
4638                    // Support "example.com" also matching "foo.example.com"
4639                    host_lower.ends_with(&format!(".{entry}"))
4640                });
4641                if matches_no_proxy {
4642                    return None;
4643                }
4644            }
4645        }
4646    }
4647
4648    // For wss:// prefer https_proxy, for ws:// prefer http_proxy, fall
4649    // back to all_proxy in both cases.
4650    let is_secure = ws_url.starts_with("wss://") || ws_url.starts_with("wss:");
4651    let preferred = if is_secure {
4652        normalize_proxy_url_option(cfg.https_proxy.as_deref())
4653    } else {
4654        normalize_proxy_url_option(cfg.http_proxy.as_deref())
4655    };
4656    preferred.or_else(|| normalize_proxy_url_option(cfg.all_proxy.as_deref()))
4657}
4658
4659/// Connect a WebSocket through the configured proxy (if any).
4660///
4661/// When no proxy applies, this is a thin wrapper around
4662/// `tokio_tungstenite::connect_async`.  When a proxy is active the
4663/// function tunnels the TCP connection through the proxy before
4664/// performing the WebSocket upgrade.
4665///
4666/// `service_key` is the proxy-service selector (e.g. `"channel.discord"`).
4667/// `channel_proxy_url` is the optional per-channel proxy override.
4668pub async fn ws_connect_with_proxy(
4669    ws_url: &str,
4670    service_key: &str,
4671    channel_proxy_url: Option<&str>,
4672) -> anyhow::Result<(
4673    ProxiedWsStream,
4674    tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
4675)> {
4676    let proxy_url = resolve_ws_proxy_url(service_key, ws_url, channel_proxy_url);
4677
4678    match proxy_url {
4679        None => {
4680            // No proxy — delegate directly.
4681            let (stream, resp) = tokio_tungstenite::connect_async(ws_url).await?;
4682            // Re-wrap the inner stream into our boxed type so the caller
4683            // always gets `ProxiedWsStream`.
4684            let inner = stream.into_inner();
4685            let boxed = BoxedIo(Box::new(inner));
4686            let ws = tokio_tungstenite::WebSocketStream::from_raw_socket(
4687                boxed,
4688                tokio_tungstenite::tungstenite::protocol::Role::Client,
4689                None,
4690            )
4691            .await;
4692            Ok((ws, resp))
4693        }
4694        Some(proxy) => ws_connect_via_proxy(ws_url, &proxy).await,
4695    }
4696}
4697
4698/// Establish a WebSocket connection tunnelled through the given proxy URL.
4699async fn ws_connect_via_proxy(
4700    ws_url: &str,
4701    proxy_url: &str,
4702) -> anyhow::Result<(
4703    ProxiedWsStream,
4704    tokio_tungstenite::tungstenite::http::Response<Option<Vec<u8>>>,
4705)> {
4706    use tokio::io::{AsyncReadExt, AsyncWriteExt as _};
4707    use tokio::net::TcpStream;
4708
4709    let target =
4710        reqwest::Url::parse(ws_url).with_context(|| format!("Invalid WebSocket URL: {ws_url}"))?;
4711    let target_host = target
4712        .host_str()
4713        .ok_or_else(|| anyhow::anyhow!("WebSocket URL has no host: {ws_url}"))?
4714        .to_string();
4715    let target_port = target
4716        .port_or_known_default()
4717        .unwrap_or(if target.scheme() == "wss" { 443 } else { 80 });
4718
4719    let proxy = reqwest::Url::parse(proxy_url)
4720        .with_context(|| format!("Invalid proxy URL: {proxy_url}"))?;
4721
4722    let stream: BoxedIo = match proxy.scheme() {
4723        "socks5" | "socks5h" | "socks" => {
4724            let proxy_addr = format!(
4725                "{}:{}",
4726                proxy.host_str().unwrap_or("127.0.0.1"),
4727                proxy.port_or_known_default().unwrap_or(1080)
4728            );
4729            let target_addr = format!("{target_host}:{target_port}");
4730            let socks_stream = if proxy.username().is_empty() {
4731                tokio_socks::tcp::Socks5Stream::connect(proxy_addr.as_str(), target_addr.as_str())
4732                    .await
4733                    .with_context(|| format!("SOCKS5 connect to {target_addr} via {proxy_addr}"))?
4734            } else {
4735                let password = proxy.password().unwrap_or("");
4736                tokio_socks::tcp::Socks5Stream::connect_with_password(
4737                    proxy_addr.as_str(),
4738                    target_addr.as_str(),
4739                    proxy.username(),
4740                    password,
4741                )
4742                .await
4743                .with_context(|| format!("SOCKS5 auth connect to {target_addr} via {proxy_addr}"))?
4744            };
4745            let tcp: TcpStream = socks_stream.into_inner();
4746            BoxedIo(Box::new(tcp))
4747        }
4748        "http" | "https" => {
4749            let proxy_host = proxy.host_str().unwrap_or("127.0.0.1");
4750            let proxy_port = proxy.port_or_known_default().unwrap_or(8080);
4751            let proxy_addr = format!("{proxy_host}:{proxy_port}");
4752
4753            let mut tcp = TcpStream::connect(&proxy_addr)
4754                .await
4755                .with_context(|| format!("TCP connect to HTTP proxy {proxy_addr}"))?;
4756
4757            // Send HTTP CONNECT request.
4758            let connect_req = format!(
4759                "CONNECT {target_host}:{target_port} HTTP/1.1\r\nHost: {target_host}:{target_port}\r\n\r\n"
4760            );
4761            tcp.write_all(connect_req.as_bytes()).await?;
4762
4763            // Read the response (we only need the status line).
4764            let mut buf = vec![0u8; 4096];
4765            let mut total = 0usize;
4766            loop {
4767                let n = tcp.read(&mut buf[total..]).await?;
4768                if n == 0 {
4769                    anyhow::bail!("HTTP CONNECT proxy closed connection before response");
4770                }
4771                total += n;
4772                // Look for end of HTTP headers.
4773                if let Some(pos) = find_header_end(&buf[..total]) {
4774                    let status_line = std::str::from_utf8(&buf[..pos])
4775                        .unwrap_or("")
4776                        .lines()
4777                        .next()
4778                        .unwrap_or("");
4779                    if !status_line.contains("200") {
4780                        anyhow::bail!(
4781                            "HTTP CONNECT proxy returned non-200 response: {status_line}"
4782                        );
4783                    }
4784                    break;
4785                }
4786                if total >= buf.len() {
4787                    anyhow::bail!("HTTP CONNECT proxy response too large");
4788                }
4789            }
4790
4791            BoxedIo(Box::new(tcp))
4792        }
4793        scheme => {
4794            anyhow::bail!("Unsupported proxy scheme '{scheme}' for WebSocket connections");
4795        }
4796    };
4797
4798    // If the target is wss://, wrap in TLS.
4799    let is_secure = target.scheme() == "wss";
4800    let stream: BoxedIo = if is_secure {
4801        let mut root_store = rustls::RootCertStore::empty();
4802        root_store.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
4803        let tls_config = std::sync::Arc::new(
4804            rustls::ClientConfig::builder()
4805                .with_root_certificates(root_store)
4806                .with_no_client_auth(),
4807        );
4808        let connector = tokio_rustls::TlsConnector::from(tls_config);
4809        let server_name = rustls_pki_types::ServerName::try_from(target_host.clone())
4810            .with_context(|| format!("Invalid TLS server name: {target_host}"))?;
4811
4812        // `stream` is `BoxedIo` — we need a concrete `AsyncRead + AsyncWrite`
4813        // for `TlsConnector::connect`.  Since `BoxedIo` already satisfies
4814        // those bounds we can pass it directly.
4815        let tls_stream = connector
4816            .connect(server_name, stream)
4817            .await
4818            .with_context(|| format!("TLS handshake with {target_host}"))?;
4819        BoxedIo(Box::new(tls_stream))
4820    } else {
4821        stream
4822    };
4823
4824    // Perform the WebSocket client handshake over the tunnelled stream.
4825    let ws_request = tokio_tungstenite::tungstenite::http::Request::builder()
4826        .uri(ws_url)
4827        .header("Host", format!("{target_host}:{target_port}"))
4828        .header("Connection", "Upgrade")
4829        .header("Upgrade", "websocket")
4830        .header(
4831            "Sec-WebSocket-Key",
4832            tokio_tungstenite::tungstenite::handshake::client::generate_key(),
4833        )
4834        .header("Sec-WebSocket-Version", "13")
4835        .body(())
4836        .with_context(|| "Failed to build WebSocket upgrade request")?;
4837
4838    let (ws_stream, response) = tokio_tungstenite::client_async(ws_request, stream)
4839        .await
4840        .with_context(|| format!("WebSocket handshake failed for {ws_url}"))?;
4841
4842    Ok((ws_stream, response))
4843}
4844
4845/// Find the `\r\n\r\n` boundary marking the end of HTTP headers.
4846fn find_header_end(buf: &[u8]) -> Option<usize> {
4847    buf.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4)
4848}
4849
4850fn parse_proxy_scope(raw: &str) -> Option<ProxyScope> {
4851    match raw.trim().to_ascii_lowercase().as_str() {
4852        "environment" | "env" => Some(ProxyScope::Environment),
4853        "construct" | "internal" | "core" => Some(ProxyScope::Construct),
4854        "services" | "service" => Some(ProxyScope::Services),
4855        _ => None,
4856    }
4857}
4858
4859fn parse_proxy_enabled(raw: &str) -> Option<bool> {
4860    match raw.trim().to_ascii_lowercase().as_str() {
4861        "1" | "true" | "yes" | "on" => Some(true),
4862        "0" | "false" | "no" | "off" => Some(false),
4863        _ => None,
4864    }
4865}
4866// ── Memory ───────────────────────────────────────────────────
4867
4868/// Persistent storage configuration (`[storage]` section).
4869#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4870pub struct StorageConfig {
4871    /// Storage provider settings (e.g. sqlite, postgres).
4872    #[serde(default)]
4873    pub provider: StorageProviderSection,
4874}
4875
4876/// Wrapper for the storage provider configuration section.
4877#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
4878pub struct StorageProviderSection {
4879    /// Storage provider backend settings.
4880    #[serde(default)]
4881    pub config: StorageProviderConfig,
4882}
4883
4884/// Storage provider backend configuration for remote storage backends.
4885#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
4886pub struct StorageProviderConfig {
4887    /// Storage engine key (e.g. "sqlite", "qdrant").
4888    #[serde(default)]
4889    pub provider: String,
4890
4891    /// Connection URL for remote providers.
4892    /// Accepts legacy aliases: dbURL, database_url, databaseUrl.
4893    #[serde(
4894        default,
4895        alias = "dbURL",
4896        alias = "database_url",
4897        alias = "databaseUrl"
4898    )]
4899    pub db_url: Option<String>,
4900
4901    /// Database schema for SQL backends.
4902    #[serde(default = "default_storage_schema")]
4903    pub schema: String,
4904
4905    /// Table name for memory entries.
4906    #[serde(default = "default_storage_table")]
4907    pub table: String,
4908
4909    /// Optional connection timeout in seconds for remote providers.
4910    #[serde(default)]
4911    pub connect_timeout_secs: Option<u64>,
4912}
4913
4914fn default_storage_schema() -> String {
4915    "public".into()
4916}
4917
4918fn default_storage_table() -> String {
4919    "memories".into()
4920}
4921
4922impl Default for StorageProviderConfig {
4923    fn default() -> Self {
4924        Self {
4925            provider: String::new(),
4926            db_url: None,
4927            schema: default_storage_schema(),
4928            table: default_storage_table(),
4929            connect_timeout_secs: None,
4930        }
4931    }
4932}
4933
4934/// Memory backend configuration (`[memory]` section).
4935///
4936/// Persistent memory in Construct is handled exclusively by Kumiho MCP; this
4937/// section controls in-session auto-save behaviour and response caching.
4938#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
4939#[allow(clippy::struct_excessive_bools)]
4940pub struct MemoryConfig {
4941    /// `"kumiho"` (persistent via Kumiho MCP) or `"none"` (no-op, in-session only).
4942    ///
4943    /// Legacy values (`sqlite`, `lucid`, `markdown`, `qdrant`) are rejected at
4944    /// startup and should be migrated to `kumiho`.
4945    pub backend: String,
4946    /// Auto-save user-stated conversation input to memory (assistant output is excluded)
4947    pub auto_save: bool,
4948    /// Run memory/session hygiene (archiving + retention cleanup)
4949    #[serde(default = "default_hygiene_enabled")]
4950    pub hygiene_enabled: bool,
4951    /// Archive daily/session files older than this many days
4952    #[serde(default = "default_archive_after_days")]
4953    pub archive_after_days: u32,
4954    /// Purge archived files older than this many days
4955    #[serde(default = "default_purge_after_days")]
4956    pub purge_after_days: u32,
4957    /// Prune archived conversation rows older than this many days.
4958    #[serde(default = "default_conversation_retention_days")]
4959    pub conversation_retention_days: u32,
4960    /// Minimum hybrid score (0.0-1.0) for a memory to be included in context.
4961    /// Memories scoring below this threshold are dropped to prevent irrelevant
4962    /// context from bleeding into conversations. Default: 0.4
4963    #[serde(default = "default_min_relevance_score")]
4964    pub min_relevance_score: f64,
4965
4966    // ── Response Cache (saves tokens on repeated prompts) ──────
4967    /// Enable LLM response caching to avoid paying for duplicate prompts
4968    #[serde(default)]
4969    pub response_cache_enabled: bool,
4970    /// TTL in minutes for cached responses (default: 60)
4971    #[serde(default = "default_response_cache_ttl")]
4972    pub response_cache_ttl_minutes: u32,
4973    /// Max number of cached responses before LRU eviction (default: 5000)
4974    #[serde(default = "default_response_cache_max")]
4975    pub response_cache_max_entries: usize,
4976    /// Max in-memory hot cache entries for the two-tier response cache (default: 256)
4977    #[serde(default = "default_response_cache_hot_entries")]
4978    pub response_cache_hot_entries: usize,
4979
4980    // ── Memory Snapshot (soul backup to Markdown) ─────────────
4981    /// Enable periodic export of core memories to MEMORY_SNAPSHOT.md
4982    #[serde(default)]
4983    pub snapshot_enabled: bool,
4984    /// Run snapshot during hygiene passes (heartbeat-driven)
4985    #[serde(default)]
4986    pub snapshot_on_hygiene: bool,
4987    /// Auto-hydrate from MEMORY_SNAPSHOT.md when brain.db is missing
4988    #[serde(default = "default_true")]
4989    pub auto_hydrate: bool,
4990
4991    // ── Namespace Isolation ─────────────────────────────────────
4992    /// Default namespace for memory entries.
4993    #[serde(default = "default_namespace")]
4994    pub default_namespace: String,
4995
4996    // ── Audit Trail ─────────────────────────────────────────────
4997    /// Enable audit logging of memory operations.
4998    #[serde(default)]
4999    pub audit_enabled: bool,
5000    /// Retention period for audit entries in days (default: 30).
5001    #[serde(default = "default_audit_retention_days")]
5002    pub audit_retention_days: u32,
5003
5004    // ── Policy Engine ───────────────────────────────────────────
5005    /// Memory policy configuration.
5006    #[serde(default)]
5007    pub policy: MemoryPolicyConfig,
5008}
5009
5010/// Memory policy configuration (`[memory.policy]` section).
5011#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
5012pub struct MemoryPolicyConfig {
5013    /// Maximum entries per namespace (0 = unlimited).
5014    #[serde(default)]
5015    pub max_entries_per_namespace: usize,
5016    /// Maximum entries per category (0 = unlimited).
5017    #[serde(default)]
5018    pub max_entries_per_category: usize,
5019    /// Retention days by category (overrides global). Keys: "core", "daily", "conversation".
5020    #[serde(default)]
5021    pub retention_days_by_category: std::collections::HashMap<String, u32>,
5022    /// Namespaces that are read-only (writes are rejected).
5023    #[serde(default)]
5024    pub read_only_namespaces: Vec<String>,
5025}
5026
5027fn default_namespace() -> String {
5028    "default".into()
5029}
5030fn default_audit_retention_days() -> u32 {
5031    30
5032}
5033fn default_hygiene_enabled() -> bool {
5034    true
5035}
5036fn default_archive_after_days() -> u32 {
5037    7
5038}
5039fn default_purge_after_days() -> u32 {
5040    30
5041}
5042fn default_conversation_retention_days() -> u32 {
5043    30
5044}
5045fn default_min_relevance_score() -> f64 {
5046    0.4
5047}
5048fn default_response_cache_ttl() -> u32 {
5049    60
5050}
5051fn default_response_cache_max() -> usize {
5052    5_000
5053}
5054
5055fn default_response_cache_hot_entries() -> usize {
5056    256
5057}
5058
5059impl Default for MemoryConfig {
5060    fn default() -> Self {
5061        Self {
5062            backend: "none".into(),
5063            auto_save: true,
5064            hygiene_enabled: default_hygiene_enabled(),
5065            archive_after_days: default_archive_after_days(),
5066            purge_after_days: default_purge_after_days(),
5067            conversation_retention_days: default_conversation_retention_days(),
5068            min_relevance_score: default_min_relevance_score(),
5069            response_cache_enabled: false,
5070            response_cache_ttl_minutes: default_response_cache_ttl(),
5071            response_cache_max_entries: default_response_cache_max(),
5072            response_cache_hot_entries: default_response_cache_hot_entries(),
5073            snapshot_enabled: false,
5074            snapshot_on_hygiene: false,
5075            auto_hydrate: true,
5076            default_namespace: default_namespace(),
5077            audit_enabled: false,
5078            audit_retention_days: default_audit_retention_days(),
5079            policy: MemoryPolicyConfig::default(),
5080        }
5081    }
5082}
5083
5084// ── Observability ─────────────────────────────────────────────────
5085
5086/// Observability backend configuration (`[observability]` section).
5087#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5088pub struct ObservabilityConfig {
5089    /// "none" | "log" | "verbose" | "prometheus" | "otel"
5090    pub backend: String,
5091
5092    /// OTLP endpoint (e.g. "http://localhost:4318"). Only used when backend = "otel".
5093    #[serde(default)]
5094    pub otel_endpoint: Option<String>,
5095
5096    /// Service name reported to the OTel collector. Defaults to "construct".
5097    #[serde(default)]
5098    pub otel_service_name: Option<String>,
5099
5100    /// Runtime trace storage mode: "none" | "rolling" | "full".
5101    /// Controls whether model replies and tool-call diagnostics are persisted.
5102    #[serde(default = "default_runtime_trace_mode")]
5103    pub runtime_trace_mode: String,
5104
5105    /// Runtime trace file path. Relative paths are resolved under workspace_dir.
5106    #[serde(default = "default_runtime_trace_path")]
5107    pub runtime_trace_path: String,
5108
5109    /// Maximum entries retained when runtime_trace_mode = "rolling".
5110    #[serde(default = "default_runtime_trace_max_entries")]
5111    pub runtime_trace_max_entries: usize,
5112}
5113
5114impl Default for ObservabilityConfig {
5115    fn default() -> Self {
5116        Self {
5117            backend: "none".into(),
5118            otel_endpoint: None,
5119            otel_service_name: None,
5120            runtime_trace_mode: default_runtime_trace_mode(),
5121            runtime_trace_path: default_runtime_trace_path(),
5122            runtime_trace_max_entries: default_runtime_trace_max_entries(),
5123        }
5124    }
5125}
5126
5127fn default_runtime_trace_mode() -> String {
5128    "none".to_string()
5129}
5130
5131fn default_runtime_trace_path() -> String {
5132    "state/runtime-trace.jsonl".to_string()
5133}
5134
5135fn default_runtime_trace_max_entries() -> usize {
5136    200
5137}
5138
5139// ── Hooks ────────────────────────────────────────────────────────
5140
5141#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5142pub struct HooksConfig {
5143    /// Enable lifecycle hook execution.
5144    ///
5145    /// Hooks run in-process with the same privileges as the main runtime.
5146    /// Keep enabled hook handlers narrowly scoped and auditable.
5147    pub enabled: bool,
5148    #[serde(default)]
5149    pub builtin: BuiltinHooksConfig,
5150}
5151
5152impl Default for HooksConfig {
5153    fn default() -> Self {
5154        Self {
5155            enabled: true,
5156            builtin: BuiltinHooksConfig::default(),
5157        }
5158    }
5159}
5160
5161#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
5162pub struct BuiltinHooksConfig {
5163    /// Enable the command-logger hook (logs tool calls for auditing).
5164    pub command_logger: bool,
5165    /// Configuration for the webhook-audit hook.
5166    ///
5167    /// When enabled, POSTs a JSON payload to `url` for every tool invocation
5168    /// that matches one of `tool_patterns`.
5169    #[serde(default)]
5170    pub webhook_audit: WebhookAuditConfig,
5171}
5172
5173/// Configuration for the webhook-audit builtin hook.
5174///
5175/// Sends an HTTP POST with a JSON body to an external endpoint each time
5176/// a tool call matches one of the configured patterns. Useful for
5177/// centralised audit logging, SIEM ingestion, or compliance pipelines.
5178#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5179pub struct WebhookAuditConfig {
5180    /// Enable the webhook-audit hook. Default: `false`.
5181    #[serde(default)]
5182    pub enabled: bool,
5183    /// Target URL that will receive the audit POST requests.
5184    #[serde(default)]
5185    pub url: String,
5186    /// Glob patterns for tool names to audit (e.g. `["Bash", "Write"]`).
5187    /// An empty list means **no** tools are audited.
5188    #[serde(default)]
5189    pub tool_patterns: Vec<String>,
5190    /// Include tool call arguments in the audit payload. Default: `false`.
5191    ///
5192    /// Be mindful of sensitive data — arguments may contain secrets or PII.
5193    #[serde(default)]
5194    pub include_args: bool,
5195    /// Maximum size (in bytes) of serialised arguments included in a single
5196    /// audit payload. Arguments exceeding this limit are truncated.
5197    /// Default: `4096`.
5198    #[serde(default = "default_max_args_bytes")]
5199    pub max_args_bytes: u64,
5200}
5201
5202fn default_max_args_bytes() -> u64 {
5203    4096
5204}
5205
5206impl Default for WebhookAuditConfig {
5207    fn default() -> Self {
5208        Self {
5209            enabled: false,
5210            url: String::new(),
5211            tool_patterns: Vec::new(),
5212            include_args: false,
5213            max_args_bytes: default_max_args_bytes(),
5214        }
5215    }
5216}
5217
5218// ── Autonomy / Security ──────────────────────────────────────────
5219
5220/// Autonomy and security policy configuration (`[autonomy]` section).
5221///
5222/// Controls what the agent is allowed to do: shell commands, filesystem access,
5223/// risk approval gates, and per-policy budgets.
5224#[allow(clippy::struct_excessive_bools)]
5225#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5226#[serde(default)]
5227pub struct AutonomyConfig {
5228    /// Autonomy level: `read_only`, `supervised` (default), or `full`.
5229    pub level: AutonomyLevel,
5230    /// Restrict absolute filesystem paths to workspace-relative references. Default: `true`.
5231    /// Resolved paths outside the workspace still require `allowed_roots`.
5232    pub workspace_only: bool,
5233    /// Allowlist of executable names permitted for shell execution.
5234    pub allowed_commands: Vec<String>,
5235    /// Explicit path denylist. Default includes system-critical paths and sensitive dotdirs.
5236    pub forbidden_paths: Vec<String>,
5237    /// Maximum actions allowed per hour per policy. Default: `100`.
5238    pub max_actions_per_hour: u32,
5239    /// Maximum cost per day in cents per policy. Default: `1000`.
5240    pub max_cost_per_day_cents: u32,
5241
5242    /// Require explicit approval for medium-risk shell commands.
5243    #[serde(default = "default_true")]
5244    pub require_approval_for_medium_risk: bool,
5245
5246    /// Block high-risk shell commands even if allowlisted.
5247    #[serde(default = "default_true")]
5248    pub block_high_risk_commands: bool,
5249
5250    /// Additional environment variables allowed for shell tool subprocesses.
5251    ///
5252    /// These names are explicitly allowlisted and merged with the built-in safe
5253    /// baseline (`PATH`, `HOME`, etc.) after `env_clear()`.
5254    #[serde(default)]
5255    pub shell_env_passthrough: Vec<String>,
5256
5257    /// Tools that never require approval (e.g. read-only tools).
5258    #[serde(default = "default_auto_approve")]
5259    pub auto_approve: Vec<String>,
5260
5261    /// Tools that always require interactive approval, even after "Always".
5262    #[serde(default = "default_always_ask")]
5263    pub always_ask: Vec<String>,
5264
5265    /// Extra directory roots the agent may read/write outside the workspace.
5266    /// Supports absolute, `~/...`, and workspace-relative entries.
5267    /// Resolved paths under any of these roots pass `is_resolved_path_allowed`.
5268    #[serde(default)]
5269    pub allowed_roots: Vec<String>,
5270
5271    /// Tools to exclude from non-CLI channels (e.g. Telegram, Discord).
5272    ///
5273    /// When a tool is listed here, non-CLI channels will not expose it to the
5274    /// model in tool specs.
5275    #[serde(default)]
5276    pub non_cli_excluded_tools: Vec<String>,
5277}
5278
5279fn default_auto_approve() -> Vec<String> {
5280    vec![
5281        "file_read".into(),
5282        "memory_recall".into(),
5283        "web_search_tool".into(),
5284        "web_fetch".into(),
5285        "calculator".into(),
5286        "glob_search".into(),
5287        "content_search".into(),
5288        "image_info".into(),
5289        "weather".into(),
5290    ]
5291}
5292
5293fn default_always_ask() -> Vec<String> {
5294    vec![]
5295}
5296
5297impl AutonomyConfig {
5298    /// Merge the built-in default `auto_approve` entries into the current
5299    /// list, preserving any user-supplied additions.
5300    pub fn ensure_default_auto_approve(&mut self) {
5301        let defaults = default_auto_approve();
5302        for entry in defaults {
5303            if !self.auto_approve.iter().any(|existing| existing == &entry) {
5304                self.auto_approve.push(entry);
5305            }
5306        }
5307    }
5308}
5309
5310fn is_valid_env_var_name(name: &str) -> bool {
5311    let mut chars = name.chars();
5312    match chars.next() {
5313        Some(first) if first.is_ascii_alphabetic() || first == '_' => {}
5314        _ => return false,
5315    }
5316    chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_')
5317}
5318
5319impl Default for AutonomyConfig {
5320    fn default() -> Self {
5321        Self {
5322            level: AutonomyLevel::Supervised,
5323            workspace_only: true,
5324            allowed_commands: vec![
5325                "git".into(),
5326                "npm".into(),
5327                "cargo".into(),
5328                "ls".into(),
5329                "cat".into(),
5330                "grep".into(),
5331                "find".into(),
5332                "echo".into(),
5333                "pwd".into(),
5334                "wc".into(),
5335                "head".into(),
5336                "tail".into(),
5337                "date".into(),
5338                "python".into(),
5339                "python3".into(),
5340                "pip".into(),
5341                "node".into(),
5342            ],
5343            forbidden_paths: vec![
5344                "/etc".into(),
5345                "/root".into(),
5346                "/home".into(),
5347                "/usr".into(),
5348                "/bin".into(),
5349                "/sbin".into(),
5350                "/lib".into(),
5351                "/opt".into(),
5352                "/boot".into(),
5353                "/dev".into(),
5354                "/proc".into(),
5355                "/sys".into(),
5356                "/var".into(),
5357                "/tmp".into(),
5358                "~/.ssh".into(),
5359                "~/.gnupg".into(),
5360                "~/.aws".into(),
5361                "~/.config".into(),
5362            ],
5363            max_actions_per_hour: 20,
5364            max_cost_per_day_cents: 500,
5365            require_approval_for_medium_risk: true,
5366            block_high_risk_commands: true,
5367            shell_env_passthrough: vec![],
5368            auto_approve: default_auto_approve(),
5369            always_ask: default_always_ask(),
5370            allowed_roots: Vec::new(),
5371            non_cli_excluded_tools: Vec::new(),
5372        }
5373    }
5374}
5375
5376// ── Runtime ──────────────────────────────────────────────────────
5377
5378/// Runtime adapter configuration (`[runtime]` section).
5379#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5380pub struct RuntimeConfig {
5381    /// Runtime kind (`native` | `docker`).
5382    #[serde(default = "default_runtime_kind")]
5383    pub kind: String,
5384
5385    /// Docker runtime settings (used when `kind = "docker"`).
5386    #[serde(default)]
5387    pub docker: DockerRuntimeConfig,
5388
5389    /// Global reasoning override for providers that expose explicit controls.
5390    /// - `None`: provider default behavior
5391    /// - `Some(true)`: request reasoning/thinking when supported
5392    /// - `Some(false)`: disable reasoning/thinking when supported
5393    #[serde(default)]
5394    pub reasoning_enabled: Option<bool>,
5395    /// Optional reasoning effort for providers that expose a level control.
5396    #[serde(default, deserialize_with = "deserialize_reasoning_effort_opt")]
5397    pub reasoning_effort: Option<String>,
5398}
5399
5400/// Docker runtime configuration (`[runtime.docker]` section).
5401#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5402pub struct DockerRuntimeConfig {
5403    /// Runtime image used to execute shell commands.
5404    #[serde(default = "default_docker_image")]
5405    pub image: String,
5406
5407    /// Docker network mode (`none`, `bridge`, etc.).
5408    #[serde(default = "default_docker_network")]
5409    pub network: String,
5410
5411    /// Optional memory limit in MB (`None` = no explicit limit).
5412    #[serde(default = "default_docker_memory_limit_mb")]
5413    pub memory_limit_mb: Option<u64>,
5414
5415    /// Optional CPU limit (`None` = no explicit limit).
5416    #[serde(default = "default_docker_cpu_limit")]
5417    pub cpu_limit: Option<f64>,
5418
5419    /// Mount root filesystem as read-only.
5420    #[serde(default = "default_true")]
5421    pub read_only_rootfs: bool,
5422
5423    /// Mount configured workspace into `/workspace`.
5424    #[serde(default = "default_true")]
5425    pub mount_workspace: bool,
5426
5427    /// Optional workspace root allowlist for Docker mount validation.
5428    #[serde(default)]
5429    pub allowed_workspace_roots: Vec<String>,
5430}
5431
5432fn default_runtime_kind() -> String {
5433    "native".into()
5434}
5435
5436fn default_docker_image() -> String {
5437    "alpine:3.20".into()
5438}
5439
5440fn default_docker_network() -> String {
5441    "none".into()
5442}
5443
5444fn default_docker_memory_limit_mb() -> Option<u64> {
5445    Some(512)
5446}
5447
5448fn default_docker_cpu_limit() -> Option<f64> {
5449    Some(1.0)
5450}
5451
5452impl Default for DockerRuntimeConfig {
5453    fn default() -> Self {
5454        Self {
5455            image: default_docker_image(),
5456            network: default_docker_network(),
5457            memory_limit_mb: default_docker_memory_limit_mb(),
5458            cpu_limit: default_docker_cpu_limit(),
5459            read_only_rootfs: true,
5460            mount_workspace: true,
5461            allowed_workspace_roots: Vec::new(),
5462        }
5463    }
5464}
5465
5466impl Default for RuntimeConfig {
5467    fn default() -> Self {
5468        Self {
5469            kind: default_runtime_kind(),
5470            docker: DockerRuntimeConfig::default(),
5471            reasoning_enabled: None,
5472            reasoning_effort: None,
5473        }
5474    }
5475}
5476
5477// ── Reliability / supervision ────────────────────────────────────
5478
5479/// Reliability and supervision configuration (`[reliability]` section).
5480///
5481/// Controls provider retries, fallback chains, API key rotation, and channel restart backoff.
5482#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5483pub struct ReliabilityConfig {
5484    /// Retries per provider before failing over.
5485    #[serde(default = "default_provider_retries")]
5486    pub provider_retries: u32,
5487    /// Base backoff (ms) for provider retry delay.
5488    #[serde(default = "default_provider_backoff_ms")]
5489    pub provider_backoff_ms: u64,
5490    /// Fallback provider chain (e.g. `["anthropic", "openai"]`).
5491    #[serde(default)]
5492    pub fallback_providers: Vec<String>,
5493    /// Additional API keys for round-robin rotation on rate-limit (429) errors.
5494    /// The primary `api_key` is always tried first; these are extras.
5495    #[serde(default)]
5496    pub api_keys: Vec<String>,
5497    /// Per-model fallback chains. When a model fails, try these alternatives in order.
5498    /// Example: `{ "claude-opus-4-20250514" = ["claude-sonnet-4-20250514", "gpt-4o"] }`
5499    #[serde(default)]
5500    pub model_fallbacks: std::collections::HashMap<String, Vec<String>>,
5501    /// Initial backoff for channel/daemon restarts.
5502    #[serde(default = "default_channel_backoff_secs")]
5503    pub channel_initial_backoff_secs: u64,
5504    /// Max backoff for channel/daemon restarts.
5505    #[serde(default = "default_channel_backoff_max_secs")]
5506    pub channel_max_backoff_secs: u64,
5507    /// Scheduler polling cadence in seconds.
5508    #[serde(default = "default_scheduler_poll_secs")]
5509    pub scheduler_poll_secs: u64,
5510    /// Max retries for cron job execution attempts.
5511    #[serde(default = "default_scheduler_retries")]
5512    pub scheduler_retries: u32,
5513}
5514
5515fn default_provider_retries() -> u32 {
5516    2
5517}
5518
5519fn default_provider_backoff_ms() -> u64 {
5520    500
5521}
5522
5523fn default_channel_backoff_secs() -> u64 {
5524    2
5525}
5526
5527fn default_channel_backoff_max_secs() -> u64 {
5528    60
5529}
5530
5531fn default_scheduler_poll_secs() -> u64 {
5532    15
5533}
5534
5535fn default_scheduler_retries() -> u32 {
5536    2
5537}
5538
5539impl Default for ReliabilityConfig {
5540    fn default() -> Self {
5541        Self {
5542            provider_retries: default_provider_retries(),
5543            provider_backoff_ms: default_provider_backoff_ms(),
5544            fallback_providers: Vec::new(),
5545            api_keys: Vec::new(),
5546            model_fallbacks: std::collections::HashMap::new(),
5547            channel_initial_backoff_secs: default_channel_backoff_secs(),
5548            channel_max_backoff_secs: default_channel_backoff_max_secs(),
5549            scheduler_poll_secs: default_scheduler_poll_secs(),
5550            scheduler_retries: default_scheduler_retries(),
5551        }
5552    }
5553}
5554
5555// ── Scheduler ────────────────────────────────────────────────────
5556
5557/// Scheduler configuration for periodic task execution (`[scheduler]` section).
5558#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5559pub struct SchedulerConfig {
5560    /// Enable the built-in scheduler loop.
5561    #[serde(default = "default_scheduler_enabled")]
5562    pub enabled: bool,
5563    /// Maximum number of persisted scheduled tasks.
5564    #[serde(default = "default_scheduler_max_tasks")]
5565    pub max_tasks: usize,
5566    /// Maximum tasks executed per scheduler polling cycle.
5567    #[serde(default = "default_scheduler_max_concurrent")]
5568    pub max_concurrent: usize,
5569}
5570
5571fn default_scheduler_enabled() -> bool {
5572    true
5573}
5574
5575fn default_scheduler_max_tasks() -> usize {
5576    64
5577}
5578
5579fn default_scheduler_max_concurrent() -> usize {
5580    4
5581}
5582
5583impl Default for SchedulerConfig {
5584    fn default() -> Self {
5585        Self {
5586            enabled: default_scheduler_enabled(),
5587            max_tasks: default_scheduler_max_tasks(),
5588            max_concurrent: default_scheduler_max_concurrent(),
5589        }
5590    }
5591}
5592
5593// ── Model routing ────────────────────────────────────────────────
5594
5595/// Route a task hint to a specific provider + model.
5596///
5597/// ```toml
5598/// [[model_routes]]
5599/// hint = "reasoning"
5600/// provider = "openrouter"
5601/// model = "anthropic/claude-opus-4-20250514"
5602///
5603/// [[model_routes]]
5604/// hint = "fast"
5605/// provider = "groq"
5606/// model = "llama-3.3-70b-versatile"
5607/// ```
5608///
5609/// Usage: pass `hint:reasoning` as the model parameter to route the request.
5610#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5611pub struct ModelRouteConfig {
5612    /// Task hint name (e.g. "reasoning", "fast", "code", "summarize")
5613    pub hint: String,
5614    /// Provider to route to (must match a known provider name)
5615    pub provider: String,
5616    /// Model to use with that provider
5617    pub model: String,
5618    /// Optional API key override for this route's provider
5619    #[serde(default)]
5620    pub api_key: Option<String>,
5621}
5622
5623// ── Embedding routing ───────────────────────────────────────────
5624
5625/// Route an embedding hint to a specific provider + model.
5626///
5627/// ```toml
5628/// [[embedding_routes]]
5629/// hint = "semantic"
5630/// provider = "openai"
5631/// model = "text-embedding-3-small"
5632/// dimensions = 1536
5633///
5634/// [memory]
5635/// embedding_model = "hint:semantic"
5636/// ```
5637#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5638pub struct EmbeddingRouteConfig {
5639    /// Route hint name (e.g. "semantic", "archive", "faq")
5640    pub hint: String,
5641    /// Embedding provider (`none`, `openai`, or `custom:<url>`)
5642    pub provider: String,
5643    /// Embedding model to use with that provider
5644    pub model: String,
5645    /// Optional embedding dimension override for this route
5646    #[serde(default)]
5647    pub dimensions: Option<usize>,
5648    /// Optional API key override for this route's provider
5649    #[serde(default)]
5650    pub api_key: Option<String>,
5651}
5652
5653// ── Query Classification ─────────────────────────────────────────
5654
5655/// Automatic query classification — classifies user messages by keyword/pattern
5656/// and routes to the appropriate model hint. Disabled by default.
5657#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5658pub struct QueryClassificationConfig {
5659    /// Enable automatic query classification. Default: `false`.
5660    #[serde(default)]
5661    pub enabled: bool,
5662    /// Classification rules evaluated in priority order.
5663    #[serde(default)]
5664    pub rules: Vec<ClassificationRule>,
5665}
5666
5667/// A single classification rule mapping message patterns to a model hint.
5668#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
5669pub struct ClassificationRule {
5670    /// Must match a `[[model_routes]]` hint value.
5671    pub hint: String,
5672    /// Case-insensitive substring matches.
5673    #[serde(default)]
5674    pub keywords: Vec<String>,
5675    /// Case-sensitive literal matches (for "```", "fn ", etc.).
5676    #[serde(default)]
5677    pub patterns: Vec<String>,
5678    /// Only match if message length >= N chars.
5679    #[serde(default)]
5680    pub min_length: Option<usize>,
5681    /// Only match if message length <= N chars.
5682    #[serde(default)]
5683    pub max_length: Option<usize>,
5684    /// Higher priority rules are checked first.
5685    #[serde(default)]
5686    pub priority: i32,
5687}
5688
5689// ── Heartbeat ────────────────────────────────────────────────────
5690
5691/// Heartbeat configuration for periodic health pings (`[heartbeat]` section).
5692#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5693#[allow(clippy::struct_excessive_bools)]
5694pub struct HeartbeatConfig {
5695    /// Enable periodic heartbeat pings. Default: `false`.
5696    pub enabled: bool,
5697    /// Interval in minutes between heartbeat pings. Default: `5`.
5698    #[serde(default = "default_heartbeat_interval")]
5699    pub interval_minutes: u32,
5700    /// Enable two-phase heartbeat: Phase 1 asks LLM whether to run, Phase 2
5701    /// executes only when the LLM decides there is work to do. Saves API cost
5702    /// during quiet periods. Default: `true`.
5703    #[serde(default = "default_two_phase")]
5704    pub two_phase: bool,
5705    /// Optional fallback task text when `HEARTBEAT.md` has no task entries.
5706    #[serde(default)]
5707    pub message: Option<String>,
5708    /// Optional delivery channel for heartbeat output (for example: `telegram`).
5709    /// When omitted, auto-selects the first configured channel.
5710    #[serde(default, alias = "channel")]
5711    pub target: Option<String>,
5712    /// Optional delivery recipient/chat identifier (required when `target` is
5713    /// explicitly set).
5714    #[serde(default, alias = "recipient")]
5715    pub to: Option<String>,
5716    /// Enable adaptive intervals that back off on failures and speed up for
5717    /// high-priority tasks. Default: `false`.
5718    #[serde(default)]
5719    pub adaptive: bool,
5720    /// Minimum interval in minutes when adaptive mode is enabled. Default: `5`.
5721    #[serde(default = "default_heartbeat_min_interval")]
5722    pub min_interval_minutes: u32,
5723    /// Maximum interval in minutes when adaptive mode backs off. Default: `120`.
5724    #[serde(default = "default_heartbeat_max_interval")]
5725    pub max_interval_minutes: u32,
5726    /// Dead-man's switch timeout in minutes. If the heartbeat has not ticked
5727    /// within this window, an alert is sent. `0` disables. Default: `0`.
5728    #[serde(default)]
5729    pub deadman_timeout_minutes: u32,
5730    /// Channel for dead-man's switch alerts (e.g. `telegram`). Falls back to
5731    /// the heartbeat delivery channel.
5732    #[serde(default)]
5733    pub deadman_channel: Option<String>,
5734    /// Recipient for dead-man's switch alerts. Falls back to `to`.
5735    #[serde(default)]
5736    pub deadman_to: Option<String>,
5737    /// Maximum number of heartbeat run history records to retain. Default: `100`.
5738    #[serde(default = "default_heartbeat_max_run_history")]
5739    pub max_run_history: u32,
5740    /// Load the channel session history before each heartbeat task execution so
5741    /// the LLM has conversational context. Default: `false`.
5742    ///
5743    /// When `true`, the session file for the configured `target`/`to` is passed
5744    /// to the agent as `session_state_file`, giving it access to the recent
5745    /// conversation history — just as if the user had sent a message.
5746    #[serde(default)]
5747    pub load_session_context: bool,
5748}
5749
5750fn default_heartbeat_interval() -> u32 {
5751    30
5752}
5753
5754fn default_two_phase() -> bool {
5755    true
5756}
5757
5758fn default_heartbeat_min_interval() -> u32 {
5759    5
5760}
5761
5762fn default_heartbeat_max_interval() -> u32 {
5763    120
5764}
5765
5766fn default_heartbeat_max_run_history() -> u32 {
5767    100
5768}
5769
5770impl Default for HeartbeatConfig {
5771    fn default() -> Self {
5772        Self {
5773            enabled: false,
5774            interval_minutes: default_heartbeat_interval(),
5775            two_phase: true,
5776            message: None,
5777            target: None,
5778            to: None,
5779            adaptive: false,
5780            min_interval_minutes: default_heartbeat_min_interval(),
5781            max_interval_minutes: default_heartbeat_max_interval(),
5782            deadman_timeout_minutes: 0,
5783            deadman_channel: None,
5784            deadman_to: None,
5785            max_run_history: default_heartbeat_max_run_history(),
5786            load_session_context: false,
5787        }
5788    }
5789}
5790
5791// ── Cron ────────────────────────────────────────────────────────
5792
5793/// Cron job configuration (`[cron]` section).
5794#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5795pub struct CronConfig {
5796    /// Enable the cron subsystem. Default: `true`.
5797    #[serde(default = "default_true")]
5798    pub enabled: bool,
5799    /// Run all overdue jobs at scheduler startup. Default: `true`.
5800    ///
5801    /// When the machine boots late or the daemon restarts, jobs whose
5802    /// `next_run` is in the past are considered "missed". With this
5803    /// option enabled the scheduler fires them once before entering
5804    /// the normal polling loop. Disable if you prefer missed jobs to
5805    /// simply wait for their next scheduled occurrence.
5806    #[serde(default = "default_true")]
5807    pub catch_up_on_startup: bool,
5808    /// Maximum number of historical cron run records to retain. Default: `50`.
5809    #[serde(default = "default_max_run_history")]
5810    pub max_run_history: u32,
5811    /// Declarative cron job definitions (`[[cron.jobs]]`).
5812    ///
5813    /// Jobs declared here are synced into the database at scheduler startup.
5814    /// They use `source = "declarative"` to distinguish them from jobs
5815    /// created imperatively via CLI or API. Declarative config takes
5816    /// precedence on each sync: if the config changes, the DB is updated
5817    /// to match. Imperative jobs are never deleted by the sync process.
5818    #[serde(default)]
5819    pub jobs: Vec<CronJobDecl>,
5820}
5821
5822/// A declarative cron job definition for the `[[cron.jobs]]` config array.
5823#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5824pub struct CronJobDecl {
5825    /// Stable identifier used for merge semantics across syncs.
5826    pub id: String,
5827    /// Human-readable name.
5828    #[serde(default)]
5829    pub name: Option<String>,
5830    /// Job type: `"shell"` (default) or `"agent"`.
5831    #[serde(default = "default_job_type_decl")]
5832    pub job_type: String,
5833    /// Schedule for the job.
5834    pub schedule: CronScheduleDecl,
5835    /// Shell command to run (required when `job_type = "shell"`).
5836    #[serde(default)]
5837    pub command: Option<String>,
5838    /// Agent prompt (required when `job_type = "agent"`).
5839    #[serde(default)]
5840    pub prompt: Option<String>,
5841    /// Whether the job is enabled. Default: `true`.
5842    #[serde(default = "default_true")]
5843    pub enabled: bool,
5844    /// Model override for agent jobs.
5845    #[serde(default)]
5846    pub model: Option<String>,
5847    /// Allowlist of tool names for agent jobs.
5848    #[serde(default)]
5849    pub allowed_tools: Option<Vec<String>>,
5850    /// Session target: `"isolated"` (default) or `"main"`.
5851    #[serde(default)]
5852    pub session_target: Option<String>,
5853    /// Delivery configuration.
5854    #[serde(default)]
5855    pub delivery: Option<DeliveryConfigDecl>,
5856}
5857
5858/// Schedule variant for declarative cron jobs.
5859#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5860#[serde(tag = "kind", rename_all = "lowercase")]
5861pub enum CronScheduleDecl {
5862    /// Classic cron expression.
5863    Cron {
5864        expr: String,
5865        #[serde(default)]
5866        tz: Option<String>,
5867    },
5868    /// Interval in milliseconds.
5869    Every { every_ms: u64 },
5870    /// One-shot at an RFC 3339 timestamp.
5871    At { at: String },
5872}
5873
5874/// Delivery configuration for declarative cron jobs.
5875#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5876pub struct DeliveryConfigDecl {
5877    /// Delivery mode: `"none"` or `"announce"`.
5878    #[serde(default = "default_delivery_mode")]
5879    pub mode: String,
5880    /// Channel name (e.g. `"telegram"`, `"discord"`).
5881    #[serde(default)]
5882    pub channel: Option<String>,
5883    /// Target/recipient identifier.
5884    #[serde(default)]
5885    pub to: Option<String>,
5886    /// Best-effort delivery. Default: `true`.
5887    #[serde(default = "default_true")]
5888    pub best_effort: bool,
5889}
5890
5891fn default_job_type_decl() -> String {
5892    "shell".to_string()
5893}
5894
5895fn default_delivery_mode() -> String {
5896    "none".to_string()
5897}
5898
5899fn default_max_run_history() -> u32 {
5900    50
5901}
5902
5903impl Default for CronConfig {
5904    fn default() -> Self {
5905        Self {
5906            enabled: true,
5907            catch_up_on_startup: true,
5908            max_run_history: default_max_run_history(),
5909            jobs: Vec::new(),
5910        }
5911    }
5912}
5913
5914// ── Tunnel ──────────────────────────────────────────────────────
5915
5916/// Tunnel configuration for exposing the gateway publicly (`[tunnel]` section).
5917///
5918/// Supported providers: `"none"` (default), `"cloudflare"`, `"tailscale"`, `"ngrok"`, `"openvpn"`, `"pinggy"`, `"custom"`.
5919#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5920pub struct TunnelConfig {
5921    /// Tunnel provider: `"none"`, `"cloudflare"`, `"tailscale"`, `"ngrok"`, `"openvpn"`, `"pinggy"`, or `"custom"`. Default: `"none"`.
5922    pub provider: String,
5923
5924    /// Cloudflare Tunnel configuration (used when `provider = "cloudflare"`).
5925    #[serde(default)]
5926    pub cloudflare: Option<CloudflareTunnelConfig>,
5927
5928    /// Tailscale Funnel/Serve configuration (used when `provider = "tailscale"`).
5929    #[serde(default)]
5930    pub tailscale: Option<TailscaleTunnelConfig>,
5931
5932    /// ngrok tunnel configuration (used when `provider = "ngrok"`).
5933    #[serde(default)]
5934    pub ngrok: Option<NgrokTunnelConfig>,
5935
5936    /// OpenVPN tunnel configuration (used when `provider = "openvpn"`).
5937    #[serde(default)]
5938    pub openvpn: Option<OpenVpnTunnelConfig>,
5939
5940    /// Custom tunnel command configuration (used when `provider = "custom"`).
5941    #[serde(default)]
5942    pub custom: Option<CustomTunnelConfig>,
5943
5944    /// Pinggy tunnel configuration (used when `provider = "pinggy"`).
5945    #[serde(default)]
5946    pub pinggy: Option<PinggyTunnelConfig>,
5947}
5948
5949impl Default for TunnelConfig {
5950    fn default() -> Self {
5951        Self {
5952            provider: "none".into(),
5953            cloudflare: None,
5954            tailscale: None,
5955            ngrok: None,
5956            openvpn: None,
5957            custom: None,
5958            pinggy: None,
5959        }
5960    }
5961}
5962
5963#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5964pub struct CloudflareTunnelConfig {
5965    /// Cloudflare Tunnel token (from Zero Trust dashboard)
5966    pub token: String,
5967}
5968
5969#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5970pub struct TailscaleTunnelConfig {
5971    /// Use Tailscale Funnel (public internet) vs Serve (tailnet only)
5972    #[serde(default)]
5973    pub funnel: bool,
5974    /// Optional hostname override
5975    pub hostname: Option<String>,
5976}
5977
5978#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5979pub struct NgrokTunnelConfig {
5980    /// ngrok auth token
5981    pub auth_token: String,
5982    /// Optional custom domain
5983    pub domain: Option<String>,
5984}
5985
5986/// OpenVPN tunnel configuration (`[tunnel.openvpn]`).
5987///
5988/// Required when `tunnel.provider = "openvpn"`. Omitting this section entirely
5989/// preserves previous behavior. Setting `tunnel.provider = "none"` (or removing
5990/// the `[tunnel.openvpn]` block) cleanly reverts to no-tunnel mode.
5991///
5992/// Defaults: `connect_timeout_secs = 30`.
5993#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
5994pub struct OpenVpnTunnelConfig {
5995    /// Path to `.ovpn` configuration file (must not be empty).
5996    pub config_file: String,
5997    /// Optional path to auth credentials file (`--auth-user-pass`).
5998    #[serde(default)]
5999    pub auth_file: Option<String>,
6000    /// Advertised address once VPN is connected (e.g., `"10.8.0.2:42617"`).
6001    /// When omitted the tunnel falls back to `http://{local_host}:{local_port}`.
6002    #[serde(default)]
6003    pub advertise_address: Option<String>,
6004    /// Connection timeout in seconds (default: 30, must be > 0).
6005    #[serde(default = "default_openvpn_timeout")]
6006    pub connect_timeout_secs: u64,
6007    /// Extra openvpn CLI arguments forwarded verbatim.
6008    #[serde(default)]
6009    pub extra_args: Vec<String>,
6010}
6011
6012fn default_openvpn_timeout() -> u64 {
6013    30
6014}
6015
6016#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6017pub struct PinggyTunnelConfig {
6018    /// Pinggy access token (optional — free tier works without one).
6019    #[serde(default)]
6020    pub token: Option<String>,
6021    /// Server region: `"us"` (USA), `"eu"` (Europe), `"ap"` (Asia), `"br"` (South America), `"au"` (Australia), or omit for auto.
6022    #[serde(default)]
6023    pub region: Option<String>,
6024}
6025
6026#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6027pub struct CustomTunnelConfig {
6028    /// Command template to start the tunnel. Use {port} and {host} placeholders.
6029    /// Example: "bore local {port} --to bore.pub"
6030    pub start_command: String,
6031    /// Optional URL to check tunnel health
6032    pub health_url: Option<String>,
6033    /// Optional regex to extract public URL from command stdout
6034    pub url_pattern: Option<String>,
6035}
6036
6037// ── Channels ─────────────────────────────────────────────────────
6038
6039struct ConfigWrapper<T: ChannelConfig>(std::marker::PhantomData<T>);
6040
6041impl<T: ChannelConfig> ConfigWrapper<T> {
6042    fn new(_: Option<&T>) -> Self {
6043        Self(std::marker::PhantomData)
6044    }
6045}
6046
6047impl<T: ChannelConfig> crate::config::traits::ConfigHandle for ConfigWrapper<T> {
6048    fn name(&self) -> &'static str {
6049        T::name()
6050    }
6051    fn desc(&self) -> &'static str {
6052        T::desc()
6053    }
6054}
6055
6056/// Top-level channel configurations (`[channels_config]` section).
6057///
6058/// Each channel sub-section (e.g. `telegram`, `discord`) is optional;
6059/// setting it to `Some(...)` enables that channel.
6060#[allow(clippy::struct_excessive_bools)]
6061#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6062pub struct ChannelsConfig {
6063    /// Enable the CLI interactive channel. Default: `true`.
6064    #[serde(default = "default_true")]
6065    pub cli: bool,
6066    /// Telegram bot channel configuration.
6067    pub telegram: Option<TelegramConfig>,
6068    /// Discord bot channel configuration.
6069    pub discord: Option<DiscordConfig>,
6070    /// Discord history channel — logs ALL messages and forwards @mentions to agent.
6071    pub discord_history: Option<DiscordHistoryConfig>,
6072    /// Slack bot channel configuration.
6073    pub slack: Option<SlackConfig>,
6074    /// Mattermost bot channel configuration.
6075    pub mattermost: Option<MattermostConfig>,
6076    /// Webhook channel configuration.
6077    pub webhook: Option<WebhookConfig>,
6078    /// iMessage channel configuration (macOS only).
6079    pub imessage: Option<IMessageConfig>,
6080    /// Matrix channel configuration.
6081    pub matrix: Option<MatrixConfig>,
6082    /// Signal channel configuration.
6083    pub signal: Option<SignalConfig>,
6084    /// WhatsApp channel configuration (Cloud API or Web mode).
6085    pub whatsapp: Option<WhatsAppConfig>,
6086    /// Linq Partner API channel configuration.
6087    pub linq: Option<LinqConfig>,
6088    /// WATI WhatsApp Business API channel configuration.
6089    pub wati: Option<WatiConfig>,
6090    /// Nextcloud Talk bot channel configuration.
6091    pub nextcloud_talk: Option<NextcloudTalkConfig>,
6092    /// Email channel configuration.
6093    pub email: Option<crate::channels::email_channel::EmailConfig>,
6094    /// Gmail Pub/Sub push notification channel configuration.
6095    pub gmail_push: Option<crate::channels::gmail_push::GmailPushConfig>,
6096    /// IRC channel configuration.
6097    pub irc: Option<IrcConfig>,
6098    /// Lark channel configuration.
6099    pub lark: Option<LarkConfig>,
6100    /// Feishu channel configuration.
6101    pub feishu: Option<FeishuConfig>,
6102    /// DingTalk channel configuration.
6103    pub dingtalk: Option<DingTalkConfig>,
6104    /// WeCom (WeChat Enterprise) Bot Webhook channel configuration.
6105    pub wecom: Option<WeComConfig>,
6106    /// QQ Official Bot channel configuration.
6107    pub qq: Option<QQConfig>,
6108    /// X/Twitter channel configuration.
6109    pub twitter: Option<TwitterConfig>,
6110    /// Mochat customer service channel configuration.
6111    pub mochat: Option<MochatConfig>,
6112    #[cfg(feature = "channel-nostr")]
6113    pub nostr: Option<NostrConfig>,
6114    /// ClawdTalk voice channel configuration.
6115    pub clawdtalk: Option<crate::channels::ClawdTalkConfig>,
6116    /// Reddit channel configuration (OAuth2 bot).
6117    pub reddit: Option<RedditConfig>,
6118    /// Bluesky channel configuration (AT Protocol).
6119    pub bluesky: Option<BlueskyConfig>,
6120    /// Voice call channel configuration (Twilio/Telnyx/Plivo).
6121    pub voice_call: Option<crate::channels::voice_call::VoiceCallConfig>,
6122    /// Voice wake word detection channel configuration.
6123    #[cfg(feature = "voice-wake")]
6124    pub voice_wake: Option<VoiceWakeConfig>,
6125    /// Base timeout in seconds for processing a single channel message (LLM + tools).
6126    /// Runtime uses this as a per-turn budget that scales with tool-loop depth
6127    /// (up to 4x, capped) so one slow/retried model call does not consume the
6128    /// entire conversation budget.
6129    /// Default: 300s for on-device LLMs (Ollama) which are slower than cloud APIs.
6130    #[serde(default = "default_channel_message_timeout_secs")]
6131    pub message_timeout_secs: u64,
6132    /// Whether to add acknowledgement reactions (👀 on receipt, ✅/⚠️ on
6133    /// completion) to incoming channel messages. Default: `true`.
6134    #[serde(default = "default_true")]
6135    pub ack_reactions: bool,
6136    /// Whether to send tool-call notification messages (e.g. `🔧 web_search_tool: …`)
6137    /// to channel users. When `false`, tool calls are still logged server-side but
6138    /// not forwarded as individual channel messages. Default: `false`.
6139    #[serde(default = "default_false")]
6140    pub show_tool_calls: bool,
6141    /// Persist channel conversation history to JSONL files so sessions survive
6142    /// daemon restarts. Files are stored in `{workspace}/sessions/`. Default: `true`.
6143    #[serde(default = "default_true")]
6144    pub session_persistence: bool,
6145    /// Session persistence backend: `"jsonl"` (legacy) or `"sqlite"` (new default).
6146    /// SQLite provides FTS5 search, metadata tracking, and TTL cleanup.
6147    #[serde(default = "default_session_backend")]
6148    pub session_backend: String,
6149    /// Auto-archive stale sessions older than this many hours. `0` disables. Default: `0`.
6150    #[serde(default)]
6151    pub session_ttl_hours: u32,
6152    /// Inbound message debounce window in milliseconds. When a sender fires
6153    /// multiple messages within this window, they are accumulated and dispatched
6154    /// as a single concatenated message. `0` disables debouncing. Default: `0`.
6155    #[serde(default)]
6156    pub debounce_ms: u64,
6157}
6158
6159impl ChannelsConfig {
6160    /// get channels' metadata and `.is_some()`, except webhook
6161    #[rustfmt::skip]
6162    pub fn channels_except_webhook(&self) -> Vec<(Box<dyn super::traits::ConfigHandle>, bool)> {
6163        vec![
6164            (
6165                Box::new(ConfigWrapper::new(self.telegram.as_ref())),
6166                self.telegram.is_some(),
6167            ),
6168            (
6169                Box::new(ConfigWrapper::new(self.discord.as_ref())),
6170                self.discord.is_some(),
6171            ),
6172            (
6173                Box::new(ConfigWrapper::new(self.slack.as_ref())),
6174                self.slack.is_some(),
6175            ),
6176            (
6177                Box::new(ConfigWrapper::new(self.mattermost.as_ref())),
6178                self.mattermost.is_some(),
6179            ),
6180            (
6181                Box::new(ConfigWrapper::new(self.imessage.as_ref())),
6182                self.imessage.is_some(),
6183            ),
6184            (
6185                Box::new(ConfigWrapper::new(self.matrix.as_ref())),
6186                self.matrix.is_some(),
6187            ),
6188            (
6189                Box::new(ConfigWrapper::new(self.signal.as_ref())),
6190                self.signal.is_some(),
6191            ),
6192            (
6193                Box::new(ConfigWrapper::new(self.whatsapp.as_ref())),
6194                self.whatsapp.is_some(),
6195            ),
6196            (
6197                Box::new(ConfigWrapper::new(self.linq.as_ref())),
6198                self.linq.is_some(),
6199            ),
6200            (
6201                Box::new(ConfigWrapper::new(self.wati.as_ref())),
6202                self.wati.is_some(),
6203            ),
6204            (
6205                Box::new(ConfigWrapper::new(self.nextcloud_talk.as_ref())),
6206                self.nextcloud_talk.is_some(),
6207            ),
6208            (
6209                Box::new(ConfigWrapper::new(self.email.as_ref())),
6210                self.email.is_some(),
6211            ),
6212            (
6213                Box::new(ConfigWrapper::new(self.gmail_push.as_ref())),
6214                self.gmail_push.is_some(),
6215            ),
6216            (
6217                Box::new(ConfigWrapper::new(self.irc.as_ref())),
6218                self.irc.is_some()
6219            ),
6220            (
6221                Box::new(ConfigWrapper::new(self.lark.as_ref())),
6222                self.lark.is_some(),
6223            ),
6224            (
6225                Box::new(ConfigWrapper::new(self.feishu.as_ref())),
6226                self.feishu.is_some(),
6227            ),
6228            (
6229                Box::new(ConfigWrapper::new(self.dingtalk.as_ref())),
6230                self.dingtalk.is_some(),
6231            ),
6232            (
6233                Box::new(ConfigWrapper::new(self.wecom.as_ref())),
6234                self.wecom.is_some(),
6235            ),
6236            (
6237                Box::new(ConfigWrapper::new(self.qq.as_ref())),
6238                self.qq.is_some()
6239            ),
6240            #[cfg(feature = "channel-nostr")]
6241            (
6242                Box::new(ConfigWrapper::new(self.nostr.as_ref())),
6243                self.nostr.is_some(),
6244            ),
6245            (
6246                Box::new(ConfigWrapper::new(self.clawdtalk.as_ref())),
6247                self.clawdtalk.is_some(),
6248            ),
6249            (
6250                Box::new(ConfigWrapper::new(self.reddit.as_ref())),
6251                self.reddit.is_some(),
6252            ),
6253            (
6254                Box::new(ConfigWrapper::new(self.bluesky.as_ref())),
6255                self.bluesky.is_some(),
6256            ),
6257            #[cfg(feature = "voice-wake")]
6258            (
6259                Box::new(ConfigWrapper::new(self.voice_wake.as_ref())),
6260                self.voice_wake.is_some(),
6261            ),
6262        ]
6263    }
6264
6265    pub fn channels(&self) -> Vec<(Box<dyn super::traits::ConfigHandle>, bool)> {
6266        let mut ret = self.channels_except_webhook();
6267        ret.push((
6268            Box::new(ConfigWrapper::new(self.webhook.as_ref())),
6269            self.webhook.is_some(),
6270        ));
6271        ret
6272    }
6273}
6274
6275fn default_channel_message_timeout_secs() -> u64 {
6276    300
6277}
6278
6279fn default_session_backend() -> String {
6280    "sqlite".into()
6281}
6282
6283impl Default for ChannelsConfig {
6284    fn default() -> Self {
6285        Self {
6286            cli: true,
6287            telegram: None,
6288            discord: None,
6289            discord_history: None,
6290            slack: None,
6291            mattermost: None,
6292            webhook: None,
6293            imessage: None,
6294            matrix: None,
6295            signal: None,
6296            whatsapp: None,
6297            linq: None,
6298            wati: None,
6299            nextcloud_talk: None,
6300            email: None,
6301            gmail_push: None,
6302            irc: None,
6303            lark: None,
6304            feishu: None,
6305            dingtalk: None,
6306            wecom: None,
6307            qq: None,
6308            twitter: None,
6309            mochat: None,
6310            #[cfg(feature = "channel-nostr")]
6311            nostr: None,
6312            clawdtalk: None,
6313            reddit: None,
6314            bluesky: None,
6315            voice_call: None,
6316            #[cfg(feature = "voice-wake")]
6317            voice_wake: None,
6318            message_timeout_secs: default_channel_message_timeout_secs(),
6319            ack_reactions: true,
6320            show_tool_calls: false,
6321            session_persistence: true,
6322            session_backend: default_session_backend(),
6323            session_ttl_hours: 0,
6324            debounce_ms: 0,
6325        }
6326    }
6327}
6328
6329/// Streaming mode for channels that support progressive message updates.
6330#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
6331#[serde(rename_all = "lowercase")]
6332pub enum StreamMode {
6333    /// No streaming -- send the complete response as a single message (default).
6334    #[default]
6335    Off,
6336    /// Update a draft message with every flush interval.
6337    Partial,
6338    /// Send the response as multiple separate messages at paragraph boundaries.
6339    #[serde(rename = "multi_message")]
6340    MultiMessage,
6341}
6342
6343fn default_draft_update_interval_ms() -> u64 {
6344    1000
6345}
6346
6347fn default_multi_message_delay_ms() -> u64 {
6348    800
6349}
6350
6351fn default_matrix_draft_update_interval_ms() -> u64 {
6352    1500
6353}
6354
6355/// Telegram bot channel configuration.
6356#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6357pub struct TelegramConfig {
6358    /// Telegram Bot API token (from @BotFather).
6359    pub bot_token: String,
6360    /// Allowed Telegram user IDs or usernames. Empty = deny all.
6361    pub allowed_users: Vec<String>,
6362    /// Streaming mode for progressive response delivery via message edits.
6363    #[serde(default)]
6364    pub stream_mode: StreamMode,
6365    /// Minimum interval (ms) between draft message edits to avoid rate limits.
6366    #[serde(default = "default_draft_update_interval_ms")]
6367    pub draft_update_interval_ms: u64,
6368    /// When true, a newer Telegram message from the same sender in the same chat
6369    /// cancels the in-flight request and starts a fresh response with preserved history.
6370    #[serde(default)]
6371    pub interrupt_on_new_message: bool,
6372    /// When true, only respond to messages that @-mention the bot in groups.
6373    /// Direct messages are always processed.
6374    #[serde(default)]
6375    pub mention_only: bool,
6376    /// Override for the top-level `ack_reactions` setting. When `None`, the
6377    /// channel falls back to `[channels_config].ack_reactions`. When set
6378    /// explicitly, it takes precedence.
6379    #[serde(default)]
6380    pub ack_reactions: Option<bool>,
6381    /// Per-channel proxy URL (http, https, socks5, socks5h).
6382    /// Overrides the global `[proxy]` setting for this channel only.
6383    #[serde(default)]
6384    pub proxy_url: Option<String>,
6385    /// Telegram chat ID for workflow notifications and human-approval prompts.
6386    /// When set, workflow `notify` steps targeting "telegram" post here, and
6387    /// approval replies are scoped to this chat via `reply_to_message_id`.
6388    #[serde(default)]
6389    pub notification_chat_id: Option<String>,
6390}
6391
6392impl ChannelConfig for TelegramConfig {
6393    fn name() -> &'static str {
6394        "Telegram"
6395    }
6396    fn desc() -> &'static str {
6397        "connect your bot"
6398    }
6399}
6400
6401/// Discord bot channel configuration.
6402#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6403pub struct DiscordConfig {
6404    /// Discord bot token (from Discord Developer Portal).
6405    pub bot_token: String,
6406    /// Optional guild (server) ID to restrict the bot to a single guild.
6407    pub guild_id: Option<String>,
6408    /// Allowed Discord user IDs. Empty = deny all.
6409    #[serde(default)]
6410    pub allowed_users: Vec<String>,
6411    /// When true, process messages from other bots (not just humans).
6412    /// The bot still ignores its own messages to prevent feedback loops.
6413    #[serde(default)]
6414    pub listen_to_bots: bool,
6415    /// When true, a newer Discord message from the same sender in the same channel
6416    /// cancels the in-flight request and starts a fresh response with preserved history.
6417    #[serde(default)]
6418    pub interrupt_on_new_message: bool,
6419    /// When true, only respond to messages that @-mention the bot.
6420    /// Other messages in the guild are silently ignored.
6421    #[serde(default)]
6422    pub mention_only: bool,
6423    /// Discord channel ID for workflow notifications and system alerts.
6424    /// When set, workflow `notify` steps targeting "discord" post here.
6425    #[serde(default)]
6426    pub notification_channel_id: Option<String>,
6427    /// Per-channel proxy URL (http, https, socks5, socks5h).
6428    /// Overrides the global `[proxy]` setting for this channel only.
6429    #[serde(default)]
6430    pub proxy_url: Option<String>,
6431    /// Streaming mode for progressive response delivery.
6432    /// `off` (default): single message. `partial`: editable draft updates.
6433    /// `multi_message`: split response into separate messages at paragraph boundaries.
6434    #[serde(default)]
6435    pub stream_mode: StreamMode,
6436    /// Minimum interval (ms) between draft message edits to avoid rate limits.
6437    /// Only used when `stream_mode = "partial"`.
6438    #[serde(default = "default_draft_update_interval_ms")]
6439    pub draft_update_interval_ms: u64,
6440    /// Delay (ms) between sending each message chunk in multi-message mode.
6441    /// Only used when `stream_mode = "multi_message"`.
6442    #[serde(default = "default_multi_message_delay_ms")]
6443    pub multi_message_delay_ms: u64,
6444}
6445
6446impl ChannelConfig for DiscordConfig {
6447    fn name() -> &'static str {
6448        "Discord"
6449    }
6450    fn desc() -> &'static str {
6451        "connect your bot"
6452    }
6453}
6454
6455/// Discord history channel — logs ALL messages to discord.db and forwards @mentions to the agent.
6456#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6457pub struct DiscordHistoryConfig {
6458    /// Discord bot token (from Discord Developer Portal).
6459    pub bot_token: String,
6460    /// Optional guild (server) ID to restrict logging to a single guild.
6461    pub guild_id: Option<String>,
6462    /// Allowed Discord user IDs. Empty = allow all (open logging).
6463    #[serde(default)]
6464    pub allowed_users: Vec<String>,
6465    /// Discord channel IDs to watch. Empty = watch all channels.
6466    #[serde(default)]
6467    pub channel_ids: Vec<String>,
6468    /// When true (default), store Direct Messages in discord.db.
6469    #[serde(default = "default_true")]
6470    pub store_dms: bool,
6471    /// When true (default), respond to @mentions in Direct Messages.
6472    #[serde(default = "default_true")]
6473    pub respond_to_dms: bool,
6474    /// Per-channel proxy URL (http, https, socks5, socks5h).
6475    #[serde(default)]
6476    pub proxy_url: Option<String>,
6477}
6478
6479impl ChannelConfig for DiscordHistoryConfig {
6480    fn name() -> &'static str {
6481        "Discord History"
6482    }
6483    fn desc() -> &'static str {
6484        "log all messages and forward @mentions"
6485    }
6486}
6487
6488/// Slack bot channel configuration.
6489#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6490#[allow(clippy::struct_excessive_bools)]
6491pub struct SlackConfig {
6492    /// Slack bot OAuth token (xoxb-...).
6493    pub bot_token: String,
6494    /// Slack app-level token for Socket Mode (xapp-...).
6495    pub app_token: Option<String>,
6496    /// Optional channel ID to restrict the bot to a single channel.
6497    /// Omit (or set `"*"`) to listen across all accessible channels.
6498    pub channel_id: Option<String>,
6499    /// Optional explicit list of channel IDs to watch.
6500    /// When set, this takes precedence over `channel_id`.
6501    #[serde(default)]
6502    pub channel_ids: Vec<String>,
6503    /// Allowed Slack user IDs. Empty = deny all.
6504    #[serde(default)]
6505    pub allowed_users: Vec<String>,
6506    /// When true, a newer Slack message from the same sender in the same channel
6507    /// cancels the in-flight request and starts a fresh response with preserved history.
6508    #[serde(default)]
6509    pub interrupt_on_new_message: bool,
6510    /// When true (default), replies stay in the originating Slack thread.
6511    /// When false, replies go to the channel root instead.
6512    #[serde(default)]
6513    pub thread_replies: Option<bool>,
6514    /// When true, only respond to messages that @-mention the bot in groups.
6515    /// Direct messages remain allowed.
6516    #[serde(default)]
6517    pub mention_only: bool,
6518    /// Use the newer Slack `markdown` block type (12 000 char limit, richer formatting).
6519    /// Defaults to false (uses universally supported `section` blocks with `mrkdwn`).
6520    /// Enable this only if your Slack workspace supports the `markdown` block type.
6521    #[serde(default)]
6522    pub use_markdown_blocks: bool,
6523    /// Per-channel proxy URL (http, https, socks5, socks5h).
6524    /// Overrides the global `[proxy]` setting for this channel only.
6525    #[serde(default)]
6526    pub proxy_url: Option<String>,
6527    /// Enable progressive draft message streaming via `chat.update`.
6528    #[serde(default)]
6529    pub stream_drafts: bool,
6530    /// Minimum interval (ms) between draft message edits to avoid Slack rate limits.
6531    #[serde(default = "default_slack_draft_update_interval_ms")]
6532    pub draft_update_interval_ms: u64,
6533    /// Emoji reaction name (without colons) that cancels an in-flight request.
6534    /// For example, `"x"` means reacting with `:x:` cancels the task.
6535    /// Leave unset to disable reaction-based cancellation.
6536    #[serde(default)]
6537    pub cancel_reaction: Option<String>,
6538    /// Slack channel ID for workflow notifications and human-approval prompts.
6539    /// When set, workflow `notify` steps targeting "slack" post here, and
6540    /// approval replies are scoped to the posted message's `thread_ts`.
6541    #[serde(default)]
6542    pub notification_channel_id: Option<String>,
6543}
6544
6545fn default_slack_draft_update_interval_ms() -> u64 {
6546    1200
6547}
6548
6549impl ChannelConfig for SlackConfig {
6550    fn name() -> &'static str {
6551        "Slack"
6552    }
6553    fn desc() -> &'static str {
6554        "connect your bot"
6555    }
6556}
6557
6558/// Mattermost bot channel configuration.
6559#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6560pub struct MattermostConfig {
6561    /// Mattermost server URL (e.g. `"https://mattermost.example.com"`).
6562    pub url: String,
6563    /// Mattermost bot access token.
6564    pub bot_token: String,
6565    /// Optional channel ID to restrict the bot to a single channel.
6566    pub channel_id: Option<String>,
6567    /// Allowed Mattermost user IDs. Empty = deny all.
6568    #[serde(default)]
6569    pub allowed_users: Vec<String>,
6570    /// When true (default), replies thread on the original post.
6571    /// When false, replies go to the channel root.
6572    #[serde(default)]
6573    pub thread_replies: Option<bool>,
6574    /// When true, only respond to messages that @-mention the bot.
6575    /// Other messages in the channel are silently ignored.
6576    #[serde(default)]
6577    pub mention_only: Option<bool>,
6578    /// When true, a newer Mattermost message from the same sender in the same channel
6579    /// cancels the in-flight request and starts a fresh response with preserved history.
6580    #[serde(default)]
6581    pub interrupt_on_new_message: bool,
6582    /// Per-channel proxy URL (http, https, socks5, socks5h).
6583    /// Overrides the global `[proxy]` setting for this channel only.
6584    #[serde(default)]
6585    pub proxy_url: Option<String>,
6586}
6587
6588impl ChannelConfig for MattermostConfig {
6589    fn name() -> &'static str {
6590        "Mattermost"
6591    }
6592    fn desc() -> &'static str {
6593        "connect to your bot"
6594    }
6595}
6596
6597/// Webhook channel configuration.
6598///
6599/// Receives messages via HTTP POST and sends replies to a configurable outbound URL.
6600/// This is the "universal adapter" for any system that supports webhooks.
6601#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6602pub struct WebhookConfig {
6603    /// Port to listen on for incoming webhooks.
6604    pub port: u16,
6605    /// URL path to listen on (default: `/webhook`).
6606    #[serde(default)]
6607    pub listen_path: Option<String>,
6608    /// URL to POST/PUT outbound messages to.
6609    #[serde(default)]
6610    pub send_url: Option<String>,
6611    /// HTTP method for outbound messages (`POST` or `PUT`). Default: `POST`.
6612    #[serde(default)]
6613    pub send_method: Option<String>,
6614    /// Optional `Authorization` header value for outbound requests.
6615    #[serde(default)]
6616    pub auth_header: Option<String>,
6617    /// Optional shared secret for webhook signature verification (HMAC-SHA256).
6618    pub secret: Option<String>,
6619}
6620
6621impl ChannelConfig for WebhookConfig {
6622    fn name() -> &'static str {
6623        "Webhook"
6624    }
6625    fn desc() -> &'static str {
6626        "HTTP endpoint"
6627    }
6628}
6629
6630/// iMessage channel configuration (macOS only).
6631#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6632pub struct IMessageConfig {
6633    /// Allowed iMessage contacts (phone numbers or email addresses). Empty = deny all.
6634    pub allowed_contacts: Vec<String>,
6635}
6636
6637impl ChannelConfig for IMessageConfig {
6638    fn name() -> &'static str {
6639        "iMessage"
6640    }
6641    fn desc() -> &'static str {
6642        "macOS only"
6643    }
6644}
6645
6646/// Matrix channel configuration.
6647#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6648pub struct MatrixConfig {
6649    /// Matrix homeserver URL (e.g. `"https://matrix.org"`).
6650    pub homeserver: String,
6651    /// Matrix access token for the bot account.
6652    pub access_token: String,
6653    /// Optional Matrix user ID (e.g. `"@bot:matrix.org"`).
6654    #[serde(default)]
6655    pub user_id: Option<String>,
6656    /// Optional Matrix device ID.
6657    #[serde(default)]
6658    pub device_id: Option<String>,
6659    /// Matrix room ID to listen in (e.g. `"!abc123:matrix.org"`).
6660    pub room_id: String,
6661    /// Allowed Matrix user IDs. Empty = deny all.
6662    pub allowed_users: Vec<String>,
6663    /// Allowed Matrix room IDs or aliases. Empty = allow all rooms.
6664    /// Supports canonical room IDs (`!abc:server`) and aliases (`#room:server`).
6665    #[serde(default)]
6666    pub allowed_rooms: Vec<String>,
6667    /// Whether to interrupt an in-flight agent response when a new message arrives.
6668    #[serde(default)]
6669    pub interrupt_on_new_message: bool,
6670    /// Streaming mode for progressive response delivery.
6671    /// `"off"` (default): single message. `"partial"`: edit-in-place draft.
6672    /// `"multi_message"`: paragraph-split delivery.
6673    #[serde(default)]
6674    pub stream_mode: StreamMode,
6675    /// Minimum interval (ms) between draft message edits in Partial mode.
6676    #[serde(default = "default_matrix_draft_update_interval_ms")]
6677    pub draft_update_interval_ms: u64,
6678    /// Delay (ms) between sending each paragraph in MultiMessage mode.
6679    #[serde(default = "default_multi_message_delay_ms")]
6680    pub multi_message_delay_ms: u64,
6681    /// Optional Matrix recovery key for automatic E2EE key backup restore.
6682    /// When set, Construct recovers room keys and cross-signing secrets on startup.
6683    #[serde(default)]
6684    pub recovery_key: Option<String>,
6685}
6686
6687impl ChannelConfig for MatrixConfig {
6688    fn name() -> &'static str {
6689        "Matrix"
6690    }
6691    fn desc() -> &'static str {
6692        "self-hosted chat"
6693    }
6694}
6695
6696#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6697pub struct SignalConfig {
6698    /// Base URL for the signal-cli HTTP daemon (e.g. "http://127.0.0.1:8686").
6699    pub http_url: String,
6700    /// E.164 phone number of the signal-cli account (e.g. "+1234567890").
6701    pub account: String,
6702    /// Optional group ID to filter messages.
6703    /// - `None` or omitted: accept all messages (DMs and groups)
6704    /// - `"dm"`: only accept direct messages
6705    /// - Specific group ID: only accept messages from that group
6706    #[serde(default)]
6707    pub group_id: Option<String>,
6708    /// Allowed sender phone numbers (E.164) or "*" for all.
6709    #[serde(default)]
6710    pub allowed_from: Vec<String>,
6711    /// Skip messages that are attachment-only (no text body).
6712    #[serde(default)]
6713    pub ignore_attachments: bool,
6714    /// Skip incoming story messages.
6715    #[serde(default)]
6716    pub ignore_stories: bool,
6717    /// Per-channel proxy URL (http, https, socks5, socks5h).
6718    /// Overrides the global `[proxy]` setting for this channel only.
6719    #[serde(default)]
6720    pub proxy_url: Option<String>,
6721}
6722
6723impl ChannelConfig for SignalConfig {
6724    fn name() -> &'static str {
6725        "Signal"
6726    }
6727    fn desc() -> &'static str {
6728        "An open-source, encrypted messaging service"
6729    }
6730}
6731
6732/// WhatsApp Web usage mode.
6733///
6734/// `Personal` treats the account as a personal phone — the bot only responds to
6735/// incoming messages that pass the DM/group/self-chat policy filters.
6736/// `Business` (default) responds to all incoming messages, subject only to the
6737/// `allowed_numbers` allowlist.
6738#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
6739#[serde(rename_all = "snake_case")]
6740pub enum WhatsAppWebMode {
6741    /// Respond to all messages passing the allowlist (default).
6742    #[default]
6743    Business,
6744    /// Apply per-chat-type policies (dm_policy, group_policy, self_chat_mode).
6745    Personal,
6746}
6747
6748/// Policy for a particular WhatsApp chat type (DMs or groups) when
6749/// `mode = "personal"`.
6750#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, PartialEq)]
6751#[serde(rename_all = "snake_case")]
6752pub enum WhatsAppChatPolicy {
6753    /// Only respond to senders on the `allowed_numbers` list (default).
6754    #[default]
6755    Allowlist,
6756    /// Ignore all messages in this chat type.
6757    Ignore,
6758    /// Respond to every message regardless of allowlist.
6759    All,
6760}
6761
6762/// WhatsApp channel configuration (Cloud API or Web mode).
6763///
6764/// Set `phone_number_id` for Cloud API mode, or `session_path` for Web mode.
6765#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6766pub struct WhatsAppConfig {
6767    /// Access token from Meta Business Suite (Cloud API mode)
6768    #[serde(default)]
6769    pub access_token: Option<String>,
6770    /// Phone number ID from Meta Business API (Cloud API mode)
6771    #[serde(default)]
6772    pub phone_number_id: Option<String>,
6773    /// Webhook verify token (you define this, Meta sends it back for verification)
6774    /// Only used in Cloud API mode
6775    #[serde(default)]
6776    pub verify_token: Option<String>,
6777    /// App secret from Meta Business Suite (for webhook signature verification)
6778    /// Can also be set via `CONSTRUCT_WHATSAPP_APP_SECRET` environment variable
6779    /// Only used in Cloud API mode
6780    #[serde(default)]
6781    pub app_secret: Option<String>,
6782    /// Session database path for WhatsApp Web client (Web mode)
6783    /// When set, enables native WhatsApp Web mode with wa-rs
6784    #[serde(default)]
6785    pub session_path: Option<String>,
6786    /// Phone number for pair code linking (Web mode, optional)
6787    /// Format: country code + number (e.g., "15551234567")
6788    /// If not set, QR code pairing will be used
6789    #[serde(default)]
6790    pub pair_phone: Option<String>,
6791    /// Custom pair code for linking (Web mode, optional)
6792    /// Leave empty to let WhatsApp generate one
6793    #[serde(default)]
6794    pub pair_code: Option<String>,
6795    /// Allowed phone numbers (E.164 format: +1234567890) or "*" for all
6796    #[serde(default)]
6797    pub allowed_numbers: Vec<String>,
6798    /// Usage mode for WhatsApp Web: "business" (default) or "personal".
6799    /// In personal mode the bot applies dm_policy, group_policy, and
6800    /// self_chat_mode to decide which chats to respond in.
6801    #[serde(default)]
6802    pub mode: WhatsAppWebMode,
6803    /// Policy for direct messages when mode = "personal".
6804    /// "allowlist" (default) | "ignore" | "all".
6805    #[serde(default)]
6806    pub dm_policy: WhatsAppChatPolicy,
6807    /// Policy for group chats when mode = "personal".
6808    /// "allowlist" (default) | "ignore" | "all".
6809    #[serde(default)]
6810    pub group_policy: WhatsAppChatPolicy,
6811    /// When true and mode = "personal", always respond to messages in the
6812    /// user's own self-chat (Notes to Self). Defaults to false.
6813    #[serde(default)]
6814    pub self_chat_mode: bool,
6815    /// Regex patterns for DM mention gating (case-insensitive).
6816    /// When non-empty, only direct messages matching at least one pattern are
6817    /// processed; matched fragments are stripped from the forwarded content.
6818    /// Example: `["@?Construct", "\\+?15555550123"]`
6819    #[serde(default)]
6820    pub dm_mention_patterns: Vec<String>,
6821    /// Regex patterns for group-chat mention gating (case-insensitive).
6822    /// When non-empty, only group messages matching at least one pattern are
6823    /// processed; matched fragments are stripped from the forwarded content.
6824    /// Example: `["@?Construct", "\\+?15555550123"]`
6825    #[serde(default)]
6826    pub group_mention_patterns: Vec<String>,
6827    /// Per-channel proxy URL (http, https, socks5, socks5h).
6828    /// Overrides the global `[proxy]` setting for this channel only.
6829    #[serde(default)]
6830    pub proxy_url: Option<String>,
6831}
6832
6833impl ChannelConfig for WhatsAppConfig {
6834    fn name() -> &'static str {
6835        "WhatsApp"
6836    }
6837    fn desc() -> &'static str {
6838        "Business Cloud API"
6839    }
6840}
6841
6842#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6843pub struct LinqConfig {
6844    /// Linq Partner API token (Bearer auth)
6845    pub api_token: String,
6846    /// Phone number to send from (E.164 format)
6847    pub from_phone: String,
6848    /// Webhook signing secret for signature verification
6849    #[serde(default)]
6850    pub signing_secret: Option<String>,
6851    /// Allowed sender handles (phone numbers) or "*" for all
6852    #[serde(default)]
6853    pub allowed_senders: Vec<String>,
6854}
6855
6856impl ChannelConfig for LinqConfig {
6857    fn name() -> &'static str {
6858        "Linq"
6859    }
6860    fn desc() -> &'static str {
6861        "iMessage/RCS/SMS via Linq API"
6862    }
6863}
6864
6865/// WATI WhatsApp Business API channel configuration.
6866#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6867pub struct WatiConfig {
6868    /// WATI API token (Bearer auth).
6869    pub api_token: String,
6870    /// WATI API base URL (default: https://live-mt-server.wati.io).
6871    #[serde(default = "default_wati_api_url")]
6872    pub api_url: String,
6873    /// Tenant ID for multi-channel setups (optional).
6874    #[serde(default)]
6875    pub tenant_id: Option<String>,
6876    /// Allowed phone numbers (E.164 format) or "*" for all.
6877    #[serde(default)]
6878    pub allowed_numbers: Vec<String>,
6879    /// Per-channel proxy URL (http, https, socks5, socks5h).
6880    /// Overrides the global `[proxy]` setting for this channel only.
6881    #[serde(default)]
6882    pub proxy_url: Option<String>,
6883}
6884
6885fn default_wati_api_url() -> String {
6886    "https://live-mt-server.wati.io".to_string()
6887}
6888
6889impl ChannelConfig for WatiConfig {
6890    fn name() -> &'static str {
6891        "WATI"
6892    }
6893    fn desc() -> &'static str {
6894        "WhatsApp via WATI Business API"
6895    }
6896}
6897
6898/// Nextcloud Talk bot configuration (webhook receive + OCS send API).
6899#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6900pub struct NextcloudTalkConfig {
6901    /// Nextcloud base URL (e.g. "https://cloud.example.com").
6902    pub base_url: String,
6903    /// Bot app token used for OCS API bearer auth.
6904    pub app_token: String,
6905    /// Shared secret for webhook signature verification.
6906    ///
6907    /// Can also be set via `CONSTRUCT_NEXTCLOUD_TALK_WEBHOOK_SECRET`.
6908    #[serde(default)]
6909    pub webhook_secret: Option<String>,
6910    /// Allowed Nextcloud actor IDs (`[]` = deny all, `"*"` = allow all).
6911    #[serde(default)]
6912    pub allowed_users: Vec<String>,
6913    /// Per-channel proxy URL (http, https, socks5, socks5h).
6914    /// Overrides the global `[proxy]` setting for this channel only.
6915    #[serde(default)]
6916    pub proxy_url: Option<String>,
6917    /// Display name of the bot in Nextcloud Talk (e.g. "construct").
6918    /// Used to filter out the bot's own messages and prevent feedback loops.
6919    /// If not set, defaults to an empty string (no self-message filtering by name).
6920    #[serde(default)]
6921    pub bot_name: Option<String>,
6922}
6923
6924impl ChannelConfig for NextcloudTalkConfig {
6925    fn name() -> &'static str {
6926        "NextCloud Talk"
6927    }
6928    fn desc() -> &'static str {
6929        "NextCloud Talk platform"
6930    }
6931}
6932
6933impl WhatsAppConfig {
6934    /// Detect which backend to use based on config fields.
6935    /// Returns "cloud" if phone_number_id is set, "web" if session_path is set.
6936    pub fn backend_type(&self) -> &'static str {
6937        if self.phone_number_id.is_some() {
6938            "cloud"
6939        } else if self.session_path.is_some() {
6940            "web"
6941        } else {
6942            // Default to Cloud API for backward compatibility
6943            "cloud"
6944        }
6945    }
6946
6947    /// Check if this is a valid Cloud API config
6948    pub fn is_cloud_config(&self) -> bool {
6949        self.phone_number_id.is_some() && self.access_token.is_some() && self.verify_token.is_some()
6950    }
6951
6952    /// Check if this is a valid Web config
6953    pub fn is_web_config(&self) -> bool {
6954        self.session_path.is_some()
6955    }
6956
6957    /// Returns true when both Cloud and Web selectors are present.
6958    ///
6959    /// Runtime currently prefers Cloud mode in this case for backward compatibility.
6960    pub fn is_ambiguous_config(&self) -> bool {
6961        self.phone_number_id.is_some() && self.session_path.is_some()
6962    }
6963}
6964
6965/// IRC channel configuration.
6966#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
6967pub struct IrcConfig {
6968    /// IRC server hostname
6969    pub server: String,
6970    /// IRC server port (default: 6697 for TLS)
6971    #[serde(default = "default_irc_port")]
6972    pub port: u16,
6973    /// Bot nickname
6974    pub nickname: String,
6975    /// Username (defaults to nickname if not set)
6976    pub username: Option<String>,
6977    /// Channels to join on connect
6978    #[serde(default)]
6979    pub channels: Vec<String>,
6980    /// Allowed nicknames (case-insensitive) or "*" for all
6981    #[serde(default)]
6982    pub allowed_users: Vec<String>,
6983    /// Server password (for bouncers like ZNC)
6984    pub server_password: Option<String>,
6985    /// NickServ IDENTIFY password
6986    pub nickserv_password: Option<String>,
6987    /// SASL PLAIN password (IRCv3)
6988    pub sasl_password: Option<String>,
6989    /// Verify TLS certificate (default: true)
6990    pub verify_tls: Option<bool>,
6991}
6992
6993impl ChannelConfig for IrcConfig {
6994    fn name() -> &'static str {
6995        "IRC"
6996    }
6997    fn desc() -> &'static str {
6998        "IRC over TLS"
6999    }
7000}
7001
7002fn default_irc_port() -> u16 {
7003    6697
7004}
7005
7006/// How Construct receives events from Feishu / Lark.
7007///
7008/// - `websocket` (default) — persistent WSS long-connection; no public URL required.
7009/// - `webhook`             — HTTP callback server; requires a public HTTPS endpoint.
7010#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
7011#[serde(rename_all = "lowercase")]
7012pub enum LarkReceiveMode {
7013    #[default]
7014    Websocket,
7015    Webhook,
7016}
7017
7018/// Lark/Feishu configuration for messaging integration.
7019/// Lark is the international version; Feishu is the Chinese version.
7020#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7021pub struct LarkConfig {
7022    /// App ID from Lark/Feishu developer console
7023    pub app_id: String,
7024    /// App Secret from Lark/Feishu developer console
7025    pub app_secret: String,
7026    /// Encrypt key for webhook message decryption (optional)
7027    #[serde(default)]
7028    pub encrypt_key: Option<String>,
7029    /// Verification token for webhook validation (optional)
7030    #[serde(default)]
7031    pub verification_token: Option<String>,
7032    /// Allowed user IDs or union IDs (empty = deny all, "*" = allow all)
7033    #[serde(default)]
7034    pub allowed_users: Vec<String>,
7035    /// When true, only respond to messages that @-mention the bot in groups.
7036    /// Direct messages are always processed.
7037    #[serde(default)]
7038    pub mention_only: bool,
7039    /// Whether to use the Feishu (Chinese) endpoint instead of Lark (International)
7040    #[serde(default)]
7041    pub use_feishu: bool,
7042    /// Event receive mode: "websocket" (default) or "webhook"
7043    #[serde(default)]
7044    pub receive_mode: LarkReceiveMode,
7045    /// HTTP port for webhook mode only. Must be set when receive_mode = "webhook".
7046    /// Not required (and ignored) for websocket mode.
7047    #[serde(default)]
7048    pub port: Option<u16>,
7049    /// Per-channel proxy URL (http, https, socks5, socks5h).
7050    /// Overrides the global `[proxy]` setting for this channel only.
7051    #[serde(default)]
7052    pub proxy_url: Option<String>,
7053}
7054
7055impl ChannelConfig for LarkConfig {
7056    fn name() -> &'static str {
7057        "Lark"
7058    }
7059    fn desc() -> &'static str {
7060        "Lark Bot"
7061    }
7062}
7063
7064/// Feishu configuration for messaging integration.
7065#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7066pub struct FeishuConfig {
7067    /// App ID from Feishu developer console
7068    pub app_id: String,
7069    /// App Secret from Feishu developer console
7070    pub app_secret: String,
7071    /// Encrypt key for webhook message decryption (optional)
7072    #[serde(default)]
7073    pub encrypt_key: Option<String>,
7074    /// Verification token for webhook validation (optional)
7075    #[serde(default)]
7076    pub verification_token: Option<String>,
7077    /// Allowed user IDs or union IDs (empty = deny all, "*" = allow all)
7078    #[serde(default)]
7079    pub allowed_users: Vec<String>,
7080    /// Event receive mode: "websocket" (default) or "webhook"
7081    #[serde(default)]
7082    pub receive_mode: LarkReceiveMode,
7083    /// HTTP port for webhook mode only. Must be set when receive_mode = "webhook".
7084    /// Not required (and ignored) for websocket mode.
7085    #[serde(default)]
7086    pub port: Option<u16>,
7087    /// Per-channel proxy URL (http, https, socks5, socks5h).
7088    /// Overrides the global `[proxy]` setting for this channel only.
7089    #[serde(default)]
7090    pub proxy_url: Option<String>,
7091}
7092
7093impl ChannelConfig for FeishuConfig {
7094    fn name() -> &'static str {
7095        "Feishu"
7096    }
7097    fn desc() -> &'static str {
7098        "Feishu Bot"
7099    }
7100}
7101
7102// ── Security Config ─────────────────────────────────────────────────
7103
7104/// Security configuration for sandboxing, resource limits, and audit logging
7105#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
7106pub struct SecurityConfig {
7107    /// Sandbox configuration
7108    #[serde(default)]
7109    pub sandbox: SandboxConfig,
7110
7111    /// Resource limits
7112    #[serde(default)]
7113    pub resources: ResourceLimitsConfig,
7114
7115    /// Audit logging configuration
7116    #[serde(default)]
7117    pub audit: AuditConfig,
7118
7119    /// OTP gating configuration for sensitive actions/domains.
7120    #[serde(default)]
7121    pub otp: OtpConfig,
7122
7123    /// Emergency-stop state machine configuration.
7124    #[serde(default)]
7125    pub estop: EstopConfig,
7126
7127    /// Nevis IAM integration for SSO/MFA authentication and role-based access.
7128    #[serde(default)]
7129    pub nevis: NevisConfig,
7130
7131    /// WebAuthn / FIDO2 hardware key authentication configuration.
7132    #[serde(default)]
7133    pub webauthn: WebAuthnConfig,
7134}
7135
7136/// WebAuthn / FIDO2 hardware key authentication configuration (`[security.webauthn]`).
7137///
7138/// Enables registration and authentication via hardware security keys
7139/// (YubiKey, SoloKey, etc.) and platform authenticators (Touch ID, Windows Hello).
7140#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7141pub struct WebAuthnConfig {
7142    /// Enable WebAuthn authentication. Default: false.
7143    #[serde(default)]
7144    pub enabled: bool,
7145    /// Relying Party ID (domain name, e.g. "example.com"). Default: "localhost".
7146    #[serde(default = "default_webauthn_rp_id")]
7147    pub rp_id: String,
7148    /// Relying Party origin URL (e.g. "https://example.com"). Default: "http://localhost:42617".
7149    #[serde(default = "default_webauthn_rp_origin")]
7150    pub rp_origin: String,
7151    /// Relying Party display name. Default: "Construct".
7152    #[serde(default = "default_webauthn_rp_name")]
7153    pub rp_name: String,
7154}
7155
7156impl Default for WebAuthnConfig {
7157    fn default() -> Self {
7158        Self {
7159            enabled: false,
7160            rp_id: default_webauthn_rp_id(),
7161            rp_origin: default_webauthn_rp_origin(),
7162            rp_name: default_webauthn_rp_name(),
7163        }
7164    }
7165}
7166
7167fn default_webauthn_rp_id() -> String {
7168    "localhost".into()
7169}
7170
7171fn default_webauthn_rp_origin() -> String {
7172    "http://localhost:42617".into()
7173}
7174
7175fn default_webauthn_rp_name() -> String {
7176    "Construct".into()
7177}
7178
7179/// OTP validation strategy.
7180#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, JsonSchema, PartialEq, Eq)]
7181#[serde(rename_all = "kebab-case")]
7182pub enum OtpMethod {
7183    /// Time-based one-time password (RFC 6238).
7184    #[default]
7185    Totp,
7186    /// Future method for paired-device confirmations.
7187    Pairing,
7188    /// Future method for local CLI challenge prompts.
7189    CliPrompt,
7190}
7191
7192/// Security OTP configuration.
7193#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7194#[serde(deny_unknown_fields)]
7195pub struct OtpConfig {
7196    /// Enable OTP gating. Defaults to disabled for backward compatibility.
7197    #[serde(default)]
7198    pub enabled: bool,
7199
7200    /// OTP method.
7201    #[serde(default)]
7202    pub method: OtpMethod,
7203
7204    /// TOTP time-step in seconds.
7205    #[serde(default = "default_otp_token_ttl_secs")]
7206    pub token_ttl_secs: u64,
7207
7208    /// Reuse window for recently validated OTP codes.
7209    #[serde(default = "default_otp_cache_valid_secs")]
7210    pub cache_valid_secs: u64,
7211
7212    /// Tool/action names gated by OTP.
7213    #[serde(default = "default_otp_gated_actions")]
7214    pub gated_actions: Vec<String>,
7215
7216    /// Explicit domain patterns gated by OTP.
7217    #[serde(default)]
7218    pub gated_domains: Vec<String>,
7219
7220    /// Domain-category presets expanded into `gated_domains`.
7221    #[serde(default)]
7222    pub gated_domain_categories: Vec<String>,
7223
7224    /// Maximum number of OTP challenge attempts before lockout.
7225    #[serde(default = "default_otp_challenge_max_attempts")]
7226    pub challenge_max_attempts: u32,
7227}
7228
7229fn default_otp_token_ttl_secs() -> u64 {
7230    30
7231}
7232
7233fn default_otp_cache_valid_secs() -> u64 {
7234    300
7235}
7236
7237fn default_otp_challenge_max_attempts() -> u32 {
7238    3
7239}
7240
7241fn default_otp_gated_actions() -> Vec<String> {
7242    vec![
7243        "shell".to_string(),
7244        "file_write".to_string(),
7245        "browser_open".to_string(),
7246        "browser".to_string(),
7247        "memory_forget".to_string(),
7248    ]
7249}
7250
7251impl Default for OtpConfig {
7252    fn default() -> Self {
7253        Self {
7254            enabled: false,
7255            method: OtpMethod::Totp,
7256            token_ttl_secs: default_otp_token_ttl_secs(),
7257            cache_valid_secs: default_otp_cache_valid_secs(),
7258            gated_actions: default_otp_gated_actions(),
7259            gated_domains: Vec::new(),
7260            gated_domain_categories: Vec::new(),
7261            challenge_max_attempts: default_otp_challenge_max_attempts(),
7262        }
7263    }
7264}
7265
7266/// Emergency stop configuration.
7267#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7268#[serde(deny_unknown_fields)]
7269pub struct EstopConfig {
7270    /// Enable emergency stop controls.
7271    #[serde(default)]
7272    pub enabled: bool,
7273
7274    /// File path used to persist estop state.
7275    #[serde(default = "default_estop_state_file")]
7276    pub state_file: String,
7277
7278    /// Require a valid OTP before resume operations.
7279    #[serde(default = "default_true")]
7280    pub require_otp_to_resume: bool,
7281}
7282
7283fn default_estop_state_file() -> String {
7284    "~/.construct/estop-state.json".to_string()
7285}
7286
7287impl Default for EstopConfig {
7288    fn default() -> Self {
7289        Self {
7290            enabled: false,
7291            state_file: default_estop_state_file(),
7292            require_otp_to_resume: true,
7293        }
7294    }
7295}
7296
7297/// Nevis IAM integration configuration.
7298///
7299/// When `enabled` is true, Construct validates incoming requests against a Nevis
7300/// Security Suite instance and maps Nevis roles to tool/workspace permissions.
7301#[derive(Clone, Serialize, Deserialize, JsonSchema)]
7302#[serde(deny_unknown_fields)]
7303pub struct NevisConfig {
7304    /// Enable Nevis IAM integration. Defaults to false for backward compatibility.
7305    #[serde(default)]
7306    pub enabled: bool,
7307
7308    /// Base URL of the Nevis instance (e.g. `https://nevis.example.com`).
7309    #[serde(default)]
7310    pub instance_url: String,
7311
7312    /// Nevis realm to authenticate against.
7313    #[serde(default = "default_nevis_realm")]
7314    pub realm: String,
7315
7316    /// OAuth2 client ID registered in Nevis.
7317    #[serde(default)]
7318    pub client_id: String,
7319
7320    /// OAuth2 client secret. Encrypted via SecretStore when stored on disk.
7321    #[serde(default)]
7322    pub client_secret: Option<String>,
7323
7324    /// Token validation strategy: `"local"` (JWKS) or `"remote"` (introspection).
7325    #[serde(default = "default_nevis_token_validation")]
7326    pub token_validation: String,
7327
7328    /// JWKS endpoint URL for local token validation.
7329    #[serde(default)]
7330    pub jwks_url: Option<String>,
7331
7332    /// Nevis role to Construct permission mappings.
7333    #[serde(default)]
7334    pub role_mapping: Vec<NevisRoleMappingConfig>,
7335
7336    /// Require MFA verification for all Nevis-authenticated requests.
7337    #[serde(default)]
7338    pub require_mfa: bool,
7339
7340    /// Session timeout in seconds.
7341    #[serde(default = "default_nevis_session_timeout_secs")]
7342    pub session_timeout_secs: u64,
7343}
7344
7345impl std::fmt::Debug for NevisConfig {
7346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
7347        f.debug_struct("NevisConfig")
7348            .field("enabled", &self.enabled)
7349            .field("instance_url", &self.instance_url)
7350            .field("realm", &self.realm)
7351            .field("client_id", &self.client_id)
7352            .field(
7353                "client_secret",
7354                &self.client_secret.as_ref().map(|_| "[REDACTED]"),
7355            )
7356            .field("token_validation", &self.token_validation)
7357            .field("jwks_url", &self.jwks_url)
7358            .field("role_mapping", &self.role_mapping)
7359            .field("require_mfa", &self.require_mfa)
7360            .field("session_timeout_secs", &self.session_timeout_secs)
7361            .finish()
7362    }
7363}
7364
7365impl NevisConfig {
7366    /// Validate that required fields are present when Nevis is enabled.
7367    ///
7368    /// Call at config load time to fail fast on invalid configuration rather
7369    /// than deferring errors to the first authentication request.
7370    pub fn validate(&self) -> Result<(), String> {
7371        if !self.enabled {
7372            return Ok(());
7373        }
7374
7375        if self.instance_url.trim().is_empty() {
7376            return Err("nevis.instance_url is required when Nevis IAM is enabled".into());
7377        }
7378
7379        if self.client_id.trim().is_empty() {
7380            return Err("nevis.client_id is required when Nevis IAM is enabled".into());
7381        }
7382
7383        if self.realm.trim().is_empty() {
7384            return Err("nevis.realm is required when Nevis IAM is enabled".into());
7385        }
7386
7387        match self.token_validation.as_str() {
7388            "local" | "remote" => {}
7389            other => {
7390                return Err(format!(
7391                    "nevis.token_validation has invalid value '{other}': \
7392                     expected 'local' or 'remote'"
7393                ));
7394            }
7395        }
7396
7397        if self.token_validation == "local" && self.jwks_url.is_none() {
7398            return Err("nevis.jwks_url is required when token_validation is 'local'".into());
7399        }
7400
7401        if self.session_timeout_secs == 0 {
7402            return Err("nevis.session_timeout_secs must be greater than 0".into());
7403        }
7404
7405        Ok(())
7406    }
7407}
7408
7409fn default_nevis_realm() -> String {
7410    "master".into()
7411}
7412
7413fn default_nevis_token_validation() -> String {
7414    "local".into()
7415}
7416
7417fn default_nevis_session_timeout_secs() -> u64 {
7418    3600
7419}
7420
7421impl Default for NevisConfig {
7422    fn default() -> Self {
7423        Self {
7424            enabled: false,
7425            instance_url: String::new(),
7426            realm: default_nevis_realm(),
7427            client_id: String::new(),
7428            client_secret: None,
7429            token_validation: default_nevis_token_validation(),
7430            jwks_url: None,
7431            role_mapping: Vec::new(),
7432            require_mfa: false,
7433            session_timeout_secs: default_nevis_session_timeout_secs(),
7434        }
7435    }
7436}
7437
7438/// Maps a Nevis role to Construct tool permissions and workspace access.
7439#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7440#[serde(deny_unknown_fields)]
7441pub struct NevisRoleMappingConfig {
7442    /// Nevis role name (case-insensitive).
7443    pub nevis_role: String,
7444
7445    /// Tool names this role can access. Use `"all"` for unrestricted tool access.
7446    #[serde(default)]
7447    pub construct_permissions: Vec<String>,
7448
7449    /// Workspace names this role can access. Use `"all"` for unrestricted.
7450    #[serde(default)]
7451    pub workspace_access: Vec<String>,
7452}
7453
7454/// Sandbox configuration for OS-level isolation
7455#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7456pub struct SandboxConfig {
7457    /// Enable sandboxing (None = auto-detect, Some = explicit)
7458    #[serde(default)]
7459    pub enabled: Option<bool>,
7460
7461    /// Sandbox backend to use
7462    #[serde(default)]
7463    pub backend: SandboxBackend,
7464
7465    /// Custom Firejail arguments (when backend = firejail)
7466    #[serde(default)]
7467    pub firejail_args: Vec<String>,
7468}
7469
7470impl Default for SandboxConfig {
7471    fn default() -> Self {
7472        Self {
7473            enabled: None, // Auto-detect
7474            backend: SandboxBackend::Auto,
7475            firejail_args: Vec::new(),
7476        }
7477    }
7478}
7479
7480/// Sandbox backend selection
7481#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
7482#[serde(rename_all = "lowercase")]
7483pub enum SandboxBackend {
7484    /// Auto-detect best available (default)
7485    #[default]
7486    Auto,
7487    /// Landlock (Linux kernel LSM, native)
7488    Landlock,
7489    /// Firejail (user-space sandbox)
7490    Firejail,
7491    /// Bubblewrap (user namespaces)
7492    Bubblewrap,
7493    /// Docker container isolation
7494    Docker,
7495    /// macOS sandbox-exec (Seatbelt)
7496    #[serde(alias = "sandbox-exec")]
7497    SandboxExec,
7498    /// No sandboxing (application-layer only)
7499    None,
7500}
7501
7502/// Resource limits for command execution
7503#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7504pub struct ResourceLimitsConfig {
7505    /// Maximum memory in MB per command
7506    #[serde(default = "default_max_memory_mb")]
7507    pub max_memory_mb: u32,
7508
7509    /// Maximum CPU time in seconds per command
7510    #[serde(default = "default_max_cpu_time_seconds")]
7511    pub max_cpu_time_seconds: u64,
7512
7513    /// Maximum number of subprocesses
7514    #[serde(default = "default_max_subprocesses")]
7515    pub max_subprocesses: u32,
7516
7517    /// Enable memory monitoring
7518    #[serde(default = "default_memory_monitoring_enabled")]
7519    pub memory_monitoring: bool,
7520}
7521
7522fn default_max_memory_mb() -> u32 {
7523    512
7524}
7525
7526fn default_max_cpu_time_seconds() -> u64 {
7527    60
7528}
7529
7530fn default_max_subprocesses() -> u32 {
7531    10
7532}
7533
7534fn default_memory_monitoring_enabled() -> bool {
7535    true
7536}
7537
7538impl Default for ResourceLimitsConfig {
7539    fn default() -> Self {
7540        Self {
7541            max_memory_mb: default_max_memory_mb(),
7542            max_cpu_time_seconds: default_max_cpu_time_seconds(),
7543            max_subprocesses: default_max_subprocesses(),
7544            memory_monitoring: default_memory_monitoring_enabled(),
7545        }
7546    }
7547}
7548
7549/// Audit logging configuration
7550#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7551pub struct AuditConfig {
7552    /// Enable audit logging
7553    #[serde(default = "default_audit_enabled")]
7554    pub enabled: bool,
7555
7556    /// Path to audit log file (relative to construct dir)
7557    #[serde(default = "default_audit_log_path")]
7558    pub log_path: String,
7559
7560    /// Maximum log size in MB before rotation
7561    #[serde(default = "default_audit_max_size_mb")]
7562    pub max_size_mb: u32,
7563
7564    /// Sign events with HMAC for tamper evidence
7565    #[serde(default)]
7566    pub sign_events: bool,
7567}
7568
7569fn default_audit_enabled() -> bool {
7570    true
7571}
7572
7573fn default_audit_log_path() -> String {
7574    "audit.log".to_string()
7575}
7576
7577fn default_audit_max_size_mb() -> u32 {
7578    100
7579}
7580
7581impl Default for AuditConfig {
7582    fn default() -> Self {
7583        Self {
7584            enabled: default_audit_enabled(),
7585            log_path: default_audit_log_path(),
7586            max_size_mb: default_audit_max_size_mb(),
7587            sign_events: false,
7588        }
7589    }
7590}
7591
7592/// DingTalk configuration for Stream Mode messaging
7593#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7594pub struct DingTalkConfig {
7595    /// Client ID (AppKey) from DingTalk developer console
7596    pub client_id: String,
7597    /// Client Secret (AppSecret) from DingTalk developer console
7598    pub client_secret: String,
7599    /// Allowed user IDs (staff IDs). Empty = deny all, "*" = allow all
7600    #[serde(default)]
7601    pub allowed_users: Vec<String>,
7602    /// Per-channel proxy URL (http, https, socks5, socks5h).
7603    /// Overrides the global `[proxy]` setting for this channel only.
7604    #[serde(default)]
7605    pub proxy_url: Option<String>,
7606}
7607
7608impl ChannelConfig for DingTalkConfig {
7609    fn name() -> &'static str {
7610        "DingTalk"
7611    }
7612    fn desc() -> &'static str {
7613        "DingTalk Stream Mode"
7614    }
7615}
7616
7617/// WeCom (WeChat Enterprise) Bot Webhook configuration
7618#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7619pub struct WeComConfig {
7620    /// Webhook key from WeCom Bot configuration
7621    pub webhook_key: String,
7622    /// Allowed user IDs. Empty = deny all, "*" = allow all
7623    #[serde(default)]
7624    pub allowed_users: Vec<String>,
7625}
7626
7627impl ChannelConfig for WeComConfig {
7628    fn name() -> &'static str {
7629        "WeCom"
7630    }
7631    fn desc() -> &'static str {
7632        "WeCom Bot Webhook"
7633    }
7634}
7635
7636/// QQ Official Bot configuration (Tencent QQ Bot SDK)
7637#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7638pub struct QQConfig {
7639    /// App ID from QQ Bot developer console
7640    pub app_id: String,
7641    /// App Secret from QQ Bot developer console
7642    pub app_secret: String,
7643    /// Allowed user IDs. Empty = deny all, "*" = allow all
7644    #[serde(default)]
7645    pub allowed_users: Vec<String>,
7646    /// Per-channel proxy URL (http, https, socks5, socks5h).
7647    /// Overrides the global `[proxy]` setting for this channel only.
7648    #[serde(default)]
7649    pub proxy_url: Option<String>,
7650}
7651
7652impl ChannelConfig for QQConfig {
7653    fn name() -> &'static str {
7654        "QQ Official"
7655    }
7656    fn desc() -> &'static str {
7657        "Tencent QQ Bot"
7658    }
7659}
7660
7661/// X/Twitter channel configuration (Twitter API v2)
7662#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7663pub struct TwitterConfig {
7664    /// Twitter API v2 Bearer Token (OAuth 2.0)
7665    pub bearer_token: String,
7666    /// Allowed usernames or user IDs. Empty = deny all, "*" = allow all
7667    #[serde(default)]
7668    pub allowed_users: Vec<String>,
7669}
7670
7671impl ChannelConfig for TwitterConfig {
7672    fn name() -> &'static str {
7673        "X/Twitter"
7674    }
7675    fn desc() -> &'static str {
7676        "X/Twitter Bot via API v2"
7677    }
7678}
7679
7680/// Mochat channel configuration (Mochat customer service API)
7681#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7682pub struct MochatConfig {
7683    /// Mochat API base URL
7684    pub api_url: String,
7685    /// Mochat API token
7686    pub api_token: String,
7687    /// Allowed user IDs. Empty = deny all, "*" = allow all
7688    #[serde(default)]
7689    pub allowed_users: Vec<String>,
7690    /// Poll interval in seconds for new messages. Default: 5
7691    #[serde(default = "default_mochat_poll_interval")]
7692    pub poll_interval_secs: u64,
7693}
7694
7695fn default_mochat_poll_interval() -> u64 {
7696    5
7697}
7698
7699impl ChannelConfig for MochatConfig {
7700    fn name() -> &'static str {
7701        "Mochat"
7702    }
7703    fn desc() -> &'static str {
7704        "Mochat Customer Service"
7705    }
7706}
7707
7708/// Reddit channel configuration (OAuth2 bot).
7709#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7710pub struct RedditConfig {
7711    /// Reddit OAuth2 client ID.
7712    pub client_id: String,
7713    /// Reddit OAuth2 client secret.
7714    pub client_secret: String,
7715    /// Reddit OAuth2 refresh token for persistent access.
7716    pub refresh_token: String,
7717    /// Reddit bot username (without `u/` prefix).
7718    pub username: String,
7719    /// Optional subreddit to filter messages (without `r/` prefix).
7720    /// When set, only messages from this subreddit are processed.
7721    #[serde(default)]
7722    pub subreddit: Option<String>,
7723}
7724
7725impl ChannelConfig for RedditConfig {
7726    fn name() -> &'static str {
7727        "Reddit"
7728    }
7729    fn desc() -> &'static str {
7730        "Reddit bot (OAuth2)"
7731    }
7732}
7733
7734/// Bluesky channel configuration (AT Protocol).
7735#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7736pub struct BlueskyConfig {
7737    /// Bluesky handle (e.g. `"mybot.bsky.social"`).
7738    pub handle: String,
7739    /// App-specific password (from Bluesky settings).
7740    pub app_password: String,
7741}
7742
7743impl ChannelConfig for BlueskyConfig {
7744    fn name() -> &'static str {
7745        "Bluesky"
7746    }
7747    fn desc() -> &'static str {
7748        "AT Protocol"
7749    }
7750}
7751
7752/// Voice wake word detection channel configuration.
7753///
7754/// Listens on the default microphone for a configurable wake word,
7755/// then captures the following utterance and transcribes it via the
7756/// existing transcription API.
7757#[cfg(feature = "voice-wake")]
7758#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7759pub struct VoiceWakeConfig {
7760    /// Wake word phrase to listen for (case-insensitive substring match).
7761    /// Default: `"hey construct"`.
7762    #[serde(default = "default_voice_wake_word")]
7763    pub wake_word: String,
7764    /// Silence timeout in milliseconds — how long to wait after the last
7765    /// energy spike before finalizing a capture window. Default: `2000`.
7766    #[serde(default = "default_voice_wake_silence_timeout_ms")]
7767    pub silence_timeout_ms: u32,
7768    /// RMS energy threshold for voice activity detection. Samples below
7769    /// this level are treated as silence. Default: `0.01`.
7770    #[serde(default = "default_voice_wake_energy_threshold")]
7771    pub energy_threshold: f32,
7772    /// Maximum capture duration in seconds before forcing transcription.
7773    /// Default: `30`.
7774    #[serde(default = "default_voice_wake_max_capture_secs")]
7775    pub max_capture_secs: u32,
7776}
7777
7778#[cfg(feature = "voice-wake")]
7779fn default_voice_wake_word() -> String {
7780    "hey construct".into()
7781}
7782
7783#[cfg(feature = "voice-wake")]
7784fn default_voice_wake_silence_timeout_ms() -> u32 {
7785    2000
7786}
7787
7788#[cfg(feature = "voice-wake")]
7789fn default_voice_wake_energy_threshold() -> f32 {
7790    0.01
7791}
7792
7793#[cfg(feature = "voice-wake")]
7794fn default_voice_wake_max_capture_secs() -> u32 {
7795    30
7796}
7797
7798#[cfg(feature = "voice-wake")]
7799impl Default for VoiceWakeConfig {
7800    fn default() -> Self {
7801        Self {
7802            wake_word: default_voice_wake_word(),
7803            silence_timeout_ms: default_voice_wake_silence_timeout_ms(),
7804            energy_threshold: default_voice_wake_energy_threshold(),
7805            max_capture_secs: default_voice_wake_max_capture_secs(),
7806        }
7807    }
7808}
7809
7810#[cfg(feature = "voice-wake")]
7811impl ChannelConfig for VoiceWakeConfig {
7812    fn name() -> &'static str {
7813        "VoiceWake"
7814    }
7815    fn desc() -> &'static str {
7816        "voice wake word detection"
7817    }
7818}
7819
7820/// Nostr channel configuration (NIP-04 + NIP-17 private messages)
7821#[cfg(feature = "channel-nostr")]
7822#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7823pub struct NostrConfig {
7824    /// Private key in hex or nsec bech32 format
7825    pub private_key: String,
7826    /// Relay URLs (wss://). Defaults to popular public relays if omitted.
7827    #[serde(default = "default_nostr_relays")]
7828    pub relays: Vec<String>,
7829    /// Allowed sender public keys (hex or npub). Empty = deny all, "*" = allow all
7830    #[serde(default)]
7831    pub allowed_pubkeys: Vec<String>,
7832}
7833
7834#[cfg(feature = "channel-nostr")]
7835impl ChannelConfig for NostrConfig {
7836    fn name() -> &'static str {
7837        "Nostr"
7838    }
7839    fn desc() -> &'static str {
7840        "Nostr DMs"
7841    }
7842}
7843
7844#[cfg(feature = "channel-nostr")]
7845pub fn default_nostr_relays() -> Vec<String> {
7846    vec![
7847        "wss://relay.damus.io".to_string(),
7848        "wss://nos.lol".to_string(),
7849        "wss://relay.primal.net".to_string(),
7850        "wss://relay.snort.social".to_string(),
7851    ]
7852}
7853
7854// -- Notion --
7855
7856/// Notion integration configuration (`[notion]`).
7857///
7858/// When `enabled = true`, the agent polls a Notion database for pending tasks
7859/// and exposes a `notion` tool for querying, reading, creating, and updating pages.
7860/// Requires `api_key` (or the `NOTION_API_KEY` env var) and `database_id`.
7861#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7862pub struct NotionConfig {
7863    #[serde(default)]
7864    pub enabled: bool,
7865    #[serde(default)]
7866    pub api_key: String,
7867    #[serde(default)]
7868    pub database_id: String,
7869    #[serde(default = "default_notion_poll_interval")]
7870    pub poll_interval_secs: u64,
7871    #[serde(default = "default_notion_status_prop")]
7872    pub status_property: String,
7873    #[serde(default = "default_notion_input_prop")]
7874    pub input_property: String,
7875    #[serde(default = "default_notion_result_prop")]
7876    pub result_property: String,
7877    #[serde(default = "default_notion_max_concurrent")]
7878    pub max_concurrent: usize,
7879    #[serde(default = "default_notion_recover_stale")]
7880    pub recover_stale: bool,
7881}
7882
7883fn default_notion_poll_interval() -> u64 {
7884    5
7885}
7886fn default_notion_status_prop() -> String {
7887    "Status".into()
7888}
7889fn default_notion_input_prop() -> String {
7890    "Input".into()
7891}
7892fn default_notion_result_prop() -> String {
7893    "Result".into()
7894}
7895fn default_notion_max_concurrent() -> usize {
7896    4
7897}
7898fn default_notion_recover_stale() -> bool {
7899    true
7900}
7901
7902impl Default for NotionConfig {
7903    fn default() -> Self {
7904        Self {
7905            enabled: false,
7906            api_key: String::new(),
7907            database_id: String::new(),
7908            poll_interval_secs: default_notion_poll_interval(),
7909            status_property: default_notion_status_prop(),
7910            input_property: default_notion_input_prop(),
7911            result_property: default_notion_result_prop(),
7912            max_concurrent: default_notion_max_concurrent(),
7913            recover_stale: default_notion_recover_stale(),
7914        }
7915    }
7916}
7917
7918/// Jira integration configuration (`[jira]`).
7919///
7920/// When `enabled = true`, registers the `jira` tool which can get tickets,
7921/// search with JQL, and add comments. Requires `base_url` and `api_token`
7922/// (or the `JIRA_API_TOKEN` env var).
7923///
7924/// ## Defaults
7925/// - `enabled`: `false`
7926/// - `allowed_actions`: `["get_ticket"]` — read-only by default.
7927///   Add `"search_tickets"` or `"comment_ticket"` to unlock them.
7928/// - `timeout_secs`: `30`
7929///
7930/// ## Auth
7931/// Jira Cloud uses HTTP Basic auth: `email` + `api_token`.
7932/// `api_token` is stored encrypted at rest; set it here or via `JIRA_API_TOKEN`.
7933#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7934pub struct JiraConfig {
7935    /// Enable the `jira` tool. Default: `false`.
7936    #[serde(default)]
7937    pub enabled: bool,
7938    /// Atlassian instance base URL, e.g. `https://yourco.atlassian.net`.
7939    #[serde(default)]
7940    pub base_url: String,
7941    /// Jira account email used for Basic auth.
7942    #[serde(default)]
7943    pub email: String,
7944    /// Jira API token. Encrypted at rest. Falls back to `JIRA_API_TOKEN` env var.
7945    #[serde(default)]
7946    pub api_token: String,
7947    /// Actions the agent is permitted to call.
7948    /// Valid values: `"get_ticket"`, `"search_tickets"`, `"comment_ticket"`.
7949    /// Defaults to `["get_ticket"]` (read-only).
7950    #[serde(default = "default_jira_allowed_actions")]
7951    pub allowed_actions: Vec<String>,
7952    /// Request timeout in seconds. Default: `30`.
7953    #[serde(default = "default_jira_timeout_secs")]
7954    pub timeout_secs: u64,
7955}
7956
7957fn default_jira_allowed_actions() -> Vec<String> {
7958    vec!["get_ticket".to_string()]
7959}
7960
7961fn default_jira_timeout_secs() -> u64 {
7962    30
7963}
7964
7965impl Default for JiraConfig {
7966    fn default() -> Self {
7967        Self {
7968            enabled: false,
7969            base_url: String::new(),
7970            email: String::new(),
7971            api_token: String::new(),
7972            allowed_actions: default_jira_allowed_actions(),
7973            timeout_secs: default_jira_timeout_secs(),
7974        }
7975    }
7976}
7977
7978///
7979/// Controls the read-only cloud transformation analysis tools:
7980/// IaC review, migration assessment, cost analysis, and architecture review.
7981#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
7982pub struct CloudOpsConfig {
7983    /// Enable cloud operations tools. Default: false.
7984    #[serde(default)]
7985    pub enabled: bool,
7986    /// Default cloud provider for analysis context. Default: "aws".
7987    #[serde(default = "default_cloud_ops_cloud")]
7988    pub default_cloud: String,
7989    /// Supported cloud providers. Default: [`aws`, `azure`, `gcp`].
7990    #[serde(default = "default_cloud_ops_supported_clouds")]
7991    pub supported_clouds: Vec<String>,
7992    /// Supported IaC tools for review. Default: [`terraform`].
7993    #[serde(default = "default_cloud_ops_iac_tools")]
7994    pub iac_tools: Vec<String>,
7995    /// Monthly USD threshold to flag cost items. Default: 100.0.
7996    #[serde(default = "default_cloud_ops_cost_threshold")]
7997    pub cost_threshold_monthly_usd: f64,
7998    /// Well-Architected Frameworks to check against. Default: [`aws-waf`].
7999    #[serde(default = "default_cloud_ops_waf")]
8000    pub well_architected_frameworks: Vec<String>,
8001}
8002
8003impl Default for CloudOpsConfig {
8004    fn default() -> Self {
8005        Self {
8006            enabled: false,
8007            default_cloud: default_cloud_ops_cloud(),
8008            supported_clouds: default_cloud_ops_supported_clouds(),
8009            iac_tools: default_cloud_ops_iac_tools(),
8010            cost_threshold_monthly_usd: default_cloud_ops_cost_threshold(),
8011            well_architected_frameworks: default_cloud_ops_waf(),
8012        }
8013    }
8014}
8015
8016impl CloudOpsConfig {
8017    pub fn validate(&self) -> Result<()> {
8018        if self.enabled {
8019            if self.default_cloud.trim().is_empty() {
8020                anyhow::bail!(
8021                    "cloud_ops.default_cloud must not be empty when cloud_ops is enabled"
8022                );
8023            }
8024            if self.supported_clouds.is_empty() {
8025                anyhow::bail!(
8026                    "cloud_ops.supported_clouds must not be empty when cloud_ops is enabled"
8027                );
8028            }
8029            for (i, cloud) in self.supported_clouds.iter().enumerate() {
8030                if cloud.trim().is_empty() {
8031                    anyhow::bail!("cloud_ops.supported_clouds[{i}] must not be empty");
8032                }
8033            }
8034            if !self.supported_clouds.contains(&self.default_cloud) {
8035                anyhow::bail!(
8036                    "cloud_ops.default_cloud '{}' is not in cloud_ops.supported_clouds {:?}",
8037                    self.default_cloud,
8038                    self.supported_clouds
8039                );
8040            }
8041            if self.cost_threshold_monthly_usd < 0.0 {
8042                anyhow::bail!(
8043                    "cloud_ops.cost_threshold_monthly_usd must be non-negative, got {}",
8044                    self.cost_threshold_monthly_usd
8045                );
8046            }
8047            if self.iac_tools.is_empty() {
8048                anyhow::bail!("cloud_ops.iac_tools must not be empty when cloud_ops is enabled");
8049            }
8050        }
8051        Ok(())
8052    }
8053}
8054
8055fn default_cloud_ops_cloud() -> String {
8056    "aws".into()
8057}
8058
8059fn default_cloud_ops_supported_clouds() -> Vec<String> {
8060    vec!["aws".into(), "azure".into(), "gcp".into()]
8061}
8062
8063fn default_cloud_ops_iac_tools() -> Vec<String> {
8064    vec!["terraform".into()]
8065}
8066
8067fn default_cloud_ops_cost_threshold() -> f64 {
8068    100.0
8069}
8070
8071fn default_cloud_ops_waf() -> Vec<String> {
8072    vec!["aws-waf".into()]
8073}
8074
8075// ── Conversational AI ──────────────────────────────────────────────
8076
8077fn default_conversational_ai_language() -> String {
8078    "en".into()
8079}
8080
8081fn default_conversational_ai_supported_languages() -> Vec<String> {
8082    vec!["en".into(), "de".into(), "fr".into(), "it".into()]
8083}
8084
8085fn default_conversational_ai_escalation_threshold() -> f64 {
8086    0.3
8087}
8088
8089fn default_conversational_ai_max_turns() -> usize {
8090    50
8091}
8092
8093fn default_conversational_ai_timeout_secs() -> u64 {
8094    1800
8095}
8096
8097/// Conversational AI agent builder configuration (`[conversational_ai]` section).
8098///
8099/// **Status: Reserved for future use.** This configuration is parsed but not yet
8100/// consumed by the runtime. Setting `enabled = true` will produce a startup warning.
8101#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
8102pub struct ConversationalAiConfig {
8103    /// Enable conversational AI features. Default: false.
8104    #[serde(default)]
8105    pub enabled: bool,
8106    /// Default language for conversations (BCP-47 tag). Default: "en".
8107    #[serde(default = "default_conversational_ai_language")]
8108    pub default_language: String,
8109    /// Supported languages for conversations. Default: [`en`, `de`, `fr`, `it`].
8110    #[serde(default = "default_conversational_ai_supported_languages")]
8111    pub supported_languages: Vec<String>,
8112    /// Automatically detect user language from message content. Default: true.
8113    #[serde(default = "default_true")]
8114    pub auto_detect_language: bool,
8115    /// Intent confidence below this threshold triggers escalation. Default: 0.3.
8116    #[serde(default = "default_conversational_ai_escalation_threshold")]
8117    pub escalation_confidence_threshold: f64,
8118    /// Maximum conversation turns before auto-ending. Default: 50.
8119    #[serde(default = "default_conversational_ai_max_turns")]
8120    pub max_conversation_turns: usize,
8121    /// Conversation timeout in seconds (inactivity). Default: 1800.
8122    #[serde(default = "default_conversational_ai_timeout_secs")]
8123    pub conversation_timeout_secs: u64,
8124    /// Enable conversation analytics tracking. Default: false (privacy-by-default).
8125    #[serde(default)]
8126    pub analytics_enabled: bool,
8127    /// Optional tool name for RAG-based knowledge base lookup during conversations.
8128    #[serde(default)]
8129    pub knowledge_base_tool: Option<String>,
8130}
8131
8132impl ConversationalAiConfig {
8133    /// Returns `true` when the feature is disabled (the default).
8134    ///
8135    /// Used by `#[serde(skip_serializing_if)]` to omit the entire
8136    /// `[conversational_ai]` section from newly-generated config files,
8137    /// avoiding user confusion over an undocumented / experimental section.
8138    pub fn is_disabled(&self) -> bool {
8139        !self.enabled
8140    }
8141}
8142
8143impl Default for ConversationalAiConfig {
8144    fn default() -> Self {
8145        Self {
8146            enabled: false,
8147            default_language: default_conversational_ai_language(),
8148            supported_languages: default_conversational_ai_supported_languages(),
8149            auto_detect_language: true,
8150            escalation_confidence_threshold: default_conversational_ai_escalation_threshold(),
8151            max_conversation_turns: default_conversational_ai_max_turns(),
8152            conversation_timeout_secs: default_conversational_ai_timeout_secs(),
8153            analytics_enabled: false,
8154            knowledge_base_tool: None,
8155        }
8156    }
8157}
8158
8159// ── Security ops config ─────────────────────────────────────────
8160
8161/// Managed Cybersecurity Service (MCSS) dashboard agent configuration (`[security_ops]`).
8162#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
8163pub struct SecurityOpsConfig {
8164    /// Enable security operations tools.
8165    #[serde(default)]
8166    pub enabled: bool,
8167    /// Directory containing incident response playbook definitions (JSON).
8168    #[serde(default = "default_playbooks_dir")]
8169    pub playbooks_dir: String,
8170    /// Automatically triage incoming alerts without user prompt.
8171    #[serde(default)]
8172    pub auto_triage: bool,
8173    /// Require human approval before executing playbook actions.
8174    #[serde(default = "default_require_approval")]
8175    pub require_approval_for_actions: bool,
8176    /// Maximum severity level that can be auto-remediated without approval.
8177    /// One of: "low", "medium", "high", "critical". Default: "low".
8178    #[serde(default = "default_max_auto_severity")]
8179    pub max_auto_severity: String,
8180    /// Directory for generated security reports.
8181    #[serde(default = "default_report_output_dir")]
8182    pub report_output_dir: String,
8183    /// Optional SIEM webhook URL for alert ingestion.
8184    #[serde(default)]
8185    pub siem_integration: Option<String>,
8186}
8187
8188fn default_playbooks_dir() -> String {
8189    "~/.construct/playbooks".into()
8190}
8191
8192fn default_require_approval() -> bool {
8193    true
8194}
8195
8196fn default_max_auto_severity() -> String {
8197    "low".into()
8198}
8199
8200fn default_report_output_dir() -> String {
8201    "~/.construct/security-reports".into()
8202}
8203
8204impl Default for SecurityOpsConfig {
8205    fn default() -> Self {
8206        Self {
8207            enabled: false,
8208            playbooks_dir: default_playbooks_dir(),
8209            auto_triage: false,
8210            require_approval_for_actions: true,
8211            max_auto_severity: default_max_auto_severity(),
8212            report_output_dir: default_report_output_dir(),
8213            siem_integration: None,
8214        }
8215    }
8216}
8217
8218// ── Config impl ──────────────────────────────────────────────────
8219
8220impl Default for Config {
8221    fn default() -> Self {
8222        let home =
8223            UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
8224        let construct_dir = home.join(".construct");
8225
8226        Self {
8227            workspace_dir: construct_dir.join("workspace"),
8228            config_path: construct_dir.join("config.toml"),
8229            api_key: None,
8230            api_url: None,
8231            api_path: None,
8232            default_provider: Some("openrouter".to_string()),
8233            default_model: Some("anthropic/claude-sonnet-4.6".to_string()),
8234            model_providers: HashMap::new(),
8235            default_temperature: default_temperature(),
8236            provider_timeout_secs: default_provider_timeout_secs(),
8237            provider_max_tokens: None,
8238            extra_headers: HashMap::new(),
8239            observability: ObservabilityConfig::default(),
8240            autonomy: AutonomyConfig::default(),
8241            trust: crate::trust::TrustConfig::default(),
8242            backup: BackupConfig::default(),
8243            data_retention: DataRetentionConfig::default(),
8244            cloud_ops: CloudOpsConfig::default(),
8245            conversational_ai: ConversationalAiConfig::default(),
8246            security: SecurityConfig::default(),
8247            security_ops: SecurityOpsConfig::default(),
8248            runtime: RuntimeConfig::default(),
8249            reliability: ReliabilityConfig::default(),
8250            scheduler: SchedulerConfig::default(),
8251            agent: AgentConfig::default(),
8252            pacing: PacingConfig::default(),
8253            skills: SkillsConfig::default(),
8254            pipeline: PipelineConfig::default(),
8255            model_routes: Vec::new(),
8256            embedding_routes: Vec::new(),
8257            heartbeat: HeartbeatConfig::default(),
8258            cron: CronConfig::default(),
8259            channels_config: ChannelsConfig::default(),
8260            memory: MemoryConfig::default(),
8261            storage: StorageConfig::default(),
8262            tunnel: TunnelConfig::default(),
8263            gateway: GatewayConfig::default(),
8264            composio: ComposioConfig::default(),
8265            microsoft365: Microsoft365Config::default(),
8266            secrets: SecretsConfig::default(),
8267            browser: BrowserConfig::default(),
8268            browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig::default(),
8269            http_request: HttpRequestConfig::default(),
8270            multimodal: MultimodalConfig::default(),
8271            media_pipeline: MediaPipelineConfig::default(),
8272            web_fetch: WebFetchConfig::default(),
8273            link_enricher: LinkEnricherConfig::default(),
8274            text_browser: TextBrowserConfig::default(),
8275            web_search: WebSearchConfig::default(),
8276            project_intel: ProjectIntelConfig::default(),
8277            google_workspace: GoogleWorkspaceConfig::default(),
8278            proxy: ProxyConfig::default(),
8279            identity: IdentityConfig::default(),
8280            cost: CostConfig::default(),
8281            peripherals: PeripheralsConfig::default(),
8282            delegate: DelegateToolConfig::default(),
8283            agents: HashMap::new(),
8284            swarms: HashMap::new(),
8285            hooks: HooksConfig::default(),
8286            hardware: HardwareConfig::default(),
8287            query_classification: QueryClassificationConfig::default(),
8288            transcription: TranscriptionConfig::default(),
8289            tts: TtsConfig::default(),
8290            mcp: McpConfig::default(),
8291            kumiho: KumihoConfig::default(),
8292            operator: OperatorConfig::default(),
8293            nodes: NodesConfig::default(),
8294            clawhub: ClawHubConfig::default(),
8295            workspace: WorkspaceConfig::default(),
8296            notion: NotionConfig::default(),
8297            jira: JiraConfig::default(),
8298            node_transport: NodeTransportConfig::default(),
8299            linkedin: LinkedInConfig::default(),
8300            image_gen: ImageGenConfig::default(),
8301            plugins: PluginsConfig::default(),
8302            locale: None,
8303            verifiable_intent: VerifiableIntentConfig::default(),
8304            claude_code: ClaudeCodeConfig::default(),
8305            claude_code_runner: ClaudeCodeRunnerConfig::default(),
8306            codex_cli: CodexCliConfig::default(),
8307            gemini_cli: GeminiCliConfig::default(),
8308            opencode_cli: OpenCodeCliConfig::default(),
8309            sop: SopConfig::default(),
8310            shell_tool: ShellToolConfig::default(),
8311        }
8312    }
8313}
8314
8315fn default_config_and_workspace_dirs() -> Result<(PathBuf, PathBuf)> {
8316    let config_dir = default_config_dir()?;
8317    Ok((config_dir.clone(), config_dir.join("workspace")))
8318}
8319
8320const ACTIVE_WORKSPACE_STATE_FILE: &str = "active_workspace.toml";
8321
8322#[derive(Debug, Serialize, Deserialize)]
8323struct ActiveWorkspaceState {
8324    config_dir: String,
8325}
8326
8327fn default_config_dir() -> Result<PathBuf> {
8328    if let Ok(home) = std::env::var("HOME") {
8329        if !home.is_empty() {
8330            return Ok(PathBuf::from(home).join(".construct"));
8331        }
8332    }
8333
8334    let home = UserDirs::new()
8335        .map(|u| u.home_dir().to_path_buf())
8336        .context("Could not find home directory")?;
8337    Ok(home.join(".construct"))
8338}
8339
8340fn active_workspace_state_path(default_dir: &Path) -> PathBuf {
8341    default_dir.join(ACTIVE_WORKSPACE_STATE_FILE)
8342}
8343
8344/// Returns `true` if `path` lives under the OS temp directory.
8345fn is_temp_directory(path: &Path) -> bool {
8346    let temp = std::env::temp_dir();
8347    // Canonicalize when possible to handle symlinks (macOS /var → /private/var)
8348    let canon_temp = temp.canonicalize().unwrap_or_else(|_| temp.clone());
8349    let canon_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
8350    canon_path.starts_with(&canon_temp)
8351}
8352
8353async fn load_persisted_workspace_dirs(
8354    default_config_dir: &Path,
8355) -> Result<Option<(PathBuf, PathBuf)>> {
8356    let state_path = active_workspace_state_path(default_config_dir);
8357    if !state_path.exists() {
8358        return Ok(None);
8359    }
8360
8361    let contents = match fs::read_to_string(&state_path).await {
8362        Ok(contents) => contents,
8363        Err(error) => {
8364            tracing::warn!(
8365                "Failed to read active workspace marker {}: {error}",
8366                state_path.display()
8367            );
8368            return Ok(None);
8369        }
8370    };
8371
8372    let state: ActiveWorkspaceState = match toml::from_str(&contents) {
8373        Ok(state) => state,
8374        Err(error) => {
8375            tracing::warn!(
8376                "Failed to parse active workspace marker {}: {error}",
8377                state_path.display()
8378            );
8379            return Ok(None);
8380        }
8381    };
8382
8383    let raw_config_dir = state.config_dir.trim();
8384    if raw_config_dir.is_empty() {
8385        tracing::warn!(
8386            "Ignoring active workspace marker {} because config_dir is empty",
8387            state_path.display()
8388        );
8389        return Ok(None);
8390    }
8391
8392    let parsed_dir = expand_tilde_path(raw_config_dir);
8393    let config_dir = if parsed_dir.is_absolute() {
8394        parsed_dir
8395    } else {
8396        default_config_dir.join(parsed_dir)
8397    };
8398    Ok(Some((config_dir.clone(), config_dir.join("workspace"))))
8399}
8400
8401pub(crate) async fn persist_active_workspace_config_dir(config_dir: &Path) -> Result<()> {
8402    persist_active_workspace_config_dir_in(config_dir, &default_config_dir()?).await
8403}
8404
8405/// Inner implementation that accepts the default config directory explicitly,
8406/// so callers (including tests) control where the marker is written without
8407/// manipulating process-wide environment variables.
8408async fn persist_active_workspace_config_dir_in(
8409    config_dir: &Path,
8410    default_config_dir: &Path,
8411) -> Result<()> {
8412    let state_path = active_workspace_state_path(default_config_dir);
8413
8414    // Guard: refuse to write a temp-directory config_dir into a non-temp
8415    // default location. This prevents transient test runs or one-off
8416    // invocations from hijacking the real user's daemon config resolution.
8417    // When both paths are temp (e.g. in tests), the write is harmless.
8418    if is_temp_directory(config_dir) && !is_temp_directory(default_config_dir) {
8419        tracing::warn!(
8420            path = %config_dir.display(),
8421            "Refusing to persist temp directory as active workspace marker"
8422        );
8423        return Ok(());
8424    }
8425
8426    if config_dir == default_config_dir {
8427        if state_path.exists() {
8428            fs::remove_file(&state_path).await.with_context(|| {
8429                format!(
8430                    "Failed to clear active workspace marker: {}",
8431                    state_path.display()
8432                )
8433            })?;
8434        }
8435        return Ok(());
8436    }
8437
8438    fs::create_dir_all(&default_config_dir)
8439        .await
8440        .with_context(|| {
8441            format!(
8442                "Failed to create default config directory: {}",
8443                default_config_dir.display()
8444            )
8445        })?;
8446
8447    let state = ActiveWorkspaceState {
8448        config_dir: config_dir.to_string_lossy().into_owned(),
8449    };
8450    let serialized =
8451        toml::to_string_pretty(&state).context("Failed to serialize active workspace marker")?;
8452
8453    let temp_path = default_config_dir.join(format!(
8454        ".{ACTIVE_WORKSPACE_STATE_FILE}.tmp-{}",
8455        uuid::Uuid::new_v4()
8456    ));
8457    fs::write(&temp_path, serialized).await.with_context(|| {
8458        format!(
8459            "Failed to write temporary active workspace marker: {}",
8460            temp_path.display()
8461        )
8462    })?;
8463
8464    if let Err(error) = fs::rename(&temp_path, &state_path).await {
8465        let _ = fs::remove_file(&temp_path).await;
8466        anyhow::bail!(
8467            "Failed to atomically persist active workspace marker {}: {error}",
8468            state_path.display()
8469        );
8470    }
8471
8472    sync_directory(default_config_dir).await?;
8473    Ok(())
8474}
8475
8476pub(crate) fn resolve_config_dir_for_workspace(workspace_dir: &Path) -> (PathBuf, PathBuf) {
8477    let workspace_config_dir = workspace_dir.to_path_buf();
8478    if workspace_config_dir.join("config.toml").exists() {
8479        return (
8480            workspace_config_dir.clone(),
8481            workspace_config_dir.join("workspace"),
8482        );
8483    }
8484
8485    let legacy_config_dir = workspace_dir
8486        .parent()
8487        .map(|parent| parent.join(".construct"));
8488    if let Some(legacy_dir) = legacy_config_dir {
8489        if legacy_dir.join("config.toml").exists() {
8490            return (legacy_dir, workspace_config_dir);
8491        }
8492
8493        if workspace_dir
8494            .file_name()
8495            .is_some_and(|name| name == std::ffi::OsStr::new("workspace"))
8496        {
8497            return (legacy_dir, workspace_config_dir);
8498        }
8499    }
8500
8501    (
8502        workspace_config_dir.clone(),
8503        workspace_config_dir.join("workspace"),
8504    )
8505}
8506
8507/// Resolve the current runtime config/workspace directories for onboarding flows.
8508///
8509/// This mirrors the same precedence used by `Config::load_or_init()`:
8510/// `CONSTRUCT_CONFIG_DIR` > `CONSTRUCT_WORKSPACE` > active workspace marker > defaults.
8511pub async fn resolve_runtime_dirs_for_onboarding() -> Result<(PathBuf, PathBuf)> {
8512    let (default_construct_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
8513    let (config_dir, workspace_dir, _) =
8514        resolve_runtime_config_dirs(&default_construct_dir, &default_workspace_dir).await?;
8515    Ok((config_dir, workspace_dir))
8516}
8517
8518#[derive(Clone, Copy, Debug, Eq, PartialEq)]
8519enum ConfigResolutionSource {
8520    EnvConfigDir,
8521    EnvWorkspace,
8522    ActiveWorkspaceMarker,
8523    DefaultConfigDir,
8524}
8525
8526impl ConfigResolutionSource {
8527    const fn as_str(self) -> &'static str {
8528        match self {
8529            Self::EnvConfigDir => "CONSTRUCT_CONFIG_DIR",
8530            Self::EnvWorkspace => "CONSTRUCT_WORKSPACE",
8531            Self::ActiveWorkspaceMarker => "active_workspace.toml",
8532            Self::DefaultConfigDir => "default",
8533        }
8534    }
8535}
8536
8537/// Expand tilde in paths, falling back to `UserDirs` when HOME is unset.
8538///
8539/// In non-TTY environments (e.g. cron), HOME may not be set, causing
8540/// `shellexpand::tilde` to return the literal `~` unexpanded. This helper
8541/// detects that case and uses `directories::UserDirs` as a fallback.
8542fn expand_tilde_path(path: &str) -> PathBuf {
8543    let expanded = shellexpand::tilde(path);
8544    let expanded_str = expanded.as_ref();
8545
8546    // If the path still starts with '~', tilde expansion failed (HOME unset)
8547    if expanded_str.starts_with('~') {
8548        if let Some(user_dirs) = UserDirs::new() {
8549            let home = user_dirs.home_dir();
8550            // Replace leading ~ with home directory
8551            if let Some(rest) = expanded_str.strip_prefix('~') {
8552                return home.join(rest.trim_start_matches(['/', '\\']));
8553            }
8554        }
8555        // If UserDirs also fails, log a warning and use the literal path
8556        tracing::warn!(
8557            path = path,
8558            "Failed to expand tilde: HOME environment variable is not set and UserDirs failed. \
8559             In cron/non-TTY environments, use absolute paths or set HOME explicitly."
8560        );
8561    }
8562
8563    PathBuf::from(expanded_str)
8564}
8565
8566async fn resolve_runtime_config_dirs(
8567    default_construct_dir: &Path,
8568    default_workspace_dir: &Path,
8569) -> Result<(PathBuf, PathBuf, ConfigResolutionSource)> {
8570    if let Ok(custom_config_dir) = std::env::var("CONSTRUCT_CONFIG_DIR") {
8571        let custom_config_dir = custom_config_dir.trim();
8572        if !custom_config_dir.is_empty() {
8573            let construct_dir = expand_tilde_path(custom_config_dir);
8574            return Ok((
8575                construct_dir.clone(),
8576                construct_dir.join("workspace"),
8577                ConfigResolutionSource::EnvConfigDir,
8578            ));
8579        }
8580    }
8581
8582    if let Ok(custom_workspace) = std::env::var("CONSTRUCT_WORKSPACE") {
8583        if !custom_workspace.is_empty() {
8584            let expanded = expand_tilde_path(&custom_workspace);
8585            let (construct_dir, workspace_dir) = resolve_config_dir_for_workspace(&expanded);
8586            return Ok((
8587                construct_dir,
8588                workspace_dir,
8589                ConfigResolutionSource::EnvWorkspace,
8590            ));
8591        }
8592    }
8593
8594    if let Some((construct_dir, workspace_dir)) =
8595        load_persisted_workspace_dirs(default_construct_dir).await?
8596    {
8597        return Ok((
8598            construct_dir,
8599            workspace_dir,
8600            ConfigResolutionSource::ActiveWorkspaceMarker,
8601        ));
8602    }
8603
8604    Ok((
8605        default_construct_dir.to_path_buf(),
8606        default_workspace_dir.to_path_buf(),
8607        ConfigResolutionSource::DefaultConfigDir,
8608    ))
8609}
8610
8611fn decrypt_optional_secret(
8612    store: &crate::security::SecretStore,
8613    value: &mut Option<String>,
8614    field_name: &str,
8615) -> Result<()> {
8616    if let Some(raw) = value.clone() {
8617        if crate::security::SecretStore::is_encrypted(&raw) {
8618            *value = Some(
8619                store
8620                    .decrypt(&raw)
8621                    .with_context(|| format!("Failed to decrypt {field_name}"))?,
8622            );
8623        }
8624    }
8625    Ok(())
8626}
8627
8628fn decrypt_secret(
8629    store: &crate::security::SecretStore,
8630    value: &mut String,
8631    field_name: &str,
8632) -> Result<()> {
8633    if crate::security::SecretStore::is_encrypted(value) {
8634        *value = store
8635            .decrypt(value)
8636            .with_context(|| format!("Failed to decrypt {field_name}"))?;
8637    }
8638    Ok(())
8639}
8640
8641fn encrypt_optional_secret(
8642    store: &crate::security::SecretStore,
8643    value: &mut Option<String>,
8644    field_name: &str,
8645) -> Result<()> {
8646    if let Some(raw) = value.clone() {
8647        if !crate::security::SecretStore::is_encrypted(&raw) {
8648            *value = Some(
8649                store
8650                    .encrypt(&raw)
8651                    .with_context(|| format!("Failed to encrypt {field_name}"))?,
8652            );
8653        }
8654    }
8655    Ok(())
8656}
8657
8658fn encrypt_secret(
8659    store: &crate::security::SecretStore,
8660    value: &mut String,
8661    field_name: &str,
8662) -> Result<()> {
8663    if !crate::security::SecretStore::is_encrypted(value) {
8664        *value = store
8665            .encrypt(value)
8666            .with_context(|| format!("Failed to encrypt {field_name}"))?;
8667    }
8668    Ok(())
8669}
8670
8671fn config_dir_creation_error(path: &Path) -> String {
8672    format!(
8673        "Failed to create config directory: {}. If running as an OpenRC service, \
8674         ensure this path is writable by user 'construct'.",
8675        path.display()
8676    )
8677}
8678
8679fn is_local_ollama_endpoint(api_url: Option<&str>) -> bool {
8680    let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
8681        return true;
8682    };
8683
8684    reqwest::Url::parse(raw)
8685        .ok()
8686        .and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
8687        .is_some_and(|host| matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1" | "0.0.0.0"))
8688}
8689
8690fn has_ollama_cloud_credential(config_api_key: Option<&str>) -> bool {
8691    let config_key_present = config_api_key
8692        .map(str::trim)
8693        .is_some_and(|value| !value.is_empty());
8694    if config_key_present {
8695        return true;
8696    }
8697
8698    ["OLLAMA_API_KEY", "CONSTRUCT_API_KEY", "API_KEY"]
8699        .iter()
8700        .any(|name| {
8701            std::env::var(name)
8702                .ok()
8703                .is_some_and(|value| !value.trim().is_empty())
8704        })
8705}
8706
8707/// Parse the `CONSTRUCT_EXTRA_HEADERS` environment variable value.
8708///
8709/// Format: `Key:Value,Key2:Value2`
8710///
8711/// Entries without a colon or with an empty key are silently skipped.
8712/// Leading/trailing whitespace on both key and value is trimmed.
8713pub fn parse_extra_headers_env(raw: &str) -> Vec<(String, String)> {
8714    let mut result = Vec::new();
8715    for entry in raw.split(',') {
8716        let entry = entry.trim();
8717        if entry.is_empty() {
8718            continue;
8719        }
8720        if let Some((key, value)) = entry.split_once(':') {
8721            let key = key.trim();
8722            let value = value.trim();
8723            if key.is_empty() {
8724                tracing::warn!("Ignoring extra header with empty name in CONSTRUCT_EXTRA_HEADERS");
8725                continue;
8726            }
8727            result.push((key.to_string(), value.to_string()));
8728        } else {
8729            tracing::warn!("Ignoring malformed extra header entry (missing ':'): {entry}");
8730        }
8731    }
8732    result
8733}
8734
8735fn normalize_wire_api(raw: &str) -> Option<&'static str> {
8736    match raw.trim().to_ascii_lowercase().as_str() {
8737        "responses" | "openai-responses" | "open-ai-responses" => Some("responses"),
8738        "chat_completions"
8739        | "chat-completions"
8740        | "chat"
8741        | "chatcompletions"
8742        | "openai-chat-completions"
8743        | "open-ai-chat-completions" => Some("chat_completions"),
8744        _ => None,
8745    }
8746}
8747
8748fn read_codex_openai_api_key() -> Option<String> {
8749    let home = UserDirs::new()?.home_dir().to_path_buf();
8750    let auth_path = home.join(".codex").join("auth.json");
8751    let raw = std::fs::read_to_string(auth_path).ok()?;
8752    let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
8753
8754    parsed
8755        .get("OPENAI_API_KEY")
8756        .and_then(serde_json::Value::as_str)
8757        .map(str::trim)
8758        .filter(|value| !value.is_empty())
8759        .map(ToString::to_string)
8760}
8761
8762/// Ensure that essential bootstrap files exist in the workspace directory.
8763///
8764/// When the workspace is created outside of `construct onboard` (e.g., non-tty
8765/// daemon/cron sessions), these files would otherwise be missing. This function
8766/// creates sensible defaults that allow the agent to operate with a basic identity.
8767async fn ensure_bootstrap_files(workspace_dir: &Path) -> Result<()> {
8768    let defaults: &[(&str, &str)] = &[
8769        (
8770            "IDENTITY.md",
8771            "# IDENTITY.md — Who Am I?\n\n\
8772             I am Construct, an autonomous AI agent.\n\n\
8773             ## Traits\n\
8774             - Helpful, precise, and safety-conscious\n\
8775             - I prioritize clarity and correctness\n",
8776        ),
8777        (
8778            "SOUL.md",
8779            "# SOUL.md — Who You Are\n\n\
8780             You are Construct, an autonomous AI agent.\n\n\
8781             ## Core Principles\n\
8782             - Be helpful and accurate\n\
8783             - Respect user intent and boundaries\n\
8784             - Ask before taking destructive actions\n\
8785             - Prefer safe, reversible operations\n",
8786        ),
8787    ];
8788
8789    for (filename, content) in defaults {
8790        let path = workspace_dir.join(filename);
8791        if !path.exists() {
8792            fs::write(&path, content)
8793                .await
8794                .with_context(|| format!("Failed to create default {filename} in workspace"))?;
8795        }
8796    }
8797
8798    Ok(())
8799}
8800
8801impl Config {
8802    pub async fn load_or_init() -> Result<Self> {
8803        let (default_construct_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
8804
8805        let (construct_dir, workspace_dir, resolution_source) =
8806            resolve_runtime_config_dirs(&default_construct_dir, &default_workspace_dir).await?;
8807
8808        let config_path = construct_dir.join("config.toml");
8809
8810        fs::create_dir_all(&construct_dir)
8811            .await
8812            .with_context(|| config_dir_creation_error(&construct_dir))?;
8813        fs::create_dir_all(&workspace_dir)
8814            .await
8815            .context("Failed to create workspace directory")?;
8816
8817        ensure_bootstrap_files(&workspace_dir).await?;
8818
8819        if config_path.exists() {
8820            // Warn if config file is world-readable (may contain API keys)
8821            #[cfg(unix)]
8822            {
8823                use std::os::unix::fs::PermissionsExt;
8824                if let Ok(meta) = fs::metadata(&config_path).await {
8825                    if meta.permissions().mode() & 0o004 != 0 {
8826                        tracing::warn!(
8827                            "Config file {:?} is world-readable (mode {:o}). \
8828                             Consider restricting with: chmod 600 {:?}",
8829                            config_path,
8830                            meta.permissions().mode() & 0o777,
8831                            config_path,
8832                        );
8833                    }
8834                }
8835            }
8836
8837            let contents = fs::read_to_string(&config_path)
8838                .await
8839                .context("Failed to read config file")?;
8840
8841            // Deserialize the config with the standard TOML parser.
8842            //
8843            // Previously this used `serde_ignored::deserialize` for both
8844            // deserialization and unknown-key detection.  However,
8845            // `serde_ignored` silently drops field values inside nested
8846            // structs that carry `#[serde(default)]` (e.g. the entire
8847            // `[autonomy]` table), causing user-supplied values to be
8848            // replaced by defaults.  See #4171.
8849            //
8850            // We now deserialize with `toml::from_str` (which is correct)
8851            // and run `serde_ignored` separately just for diagnostics.
8852            let mut config: Config =
8853                toml::from_str(&contents).context("Failed to deserialize config file")?;
8854
8855            // Ensure the built-in default auto_approve entries are always
8856            // present.  When a user specifies `auto_approve` in their TOML
8857            // (e.g. to add a custom tool), serde replaces the default list
8858            // instead of merging.  This caused default-safe tools like
8859            // `weather` or `calculator` to lose their auto-approve status
8860            // and get silently denied in non-interactive channel runs.
8861            // See #4247.
8862            //
8863            // Users who want to require approval for a default tool can
8864            // add it to `always_ask`, which takes precedence over
8865            // `auto_approve` in the approval decision (see approval/mod.rs).
8866            config.autonomy.ensure_default_auto_approve();
8867
8868            // Detect unknown top-level config keys by comparing the raw
8869            // TOML table keys against what Config actually deserializes.
8870            // This replaces the previous serde_ignored-based approach which
8871            // had false-positive issues with #[serde(default)] nested structs.
8872            if let Ok(raw) = contents.parse::<toml::Table>() {
8873                // Build the set of known top-level keys from a default Config
8874                // serialization round-trip.  This is computed once and cached.
8875                static KNOWN_KEYS: OnceLock<Vec<String>> = OnceLock::new();
8876                let known = KNOWN_KEYS.get_or_init(|| {
8877                    toml::to_string(&Config::default())
8878                        .ok()
8879                        .and_then(|s| s.parse::<toml::Table>().ok())
8880                        .map(|t| t.keys().cloned().collect())
8881                        .unwrap_or_default()
8882                });
8883                for key in raw.keys() {
8884                    if !known.contains(key) {
8885                        tracing::warn!(
8886                            "Unknown config key ignored: \"{key}\". Check config.toml for typos or deprecated options.",
8887                        );
8888                    }
8889                }
8890            }
8891            // Set computed paths that are skipped during serialization
8892            config.config_path = config_path.clone();
8893            config.workspace_dir = workspace_dir;
8894            let store = crate::security::SecretStore::new(&construct_dir, config.secrets.encrypt);
8895            decrypt_optional_secret(&store, &mut config.api_key, "config.api_key")?;
8896            decrypt_optional_secret(
8897                &store,
8898                &mut config.composio.api_key,
8899                "config.composio.api_key",
8900            )?;
8901            if let Some(ref mut pinggy) = config.tunnel.pinggy {
8902                decrypt_optional_secret(&store, &mut pinggy.token, "config.tunnel.pinggy.token")?;
8903            }
8904            decrypt_optional_secret(
8905                &store,
8906                &mut config.microsoft365.client_secret,
8907                "config.microsoft365.client_secret",
8908            )?;
8909
8910            decrypt_optional_secret(
8911                &store,
8912                &mut config.browser.computer_use.api_key,
8913                "config.browser.computer_use.api_key",
8914            )?;
8915
8916            decrypt_optional_secret(
8917                &store,
8918                &mut config.web_search.brave_api_key,
8919                "config.web_search.brave_api_key",
8920            )?;
8921
8922            decrypt_optional_secret(
8923                &store,
8924                &mut config.storage.provider.config.db_url,
8925                "config.storage.provider.config.db_url",
8926            )?;
8927
8928            for agent in config.agents.values_mut() {
8929                decrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
8930            }
8931
8932            // Decrypt TTS provider API keys
8933            if let Some(ref mut openai) = config.tts.openai {
8934                decrypt_optional_secret(&store, &mut openai.api_key, "config.tts.openai.api_key")?;
8935            }
8936            if let Some(ref mut elevenlabs) = config.tts.elevenlabs {
8937                decrypt_optional_secret(
8938                    &store,
8939                    &mut elevenlabs.api_key,
8940                    "config.tts.elevenlabs.api_key",
8941                )?;
8942            }
8943            if let Some(ref mut google) = config.tts.google {
8944                decrypt_optional_secret(&store, &mut google.api_key, "config.tts.google.api_key")?;
8945            }
8946
8947            // Decrypt nested STT provider API keys
8948            decrypt_optional_secret(
8949                &store,
8950                &mut config.transcription.api_key,
8951                "config.transcription.api_key",
8952            )?;
8953            if let Some(ref mut openai) = config.transcription.openai {
8954                decrypt_optional_secret(
8955                    &store,
8956                    &mut openai.api_key,
8957                    "config.transcription.openai.api_key",
8958                )?;
8959            }
8960            if let Some(ref mut deepgram) = config.transcription.deepgram {
8961                decrypt_optional_secret(
8962                    &store,
8963                    &mut deepgram.api_key,
8964                    "config.transcription.deepgram.api_key",
8965                )?;
8966            }
8967            if let Some(ref mut assemblyai) = config.transcription.assemblyai {
8968                decrypt_optional_secret(
8969                    &store,
8970                    &mut assemblyai.api_key,
8971                    "config.transcription.assemblyai.api_key",
8972                )?;
8973            }
8974            if let Some(ref mut google) = config.transcription.google {
8975                decrypt_optional_secret(
8976                    &store,
8977                    &mut google.api_key,
8978                    "config.transcription.google.api_key",
8979                )?;
8980            }
8981            if let Some(ref mut local) = config.transcription.local_whisper {
8982                decrypt_optional_secret(
8983                    &store,
8984                    &mut local.bearer_token,
8985                    "config.transcription.local_whisper.bearer_token",
8986                )?;
8987            }
8988
8989            #[cfg(feature = "channel-nostr")]
8990            if let Some(ref mut ns) = config.channels_config.nostr {
8991                decrypt_secret(
8992                    &store,
8993                    &mut ns.private_key,
8994                    "config.channels_config.nostr.private_key",
8995                )?;
8996            }
8997            if let Some(ref mut fs) = config.channels_config.feishu {
8998                decrypt_secret(
8999                    &store,
9000                    &mut fs.app_secret,
9001                    "config.channels_config.feishu.app_secret",
9002                )?;
9003                decrypt_optional_secret(
9004                    &store,
9005                    &mut fs.encrypt_key,
9006                    "config.channels_config.feishu.encrypt_key",
9007                )?;
9008                decrypt_optional_secret(
9009                    &store,
9010                    &mut fs.verification_token,
9011                    "config.channels_config.feishu.verification_token",
9012                )?;
9013            }
9014
9015            // Decrypt channel secrets
9016            if let Some(ref mut tg) = config.channels_config.telegram {
9017                decrypt_secret(
9018                    &store,
9019                    &mut tg.bot_token,
9020                    "config.channels_config.telegram.bot_token",
9021                )?;
9022            }
9023            if let Some(ref mut dc) = config.channels_config.discord {
9024                decrypt_secret(
9025                    &store,
9026                    &mut dc.bot_token,
9027                    "config.channels_config.discord.bot_token",
9028                )?;
9029            }
9030            if let Some(ref mut sl) = config.channels_config.slack {
9031                decrypt_secret(
9032                    &store,
9033                    &mut sl.bot_token,
9034                    "config.channels_config.slack.bot_token",
9035                )?;
9036                decrypt_optional_secret(
9037                    &store,
9038                    &mut sl.app_token,
9039                    "config.channels_config.slack.app_token",
9040                )?;
9041            }
9042            if let Some(ref mut mm) = config.channels_config.mattermost {
9043                decrypt_secret(
9044                    &store,
9045                    &mut mm.bot_token,
9046                    "config.channels_config.mattermost.bot_token",
9047                )?;
9048            }
9049            if let Some(ref mut mx) = config.channels_config.matrix {
9050                decrypt_secret(
9051                    &store,
9052                    &mut mx.access_token,
9053                    "config.channels_config.matrix.access_token",
9054                )?;
9055                decrypt_optional_secret(
9056                    &store,
9057                    &mut mx.recovery_key,
9058                    "config.channels_config.matrix.recovery_key",
9059                )?;
9060            }
9061            if let Some(ref mut wa) = config.channels_config.whatsapp {
9062                decrypt_optional_secret(
9063                    &store,
9064                    &mut wa.access_token,
9065                    "config.channels_config.whatsapp.access_token",
9066                )?;
9067                decrypt_optional_secret(
9068                    &store,
9069                    &mut wa.app_secret,
9070                    "config.channels_config.whatsapp.app_secret",
9071                )?;
9072                decrypt_optional_secret(
9073                    &store,
9074                    &mut wa.verify_token,
9075                    "config.channels_config.whatsapp.verify_token",
9076                )?;
9077            }
9078            if let Some(ref mut lq) = config.channels_config.linq {
9079                decrypt_secret(
9080                    &store,
9081                    &mut lq.api_token,
9082                    "config.channels_config.linq.api_token",
9083                )?;
9084                decrypt_optional_secret(
9085                    &store,
9086                    &mut lq.signing_secret,
9087                    "config.channels_config.linq.signing_secret",
9088                )?;
9089            }
9090            if let Some(ref mut wt) = config.channels_config.wati {
9091                decrypt_secret(
9092                    &store,
9093                    &mut wt.api_token,
9094                    "config.channels_config.wati.api_token",
9095                )?;
9096            }
9097            if let Some(ref mut nc) = config.channels_config.nextcloud_talk {
9098                decrypt_secret(
9099                    &store,
9100                    &mut nc.app_token,
9101                    "config.channels_config.nextcloud_talk.app_token",
9102                )?;
9103                decrypt_optional_secret(
9104                    &store,
9105                    &mut nc.webhook_secret,
9106                    "config.channels_config.nextcloud_talk.webhook_secret",
9107                )?;
9108            }
9109            if let Some(ref mut em) = config.channels_config.email {
9110                decrypt_secret(
9111                    &store,
9112                    &mut em.password,
9113                    "config.channels_config.email.password",
9114                )?;
9115            }
9116            if let Some(ref mut gp) = config.channels_config.gmail_push {
9117                decrypt_secret(
9118                    &store,
9119                    &mut gp.oauth_token,
9120                    "config.channels_config.gmail_push.oauth_token",
9121                )?;
9122            }
9123            if let Some(ref mut irc) = config.channels_config.irc {
9124                decrypt_optional_secret(
9125                    &store,
9126                    &mut irc.server_password,
9127                    "config.channels_config.irc.server_password",
9128                )?;
9129                decrypt_optional_secret(
9130                    &store,
9131                    &mut irc.nickserv_password,
9132                    "config.channels_config.irc.nickserv_password",
9133                )?;
9134                decrypt_optional_secret(
9135                    &store,
9136                    &mut irc.sasl_password,
9137                    "config.channels_config.irc.sasl_password",
9138                )?;
9139            }
9140            if let Some(ref mut lk) = config.channels_config.lark {
9141                decrypt_secret(
9142                    &store,
9143                    &mut lk.app_secret,
9144                    "config.channels_config.lark.app_secret",
9145                )?;
9146                decrypt_optional_secret(
9147                    &store,
9148                    &mut lk.encrypt_key,
9149                    "config.channels_config.lark.encrypt_key",
9150                )?;
9151                decrypt_optional_secret(
9152                    &store,
9153                    &mut lk.verification_token,
9154                    "config.channels_config.lark.verification_token",
9155                )?;
9156            }
9157            if let Some(ref mut fs) = config.channels_config.feishu {
9158                decrypt_secret(
9159                    &store,
9160                    &mut fs.app_secret,
9161                    "config.channels_config.feishu.app_secret",
9162                )?;
9163                decrypt_optional_secret(
9164                    &store,
9165                    &mut fs.encrypt_key,
9166                    "config.channels_config.feishu.encrypt_key",
9167                )?;
9168                decrypt_optional_secret(
9169                    &store,
9170                    &mut fs.verification_token,
9171                    "config.channels_config.feishu.verification_token",
9172                )?;
9173            }
9174            if let Some(ref mut dt) = config.channels_config.dingtalk {
9175                decrypt_secret(
9176                    &store,
9177                    &mut dt.client_secret,
9178                    "config.channels_config.dingtalk.client_secret",
9179                )?;
9180            }
9181            if let Some(ref mut wc) = config.channels_config.wecom {
9182                decrypt_secret(
9183                    &store,
9184                    &mut wc.webhook_key,
9185                    "config.channels_config.wecom.webhook_key",
9186                )?;
9187            }
9188            if let Some(ref mut qq) = config.channels_config.qq {
9189                decrypt_secret(
9190                    &store,
9191                    &mut qq.app_secret,
9192                    "config.channels_config.qq.app_secret",
9193                )?;
9194            }
9195            if let Some(ref mut wh) = config.channels_config.webhook {
9196                decrypt_optional_secret(
9197                    &store,
9198                    &mut wh.secret,
9199                    "config.channels_config.webhook.secret",
9200                )?;
9201            }
9202            if let Some(ref mut ct) = config.channels_config.clawdtalk {
9203                decrypt_secret(
9204                    &store,
9205                    &mut ct.api_key,
9206                    "config.channels_config.clawdtalk.api_key",
9207                )?;
9208                decrypt_optional_secret(
9209                    &store,
9210                    &mut ct.webhook_secret,
9211                    "config.channels_config.clawdtalk.webhook_secret",
9212                )?;
9213            }
9214
9215            // Decrypt gateway paired tokens
9216            for token in &mut config.gateway.paired_tokens {
9217                decrypt_secret(&store, token, "config.gateway.paired_tokens[]")?;
9218            }
9219
9220            // Decrypt Nevis IAM secret
9221            decrypt_optional_secret(
9222                &store,
9223                &mut config.security.nevis.client_secret,
9224                "config.security.nevis.client_secret",
9225            )?;
9226
9227            // Notion API key (top-level, not in ChannelsConfig)
9228            if !config.notion.api_key.is_empty() {
9229                decrypt_secret(&store, &mut config.notion.api_key, "config.notion.api_key")?;
9230            }
9231
9232            // Jira API token
9233            if !config.jira.api_token.is_empty() {
9234                decrypt_secret(&store, &mut config.jira.api_token, "config.jira.api_token")?;
9235            }
9236
9237            config.apply_env_overrides();
9238            config.validate()?;
9239            tracing::info!(
9240                path = %config.config_path.display(),
9241                workspace = %config.workspace_dir.display(),
9242                source = resolution_source.as_str(),
9243                initialized = true,
9244                "Config loaded"
9245            );
9246            Ok(config)
9247        } else {
9248            let mut config = Config::default();
9249            config.config_path = config_path.clone();
9250            config.workspace_dir = workspace_dir;
9251            config.save().await?;
9252
9253            // Restrict permissions on newly created config file (may contain API keys)
9254            #[cfg(unix)]
9255            {
9256                use std::{fs::Permissions, os::unix::fs::PermissionsExt};
9257                let _ = fs::set_permissions(&config_path, Permissions::from_mode(0o600)).await;
9258            }
9259
9260            config.apply_env_overrides();
9261            config.validate()?;
9262            tracing::info!(
9263                path = %config.config_path.display(),
9264                workspace = %config.workspace_dir.display(),
9265                source = resolution_source.as_str(),
9266                initialized = true,
9267                "Config loaded"
9268            );
9269            Ok(config)
9270        }
9271    }
9272
9273    fn lookup_model_provider_profile(
9274        &self,
9275        provider_name: &str,
9276    ) -> Option<(String, ModelProviderConfig)> {
9277        let needle = provider_name.trim();
9278        if needle.is_empty() {
9279            return None;
9280        }
9281
9282        self.model_providers
9283            .iter()
9284            .find(|(name, _)| name.eq_ignore_ascii_case(needle))
9285            .map(|(name, profile)| (name.clone(), profile.clone()))
9286    }
9287
9288    fn apply_named_model_provider_profile(&mut self) {
9289        let Some(current_provider) = self.default_provider.clone() else {
9290            return;
9291        };
9292
9293        let Some((profile_key, profile)) = self.lookup_model_provider_profile(&current_provider)
9294        else {
9295            return;
9296        };
9297
9298        let base_url = profile
9299            .base_url
9300            .as_deref()
9301            .map(str::trim)
9302            .filter(|value| !value.is_empty())
9303            .map(ToString::to_string);
9304
9305        if self
9306            .api_url
9307            .as_deref()
9308            .map(str::trim)
9309            .is_none_or(|value| value.is_empty())
9310        {
9311            if let Some(base_url) = base_url.as_ref() {
9312                self.api_url = Some(base_url.clone());
9313            }
9314        }
9315
9316        // Propagate api_path from the profile when not already set at top level.
9317        if self.api_path.is_none() {
9318            if let Some(ref path) = profile.api_path {
9319                let trimmed = path.trim();
9320                if !trimmed.is_empty() {
9321                    self.api_path = Some(trimmed.to_string());
9322                }
9323            }
9324        }
9325
9326        // Propagate max_tokens from the profile when not already set at top level.
9327        if self.provider_max_tokens.is_none() {
9328            if let Some(max_tokens) = profile.max_tokens {
9329                self.provider_max_tokens = Some(max_tokens);
9330            }
9331        }
9332
9333        if profile.requires_openai_auth
9334            && self
9335                .api_key
9336                .as_deref()
9337                .map(str::trim)
9338                .is_none_or(|value| value.is_empty())
9339        {
9340            let codex_key = std::env::var("OPENAI_API_KEY")
9341                .ok()
9342                .map(|value| value.trim().to_string())
9343                .filter(|value| !value.is_empty())
9344                .or_else(read_codex_openai_api_key);
9345            if let Some(codex_key) = codex_key {
9346                self.api_key = Some(codex_key);
9347            }
9348        }
9349
9350        let normalized_wire_api = profile.wire_api.as_deref().and_then(normalize_wire_api);
9351        let profile_name = profile
9352            .name
9353            .as_deref()
9354            .map(str::trim)
9355            .filter(|value| !value.is_empty());
9356
9357        if normalized_wire_api == Some("responses") {
9358            self.default_provider = Some("openai-codex".to_string());
9359            return;
9360        }
9361
9362        if let Some(profile_name) = profile_name {
9363            if !profile_name.eq_ignore_ascii_case(&profile_key) {
9364                self.default_provider = Some(profile_name.to_string());
9365                return;
9366            }
9367        }
9368
9369        if let Some(base_url) = base_url {
9370            self.default_provider = Some(format!("custom:{base_url}"));
9371        }
9372    }
9373
9374    /// Validate configuration values that would cause runtime failures.
9375    ///
9376    /// Called after TOML deserialization and env-override application to catch
9377    /// obviously invalid values early instead of failing at arbitrary runtime points.
9378    pub fn validate(&self) -> Result<()> {
9379        // Tunnel — OpenVPN
9380        if self.tunnel.provider.trim() == "openvpn" {
9381            let openvpn = self.tunnel.openvpn.as_ref().ok_or_else(|| {
9382                anyhow::anyhow!("tunnel.provider='openvpn' requires [tunnel.openvpn]")
9383            })?;
9384
9385            if openvpn.config_file.trim().is_empty() {
9386                anyhow::bail!("tunnel.openvpn.config_file must not be empty");
9387            }
9388            if openvpn.connect_timeout_secs == 0 {
9389                anyhow::bail!("tunnel.openvpn.connect_timeout_secs must be greater than 0");
9390            }
9391        }
9392
9393        // Gateway
9394        if self.gateway.host.trim().is_empty() {
9395            anyhow::bail!("gateway.host must not be empty");
9396        }
9397        if let Some(ref prefix) = self.gateway.path_prefix {
9398            // Validate the raw value — no silent trimming so the stored
9399            // value is exactly what was validated.
9400            if !prefix.is_empty() {
9401                if !prefix.starts_with('/') {
9402                    anyhow::bail!("gateway.path_prefix must start with '/'");
9403                }
9404                if prefix.ends_with('/') {
9405                    anyhow::bail!("gateway.path_prefix must not end with '/' (including bare '/')");
9406                }
9407                // Reject characters unsafe for URL paths or HTML/JS injection.
9408                // Whitespace is intentionally excluded from the allowed set.
9409                if let Some(bad) = prefix.chars().find(|c| {
9410                    !matches!(c, '/' | '-' | '_' | '.' | '~'
9411                        | 'a'..='z' | 'A'..='Z' | '0'..='9'
9412                        | '!' | '$' | '&' | '\'' | '(' | ')' | '*' | '+' | ',' | ';' | '='
9413                        | ':' | '@')
9414                }) {
9415                    anyhow::bail!(
9416                        "gateway.path_prefix contains invalid character '{bad}'; \
9417                         only unreserved and sub-delim URI characters are allowed"
9418                    );
9419                }
9420            }
9421        }
9422
9423        // Autonomy
9424        if self.autonomy.max_actions_per_hour == 0 {
9425            anyhow::bail!("autonomy.max_actions_per_hour must be greater than 0");
9426        }
9427        for (i, env_name) in self.autonomy.shell_env_passthrough.iter().enumerate() {
9428            if !is_valid_env_var_name(env_name) {
9429                anyhow::bail!(
9430                    "autonomy.shell_env_passthrough[{i}] is invalid ({env_name}); expected [A-Za-z_][A-Za-z0-9_]*"
9431                );
9432            }
9433        }
9434
9435        // Security OTP / estop
9436        if self.security.otp.challenge_max_attempts == 0 {
9437            anyhow::bail!("security.otp.challenge_max_attempts must be greater than 0");
9438        }
9439        if self.security.otp.token_ttl_secs == 0 {
9440            anyhow::bail!("security.otp.token_ttl_secs must be greater than 0");
9441        }
9442        if self.security.otp.cache_valid_secs == 0 {
9443            anyhow::bail!("security.otp.cache_valid_secs must be greater than 0");
9444        }
9445        if self.security.otp.cache_valid_secs < self.security.otp.token_ttl_secs {
9446            anyhow::bail!(
9447                "security.otp.cache_valid_secs must be greater than or equal to security.otp.token_ttl_secs"
9448            );
9449        }
9450        if self.security.otp.challenge_max_attempts == 0 {
9451            anyhow::bail!("security.otp.challenge_max_attempts must be greater than 0");
9452        }
9453        for (i, action) in self.security.otp.gated_actions.iter().enumerate() {
9454            let normalized = action.trim();
9455            if normalized.is_empty() {
9456                anyhow::bail!("security.otp.gated_actions[{i}] must not be empty");
9457            }
9458            if !normalized
9459                .chars()
9460                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
9461            {
9462                anyhow::bail!(
9463                    "security.otp.gated_actions[{i}] contains invalid characters: {normalized}"
9464                );
9465            }
9466        }
9467        DomainMatcher::new(
9468            &self.security.otp.gated_domains,
9469            &self.security.otp.gated_domain_categories,
9470        )
9471        .with_context(
9472            || "Invalid security.otp.gated_domains or security.otp.gated_domain_categories",
9473        )?;
9474        if self.security.estop.state_file.trim().is_empty() {
9475            anyhow::bail!("security.estop.state_file must not be empty");
9476        }
9477
9478        // Scheduler
9479        if self.scheduler.max_concurrent == 0 {
9480            anyhow::bail!("scheduler.max_concurrent must be greater than 0");
9481        }
9482        if self.scheduler.max_tasks == 0 {
9483            anyhow::bail!("scheduler.max_tasks must be greater than 0");
9484        }
9485
9486        // Model routes
9487        for (i, route) in self.model_routes.iter().enumerate() {
9488            if route.hint.trim().is_empty() {
9489                anyhow::bail!("model_routes[{i}].hint must not be empty");
9490            }
9491            if route.provider.trim().is_empty() {
9492                anyhow::bail!("model_routes[{i}].provider must not be empty");
9493            }
9494            if route.model.trim().is_empty() {
9495                anyhow::bail!("model_routes[{i}].model must not be empty");
9496            }
9497        }
9498
9499        // Embedding routes
9500        for (i, route) in self.embedding_routes.iter().enumerate() {
9501            if route.hint.trim().is_empty() {
9502                anyhow::bail!("embedding_routes[{i}].hint must not be empty");
9503            }
9504            if route.provider.trim().is_empty() {
9505                anyhow::bail!("embedding_routes[{i}].provider must not be empty");
9506            }
9507            if route.model.trim().is_empty() {
9508                anyhow::bail!("embedding_routes[{i}].model must not be empty");
9509            }
9510        }
9511
9512        for (profile_key, profile) in &self.model_providers {
9513            let profile_name = profile_key.trim();
9514            if profile_name.is_empty() {
9515                anyhow::bail!("model_providers contains an empty profile name");
9516            }
9517
9518            let has_name = profile
9519                .name
9520                .as_deref()
9521                .map(str::trim)
9522                .is_some_and(|value| !value.is_empty());
9523            let has_base_url = profile
9524                .base_url
9525                .as_deref()
9526                .map(str::trim)
9527                .is_some_and(|value| !value.is_empty());
9528
9529            if !has_name && !has_base_url {
9530                anyhow::bail!(
9531                    "model_providers.{profile_name} must define at least one of `name` or `base_url`"
9532                );
9533            }
9534
9535            if let Some(base_url) = profile.base_url.as_deref().map(str::trim) {
9536                if !base_url.is_empty() {
9537                    let parsed = reqwest::Url::parse(base_url).with_context(|| {
9538                        format!("model_providers.{profile_name}.base_url is not a valid URL")
9539                    })?;
9540                    if !matches!(parsed.scheme(), "http" | "https") {
9541                        anyhow::bail!(
9542                            "model_providers.{profile_name}.base_url must use http/https"
9543                        );
9544                    }
9545                }
9546            }
9547
9548            if let Some(wire_api) = profile.wire_api.as_deref().map(str::trim) {
9549                if !wire_api.is_empty() && normalize_wire_api(wire_api).is_none() {
9550                    anyhow::bail!(
9551                        "model_providers.{profile_name}.wire_api must be one of: responses, chat_completions"
9552                    );
9553                }
9554            }
9555        }
9556
9557        // Ollama cloud-routing safety checks
9558        if self
9559            .default_provider
9560            .as_deref()
9561            .is_some_and(|provider| provider.trim().eq_ignore_ascii_case("ollama"))
9562            && self
9563                .default_model
9564                .as_deref()
9565                .is_some_and(|model| model.trim().ends_with(":cloud"))
9566        {
9567            if is_local_ollama_endpoint(self.api_url.as_deref()) {
9568                anyhow::bail!(
9569                    "default_model uses ':cloud' with provider 'ollama', but api_url is local or unset. Set api_url to a remote Ollama endpoint (for example https://ollama.com)."
9570                );
9571            }
9572
9573            if !has_ollama_cloud_credential(self.api_key.as_deref()) {
9574                anyhow::bail!(
9575                    "default_model uses ':cloud' with provider 'ollama', but no API key is configured. Set api_key or OLLAMA_API_KEY."
9576                );
9577            }
9578        }
9579
9580        // Microsoft 365
9581        if self.microsoft365.enabled {
9582            let tenant = self
9583                .microsoft365
9584                .tenant_id
9585                .as_deref()
9586                .map(str::trim)
9587                .filter(|s| !s.is_empty());
9588            if tenant.is_none() {
9589                anyhow::bail!(
9590                    "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
9591                );
9592            }
9593            let client = self
9594                .microsoft365
9595                .client_id
9596                .as_deref()
9597                .map(str::trim)
9598                .filter(|s| !s.is_empty());
9599            if client.is_none() {
9600                anyhow::bail!(
9601                    "microsoft365.client_id must not be empty when microsoft365 is enabled"
9602                );
9603            }
9604            let flow = self.microsoft365.auth_flow.trim();
9605            if flow != "client_credentials" && flow != "device_code" {
9606                anyhow::bail!(
9607                    "microsoft365.auth_flow must be 'client_credentials' or 'device_code'"
9608                );
9609            }
9610            if flow == "client_credentials"
9611                && self
9612                    .microsoft365
9613                    .client_secret
9614                    .as_deref()
9615                    .map_or(true, |s| s.trim().is_empty())
9616            {
9617                anyhow::bail!(
9618                    "microsoft365.client_secret must not be empty when auth_flow is 'client_credentials'"
9619                );
9620            }
9621        }
9622
9623        // Microsoft 365
9624        if self.microsoft365.enabled {
9625            let tenant = self
9626                .microsoft365
9627                .tenant_id
9628                .as_deref()
9629                .map(str::trim)
9630                .filter(|s| !s.is_empty());
9631            if tenant.is_none() {
9632                anyhow::bail!(
9633                    "microsoft365.tenant_id must not be empty when microsoft365 is enabled"
9634                );
9635            }
9636            let client = self
9637                .microsoft365
9638                .client_id
9639                .as_deref()
9640                .map(str::trim)
9641                .filter(|s| !s.is_empty());
9642            if client.is_none() {
9643                anyhow::bail!(
9644                    "microsoft365.client_id must not be empty when microsoft365 is enabled"
9645                );
9646            }
9647            let flow = self.microsoft365.auth_flow.trim();
9648            if flow != "client_credentials" && flow != "device_code" {
9649                anyhow::bail!("microsoft365.auth_flow must be client_credentials or device_code");
9650            }
9651            if flow == "client_credentials"
9652                && self
9653                    .microsoft365
9654                    .client_secret
9655                    .as_deref()
9656                    .map_or(true, |s| s.trim().is_empty())
9657            {
9658                anyhow::bail!(
9659                    "microsoft365.client_secret must not be empty when auth_flow is client_credentials"
9660                );
9661            }
9662        }
9663
9664        // MCP
9665        if self.mcp.enabled {
9666            validate_mcp_config(&self.mcp)?;
9667        }
9668
9669        // Google Workspace allowed_services validation
9670        let mut seen_gws_services = std::collections::HashSet::new();
9671        for (i, service) in self.google_workspace.allowed_services.iter().enumerate() {
9672            let normalized = service.trim();
9673            if normalized.is_empty() {
9674                anyhow::bail!("google_workspace.allowed_services[{i}] must not be empty");
9675            }
9676            if !normalized
9677                .chars()
9678                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
9679            {
9680                anyhow::bail!(
9681                    "google_workspace.allowed_services[{i}] contains invalid characters: {normalized}"
9682                );
9683            }
9684            if !seen_gws_services.insert(normalized.to_string()) {
9685                anyhow::bail!(
9686                    "google_workspace.allowed_services contains duplicate entry: {normalized}"
9687                );
9688            }
9689        }
9690
9691        // Build the effective allowed-services set for cross-validation.
9692        // When the operator leaves allowed_services empty the tool falls back to
9693        // DEFAULT_GWS_SERVICES; use the same constant here so validation is
9694        // consistent in both cases.
9695        let effective_services: std::collections::HashSet<&str> =
9696            if self.google_workspace.allowed_services.is_empty() {
9697                DEFAULT_GWS_SERVICES.iter().copied().collect()
9698            } else {
9699                self.google_workspace
9700                    .allowed_services
9701                    .iter()
9702                    .map(|s| s.trim())
9703                    .collect()
9704            };
9705
9706        let mut seen_gws_operations = std::collections::HashSet::new();
9707        for (i, operation) in self.google_workspace.allowed_operations.iter().enumerate() {
9708            let service = operation.service.trim();
9709            let resource = operation.resource.trim();
9710
9711            if service.is_empty() {
9712                anyhow::bail!("google_workspace.allowed_operations[{i}].service must not be empty");
9713            }
9714            if resource.is_empty() {
9715                anyhow::bail!(
9716                    "google_workspace.allowed_operations[{i}].resource must not be empty"
9717                );
9718            }
9719
9720            if !effective_services.contains(service) {
9721                anyhow::bail!(
9722                    "google_workspace.allowed_operations[{i}].service '{service}' is not in the \
9723                     effective allowed_services; this entry can never match at runtime"
9724                );
9725            }
9726            if !service
9727                .chars()
9728                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
9729            {
9730                anyhow::bail!(
9731                    "google_workspace.allowed_operations[{i}].service contains invalid characters: {service}"
9732                );
9733            }
9734            if !resource
9735                .chars()
9736                .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
9737            {
9738                anyhow::bail!(
9739                    "google_workspace.allowed_operations[{i}].resource contains invalid characters: {resource}"
9740                );
9741            }
9742
9743            if let Some(ref sub_resource) = operation.sub_resource {
9744                let sub = sub_resource.trim();
9745                if sub.is_empty() {
9746                    anyhow::bail!(
9747                        "google_workspace.allowed_operations[{i}].sub_resource must not be empty when present"
9748                    );
9749                }
9750                if !sub
9751                    .chars()
9752                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
9753                {
9754                    anyhow::bail!(
9755                        "google_workspace.allowed_operations[{i}].sub_resource contains invalid characters: {sub}"
9756                    );
9757                }
9758            }
9759
9760            if operation.methods.is_empty() {
9761                anyhow::bail!("google_workspace.allowed_operations[{i}].methods must not be empty");
9762            }
9763
9764            let mut seen_methods = std::collections::HashSet::new();
9765            for (j, method) in operation.methods.iter().enumerate() {
9766                let normalized = method.trim();
9767                if normalized.is_empty() {
9768                    anyhow::bail!(
9769                        "google_workspace.allowed_operations[{i}].methods[{j}] must not be empty"
9770                    );
9771                }
9772                if !normalized
9773                    .chars()
9774                    .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
9775                {
9776                    anyhow::bail!(
9777                        "google_workspace.allowed_operations[{i}].methods[{j}] contains invalid characters: {normalized}"
9778                    );
9779                }
9780                if !seen_methods.insert(normalized.to_string()) {
9781                    anyhow::bail!(
9782                        "google_workspace.allowed_operations[{i}].methods contains duplicate entry: {normalized}"
9783                    );
9784                }
9785            }
9786
9787            let sub_key = operation
9788                .sub_resource
9789                .as_deref()
9790                .map(str::trim)
9791                .unwrap_or("");
9792            let operation_key = format!("{service}:{resource}:{sub_key}");
9793            if !seen_gws_operations.insert(operation_key.clone()) {
9794                anyhow::bail!(
9795                    "google_workspace.allowed_operations contains duplicate service/resource/sub_resource entry: {operation_key}"
9796                );
9797            }
9798        }
9799
9800        // Project intelligence
9801        if self.project_intel.enabled {
9802            let lang = &self.project_intel.default_language;
9803            if !["en", "de", "fr", "it"].contains(&lang.as_str()) {
9804                anyhow::bail!(
9805                    "project_intel.default_language must be one of: en, de, fr, it (got '{lang}')"
9806                );
9807            }
9808            let sens = &self.project_intel.risk_sensitivity;
9809            if !["low", "medium", "high"].contains(&sens.as_str()) {
9810                anyhow::bail!(
9811                    "project_intel.risk_sensitivity must be one of: low, medium, high (got '{sens}')"
9812                );
9813            }
9814            if let Some(ref tpl_dir) = self.project_intel.templates_dir {
9815                let path = std::path::Path::new(tpl_dir);
9816                if !path.exists() {
9817                    anyhow::bail!("project_intel.templates_dir path does not exist: {tpl_dir}");
9818                }
9819            }
9820        }
9821
9822        // Proxy (delegate to existing validation)
9823        self.proxy.validate()?;
9824        self.cloud_ops.validate()?;
9825
9826        // Notion
9827        if self.notion.enabled {
9828            if self.notion.database_id.trim().is_empty() {
9829                anyhow::bail!("notion.database_id must not be empty when notion.enabled = true");
9830            }
9831            if self.notion.poll_interval_secs == 0 {
9832                anyhow::bail!("notion.poll_interval_secs must be greater than 0");
9833            }
9834            if self.notion.max_concurrent == 0 {
9835                anyhow::bail!("notion.max_concurrent must be greater than 0");
9836            }
9837            if self.notion.status_property.trim().is_empty() {
9838                anyhow::bail!("notion.status_property must not be empty");
9839            }
9840            if self.notion.input_property.trim().is_empty() {
9841                anyhow::bail!("notion.input_property must not be empty");
9842            }
9843            if self.notion.result_property.trim().is_empty() {
9844                anyhow::bail!("notion.result_property must not be empty");
9845            }
9846        }
9847
9848        // Pinggy tunnel region — validate allowed values (case-insensitive, auto-lowercased at runtime).
9849        if let Some(ref pinggy) = self.tunnel.pinggy {
9850            if let Some(ref region) = pinggy.region {
9851                let r = region.trim().to_ascii_lowercase();
9852                if !r.is_empty() && !matches!(r.as_str(), "us" | "eu" | "ap" | "br" | "au") {
9853                    anyhow::bail!(
9854                        "tunnel.pinggy.region must be one of: us, eu, ap, br, au (or omitted for auto)"
9855                    );
9856                }
9857            }
9858        }
9859
9860        // Jira
9861        if self.jira.enabled {
9862            if self.jira.base_url.trim().is_empty() {
9863                anyhow::bail!("jira.base_url must not be empty when jira.enabled = true");
9864            }
9865            if self.jira.email.trim().is_empty() {
9866                anyhow::bail!("jira.email must not be empty when jira.enabled = true");
9867            }
9868            if self.jira.api_token.trim().is_empty()
9869                && std::env::var("JIRA_API_TOKEN")
9870                    .unwrap_or_default()
9871                    .trim()
9872                    .is_empty()
9873            {
9874                anyhow::bail!(
9875                    "jira.api_token must be set (or JIRA_API_TOKEN env var) when jira.enabled = true"
9876                );
9877            }
9878            let valid_actions = ["get_ticket", "search_tickets", "comment_ticket"];
9879            for action in &self.jira.allowed_actions {
9880                if !valid_actions.contains(&action.as_str()) {
9881                    anyhow::bail!(
9882                        "jira.allowed_actions contains unknown action: '{}'. \
9883                         Valid: get_ticket, search_tickets, comment_ticket",
9884                        action
9885                    );
9886                }
9887            }
9888        }
9889
9890        // Nevis IAM — delegate to NevisConfig::validate() for field-level checks
9891        if let Err(msg) = self.security.nevis.validate() {
9892            anyhow::bail!("security.nevis: {msg}");
9893        }
9894
9895        // Delegate agent timeouts
9896        const MAX_DELEGATE_TIMEOUT_SECS: u64 = 3600;
9897        for (name, agent) in &self.agents {
9898            if let Some(timeout) = agent.timeout_secs {
9899                if timeout == 0 {
9900                    anyhow::bail!("agents.{name}.timeout_secs must be greater than 0");
9901                }
9902                if timeout > MAX_DELEGATE_TIMEOUT_SECS {
9903                    anyhow::bail!(
9904                        "agents.{name}.timeout_secs exceeds max {MAX_DELEGATE_TIMEOUT_SECS}"
9905                    );
9906                }
9907            }
9908            if let Some(timeout) = agent.agentic_timeout_secs {
9909                if timeout == 0 {
9910                    anyhow::bail!("agents.{name}.agentic_timeout_secs must be greater than 0");
9911                }
9912                if timeout > MAX_DELEGATE_TIMEOUT_SECS {
9913                    anyhow::bail!(
9914                        "agents.{name}.agentic_timeout_secs exceeds max {MAX_DELEGATE_TIMEOUT_SECS}"
9915                    );
9916                }
9917            }
9918        }
9919
9920        // Transcription
9921        {
9922            let dp = self.transcription.default_provider.trim();
9923            match dp {
9924                "groq" | "openai" | "deepgram" | "assemblyai" | "google" | "local_whisper" => {}
9925                other => {
9926                    anyhow::bail!(
9927                        "transcription.default_provider must be one of: groq, openai, deepgram, assemblyai, google, local_whisper (got '{other}')"
9928                    );
9929                }
9930            }
9931        }
9932
9933        // Delegate tool global defaults
9934        if self.delegate.timeout_secs == 0 {
9935            anyhow::bail!("delegate.timeout_secs must be greater than 0");
9936        }
9937        if self.delegate.agentic_timeout_secs == 0 {
9938            anyhow::bail!("delegate.agentic_timeout_secs must be greater than 0");
9939        }
9940
9941        // Per-agent delegate timeout overrides
9942        for (name, agent) in &self.agents {
9943            if let Some(t) = agent.timeout_secs {
9944                if t == 0 {
9945                    anyhow::bail!("agents.{name}.timeout_secs must be greater than 0");
9946                }
9947            }
9948            if let Some(t) = agent.agentic_timeout_secs {
9949                if t == 0 {
9950                    anyhow::bail!("agents.{name}.agentic_timeout_secs must be greater than 0");
9951                }
9952            }
9953        }
9954
9955        Ok(())
9956    }
9957
9958    /// Apply environment variable overrides to config
9959    pub fn apply_env_overrides(&mut self) {
9960        // API Key: CONSTRUCT_API_KEY or API_KEY (generic)
9961        if let Ok(key) = std::env::var("CONSTRUCT_API_KEY").or_else(|_| std::env::var("API_KEY")) {
9962            if !key.is_empty() {
9963                self.api_key = Some(key);
9964            }
9965        }
9966        // API Key: GLM_API_KEY overrides when provider is a GLM/Zhipu variant.
9967        if self.default_provider.as_deref().is_some_and(is_glm_alias) {
9968            if let Ok(key) = std::env::var("GLM_API_KEY") {
9969                if !key.is_empty() {
9970                    self.api_key = Some(key);
9971                }
9972            }
9973        }
9974
9975        // API Key: ZAI_API_KEY overrides when provider is a Z.AI variant.
9976        if self.default_provider.as_deref().is_some_and(is_zai_alias) {
9977            if let Ok(key) = std::env::var("ZAI_API_KEY") {
9978                if !key.is_empty() {
9979                    self.api_key = Some(key);
9980                }
9981            }
9982        }
9983
9984        // Provider override precedence:
9985        // 1) CONSTRUCT_PROVIDER always wins when set.
9986        // 2) CONSTRUCT_MODEL_PROVIDER/MODEL_PROVIDER (Codex app-server style).
9987        // 3) Legacy PROVIDER is honored only when config still uses default provider.
9988        if let Ok(provider) = std::env::var("CONSTRUCT_PROVIDER") {
9989            if !provider.is_empty() {
9990                self.default_provider = Some(provider);
9991            }
9992        } else if let Ok(provider) =
9993            std::env::var("CONSTRUCT_MODEL_PROVIDER").or_else(|_| std::env::var("MODEL_PROVIDER"))
9994        {
9995            if !provider.is_empty() {
9996                self.default_provider = Some(provider);
9997            }
9998        } else if let Ok(provider) = std::env::var("PROVIDER") {
9999            let should_apply_legacy_provider =
10000                self.default_provider.as_deref().map_or(true, |configured| {
10001                    configured.trim().eq_ignore_ascii_case("openrouter")
10002                });
10003            if should_apply_legacy_provider && !provider.is_empty() {
10004                self.default_provider = Some(provider);
10005            }
10006        }
10007
10008        // Model: CONSTRUCT_MODEL or MODEL
10009        if let Ok(model) = std::env::var("CONSTRUCT_MODEL").or_else(|_| std::env::var("MODEL")) {
10010            if !model.is_empty() {
10011                self.default_model = Some(model);
10012            }
10013        }
10014
10015        // Provider HTTP timeout: CONSTRUCT_PROVIDER_TIMEOUT_SECS
10016        if let Ok(timeout_secs) = std::env::var("CONSTRUCT_PROVIDER_TIMEOUT_SECS") {
10017            if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
10018                if timeout_secs > 0 {
10019                    self.provider_timeout_secs = timeout_secs;
10020                }
10021            }
10022        }
10023
10024        // Extra provider headers: CONSTRUCT_EXTRA_HEADERS
10025        // Format: "Key:Value,Key2:Value2"
10026        // Env var headers override config file headers with the same name.
10027        if let Ok(raw) = std::env::var("CONSTRUCT_EXTRA_HEADERS") {
10028            for header in parse_extra_headers_env(&raw) {
10029                self.extra_headers.insert(header.0, header.1);
10030            }
10031        }
10032
10033        // Apply named provider profile remapping (Codex app-server compatibility).
10034        self.apply_named_model_provider_profile();
10035
10036        // Workspace directory: CONSTRUCT_WORKSPACE
10037        if let Ok(workspace) = std::env::var("CONSTRUCT_WORKSPACE") {
10038            if !workspace.is_empty() {
10039                let expanded = expand_tilde_path(&workspace);
10040                let (_, workspace_dir) = resolve_config_dir_for_workspace(&expanded);
10041                self.workspace_dir = workspace_dir;
10042            }
10043        }
10044
10045        // Open-skills opt-in flag: CONSTRUCT_OPEN_SKILLS_ENABLED
10046        if let Ok(flag) = std::env::var("CONSTRUCT_OPEN_SKILLS_ENABLED") {
10047            if !flag.trim().is_empty() {
10048                match flag.trim().to_ascii_lowercase().as_str() {
10049                    "1" | "true" | "yes" | "on" => self.skills.open_skills_enabled = true,
10050                    "0" | "false" | "no" | "off" => self.skills.open_skills_enabled = false,
10051                    _ => tracing::warn!(
10052                        "Ignoring invalid CONSTRUCT_OPEN_SKILLS_ENABLED (valid: 1|0|true|false|yes|no|on|off)"
10053                    ),
10054                }
10055            }
10056        }
10057
10058        // Open-skills directory override: CONSTRUCT_OPEN_SKILLS_DIR
10059        if let Ok(path) = std::env::var("CONSTRUCT_OPEN_SKILLS_DIR") {
10060            let trimmed = path.trim();
10061            if !trimmed.is_empty() {
10062                self.skills.open_skills_dir = Some(trimmed.to_string());
10063            }
10064        }
10065
10066        // Skills script-file audit override: CONSTRUCT_SKILLS_ALLOW_SCRIPTS
10067        if let Ok(flag) = std::env::var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS") {
10068            if !flag.trim().is_empty() {
10069                match flag.trim().to_ascii_lowercase().as_str() {
10070                    "1" | "true" | "yes" | "on" => self.skills.allow_scripts = true,
10071                    "0" | "false" | "no" | "off" => self.skills.allow_scripts = false,
10072                    _ => tracing::warn!(
10073                        "Ignoring invalid CONSTRUCT_SKILLS_ALLOW_SCRIPTS (valid: 1|0|true|false|yes|no|on|off)"
10074                    ),
10075                }
10076            }
10077        }
10078
10079        // Skills prompt mode override: CONSTRUCT_SKILLS_PROMPT_MODE
10080        if let Ok(mode) = std::env::var("CONSTRUCT_SKILLS_PROMPT_MODE") {
10081            if !mode.trim().is_empty() {
10082                if let Some(parsed) = parse_skills_prompt_injection_mode(&mode) {
10083                    self.skills.prompt_injection_mode = parsed;
10084                } else {
10085                    tracing::warn!(
10086                        "Ignoring invalid CONSTRUCT_SKILLS_PROMPT_MODE (valid: full|compact)"
10087                    );
10088                }
10089            }
10090        }
10091
10092        // Gateway port: CONSTRUCT_GATEWAY_PORT or PORT
10093        if let Ok(port_str) =
10094            std::env::var("CONSTRUCT_GATEWAY_PORT").or_else(|_| std::env::var("PORT"))
10095        {
10096            if let Ok(port) = port_str.parse::<u16>() {
10097                self.gateway.port = port;
10098            }
10099        }
10100
10101        // Gateway host: CONSTRUCT_GATEWAY_HOST or HOST
10102        if let Ok(host) = std::env::var("CONSTRUCT_GATEWAY_HOST").or_else(|_| std::env::var("HOST"))
10103        {
10104            if !host.is_empty() {
10105                self.gateway.host = host;
10106            }
10107        }
10108
10109        // Allow public bind: CONSTRUCT_ALLOW_PUBLIC_BIND
10110        if let Ok(val) = std::env::var("CONSTRUCT_ALLOW_PUBLIC_BIND") {
10111            self.gateway.allow_public_bind = val == "1" || val.eq_ignore_ascii_case("true");
10112        }
10113
10114        // Require pairing: CONSTRUCT_REQUIRE_PAIRING
10115        if let Ok(val) = std::env::var("CONSTRUCT_REQUIRE_PAIRING") {
10116            self.gateway.require_pairing = val == "1" || val.eq_ignore_ascii_case("true");
10117        }
10118
10119        // Temperature: CONSTRUCT_TEMPERATURE
10120        if let Ok(temp_str) = std::env::var("CONSTRUCT_TEMPERATURE") {
10121            match temp_str.parse::<f64>() {
10122                Ok(temp) if TEMPERATURE_RANGE.contains(&temp) => {
10123                    self.default_temperature = temp;
10124                }
10125                Ok(temp) => {
10126                    tracing::warn!(
10127                        "Ignoring CONSTRUCT_TEMPERATURE={temp}: \
10128                         value out of range (expected {}..={})",
10129                        TEMPERATURE_RANGE.start(),
10130                        TEMPERATURE_RANGE.end()
10131                    );
10132                }
10133                Err(_) => {
10134                    tracing::warn!(
10135                        "Ignoring CONSTRUCT_TEMPERATURE={temp_str:?}: not a valid number"
10136                    );
10137                }
10138            }
10139        }
10140
10141        // Reasoning override: CONSTRUCT_REASONING_ENABLED or REASONING_ENABLED
10142        if let Ok(flag) = std::env::var("CONSTRUCT_REASONING_ENABLED")
10143            .or_else(|_| std::env::var("REASONING_ENABLED"))
10144        {
10145            let normalized = flag.trim().to_ascii_lowercase();
10146            match normalized.as_str() {
10147                "1" | "true" | "yes" | "on" => self.runtime.reasoning_enabled = Some(true),
10148                "0" | "false" | "no" | "off" => self.runtime.reasoning_enabled = Some(false),
10149                _ => {}
10150            }
10151        }
10152
10153        if let Ok(raw) = std::env::var("CONSTRUCT_REASONING_EFFORT")
10154            .or_else(|_| std::env::var("REASONING_EFFORT"))
10155            .or_else(|_| std::env::var("CONSTRUCT_CODEX_REASONING_EFFORT"))
10156        {
10157            match normalize_reasoning_effort(&raw) {
10158                Ok(effort) => self.runtime.reasoning_effort = Some(effort),
10159                Err(message) => tracing::warn!("Ignoring reasoning effort env override: {message}"),
10160            }
10161        }
10162
10163        // Web search enabled: CONSTRUCT_WEB_SEARCH_ENABLED or WEB_SEARCH_ENABLED
10164        if let Ok(enabled) = std::env::var("CONSTRUCT_WEB_SEARCH_ENABLED")
10165            .or_else(|_| std::env::var("WEB_SEARCH_ENABLED"))
10166        {
10167            self.web_search.enabled = enabled == "1" || enabled.eq_ignore_ascii_case("true");
10168        }
10169
10170        // Web search provider: CONSTRUCT_WEB_SEARCH_PROVIDER or WEB_SEARCH_PROVIDER
10171        if let Ok(provider) = std::env::var("CONSTRUCT_WEB_SEARCH_PROVIDER")
10172            .or_else(|_| std::env::var("WEB_SEARCH_PROVIDER"))
10173        {
10174            let provider = provider.trim();
10175            if !provider.is_empty() {
10176                self.web_search.provider = provider.to_string();
10177            }
10178        }
10179
10180        // Brave API key: CONSTRUCT_BRAVE_API_KEY or BRAVE_API_KEY
10181        if let Ok(api_key) =
10182            std::env::var("CONSTRUCT_BRAVE_API_KEY").or_else(|_| std::env::var("BRAVE_API_KEY"))
10183        {
10184            let api_key = api_key.trim();
10185            if !api_key.is_empty() {
10186                self.web_search.brave_api_key = Some(api_key.to_string());
10187            }
10188        }
10189
10190        // SearXNG instance URL: CONSTRUCT_SEARXNG_INSTANCE_URL or SEARXNG_INSTANCE_URL
10191        if let Ok(instance_url) = std::env::var("CONSTRUCT_SEARXNG_INSTANCE_URL")
10192            .or_else(|_| std::env::var("SEARXNG_INSTANCE_URL"))
10193        {
10194            let instance_url = instance_url.trim();
10195            if !instance_url.is_empty() {
10196                self.web_search.searxng_instance_url = Some(instance_url.to_string());
10197            }
10198        }
10199
10200        // Web search max results: CONSTRUCT_WEB_SEARCH_MAX_RESULTS or WEB_SEARCH_MAX_RESULTS
10201        if let Ok(max_results) = std::env::var("CONSTRUCT_WEB_SEARCH_MAX_RESULTS")
10202            .or_else(|_| std::env::var("WEB_SEARCH_MAX_RESULTS"))
10203        {
10204            if let Ok(max_results) = max_results.parse::<usize>() {
10205                if (1..=10).contains(&max_results) {
10206                    self.web_search.max_results = max_results;
10207                }
10208            }
10209        }
10210
10211        // Web search timeout: CONSTRUCT_WEB_SEARCH_TIMEOUT_SECS or WEB_SEARCH_TIMEOUT_SECS
10212        if let Ok(timeout_secs) = std::env::var("CONSTRUCT_WEB_SEARCH_TIMEOUT_SECS")
10213            .or_else(|_| std::env::var("WEB_SEARCH_TIMEOUT_SECS"))
10214        {
10215            if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
10216                if timeout_secs > 0 {
10217                    self.web_search.timeout_secs = timeout_secs;
10218                }
10219            }
10220        }
10221
10222        // Storage provider key (optional backend override): CONSTRUCT_STORAGE_PROVIDER
10223        if let Ok(provider) = std::env::var("CONSTRUCT_STORAGE_PROVIDER") {
10224            let provider = provider.trim();
10225            if !provider.is_empty() {
10226                self.storage.provider.config.provider = provider.to_string();
10227            }
10228        }
10229
10230        // Storage connection URL (for remote backends): CONSTRUCT_STORAGE_DB_URL
10231        if let Ok(db_url) = std::env::var("CONSTRUCT_STORAGE_DB_URL") {
10232            let db_url = db_url.trim();
10233            if !db_url.is_empty() {
10234                self.storage.provider.config.db_url = Some(db_url.to_string());
10235            }
10236        }
10237
10238        // Storage connect timeout: CONSTRUCT_STORAGE_CONNECT_TIMEOUT_SECS
10239        if let Ok(timeout_secs) = std::env::var("CONSTRUCT_STORAGE_CONNECT_TIMEOUT_SECS") {
10240            if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
10241                if timeout_secs > 0 {
10242                    self.storage.provider.config.connect_timeout_secs = Some(timeout_secs);
10243                }
10244            }
10245        }
10246        // Proxy enabled flag: CONSTRUCT_PROXY_ENABLED
10247        let explicit_proxy_enabled = std::env::var("CONSTRUCT_PROXY_ENABLED")
10248            .ok()
10249            .as_deref()
10250            .and_then(parse_proxy_enabled);
10251        if let Some(enabled) = explicit_proxy_enabled {
10252            self.proxy.enabled = enabled;
10253        }
10254
10255        // Proxy URLs: CONSTRUCT_* wins, then generic *PROXY vars.
10256        let mut proxy_url_overridden = false;
10257        if let Ok(proxy_url) =
10258            std::env::var("CONSTRUCT_HTTP_PROXY").or_else(|_| std::env::var("HTTP_PROXY"))
10259        {
10260            self.proxy.http_proxy = normalize_proxy_url_option(Some(&proxy_url));
10261            proxy_url_overridden = true;
10262        }
10263        if let Ok(proxy_url) =
10264            std::env::var("CONSTRUCT_HTTPS_PROXY").or_else(|_| std::env::var("HTTPS_PROXY"))
10265        {
10266            self.proxy.https_proxy = normalize_proxy_url_option(Some(&proxy_url));
10267            proxy_url_overridden = true;
10268        }
10269        if let Ok(proxy_url) =
10270            std::env::var("CONSTRUCT_ALL_PROXY").or_else(|_| std::env::var("ALL_PROXY"))
10271        {
10272            self.proxy.all_proxy = normalize_proxy_url_option(Some(&proxy_url));
10273            proxy_url_overridden = true;
10274        }
10275        if let Ok(no_proxy) =
10276            std::env::var("CONSTRUCT_NO_PROXY").or_else(|_| std::env::var("NO_PROXY"))
10277        {
10278            self.proxy.no_proxy = normalize_no_proxy_list(vec![no_proxy]);
10279        }
10280
10281        if explicit_proxy_enabled.is_none()
10282            && proxy_url_overridden
10283            && self.proxy.has_any_proxy_url()
10284        {
10285            self.proxy.enabled = true;
10286        }
10287
10288        // Proxy scope and service selectors.
10289        if let Ok(scope_raw) = std::env::var("CONSTRUCT_PROXY_SCOPE") {
10290            if let Some(scope) = parse_proxy_scope(&scope_raw) {
10291                self.proxy.scope = scope;
10292            } else {
10293                tracing::warn!(
10294                    scope = %scope_raw,
10295                    "Ignoring invalid CONSTRUCT_PROXY_SCOPE (valid: environment|construct|services)"
10296                );
10297            }
10298        }
10299
10300        if let Ok(services_raw) = std::env::var("CONSTRUCT_PROXY_SERVICES") {
10301            self.proxy.services = normalize_service_list(vec![services_raw]);
10302        }
10303
10304        if let Err(error) = self.proxy.validate() {
10305            tracing::warn!("Invalid proxy configuration ignored: {error}");
10306            self.proxy.enabled = false;
10307        }
10308
10309        if self.proxy.enabled && self.proxy.scope == ProxyScope::Environment {
10310            self.proxy.apply_to_process_env();
10311        }
10312
10313        set_runtime_proxy_config(self.proxy.clone());
10314
10315        if self.conversational_ai.enabled {
10316            tracing::warn!(
10317                "conversational_ai.enabled = true but conversational AI features are not yet \
10318                 implemented; this section is reserved for future use and will be ignored"
10319            );
10320        }
10321    }
10322
10323    async fn resolve_config_path_for_save(&self) -> Result<PathBuf> {
10324        if self
10325            .config_path
10326            .parent()
10327            .is_some_and(|parent| !parent.as_os_str().is_empty())
10328        {
10329            return Ok(self.config_path.clone());
10330        }
10331
10332        let (default_construct_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
10333        let (construct_dir, _workspace_dir, source) =
10334            resolve_runtime_config_dirs(&default_construct_dir, &default_workspace_dir).await?;
10335        let file_name = self
10336            .config_path
10337            .file_name()
10338            .filter(|name| !name.is_empty())
10339            .unwrap_or_else(|| std::ffi::OsStr::new("config.toml"));
10340        let resolved = construct_dir.join(file_name);
10341        tracing::warn!(
10342            path = %self.config_path.display(),
10343            resolved = %resolved.display(),
10344            source = source.as_str(),
10345            "Config path missing parent directory; resolving from runtime environment"
10346        );
10347        Ok(resolved)
10348    }
10349
10350    pub async fn save(&self) -> Result<()> {
10351        // Encrypt secrets before serialization
10352        let mut config_to_save = self.clone();
10353        let config_path = self.resolve_config_path_for_save().await?;
10354        let construct_dir = config_path
10355            .parent()
10356            .context("Config path must have a parent directory")?;
10357        let store = crate::security::SecretStore::new(construct_dir, self.secrets.encrypt);
10358
10359        encrypt_optional_secret(&store, &mut config_to_save.api_key, "config.api_key")?;
10360        encrypt_optional_secret(
10361            &store,
10362            &mut config_to_save.composio.api_key,
10363            "config.composio.api_key",
10364        )?;
10365        if let Some(ref mut pinggy) = config_to_save.tunnel.pinggy {
10366            encrypt_optional_secret(&store, &mut pinggy.token, "config.tunnel.pinggy.token")?;
10367        }
10368        encrypt_optional_secret(
10369            &store,
10370            &mut config_to_save.microsoft365.client_secret,
10371            "config.microsoft365.client_secret",
10372        )?;
10373
10374        encrypt_optional_secret(
10375            &store,
10376            &mut config_to_save.browser.computer_use.api_key,
10377            "config.browser.computer_use.api_key",
10378        )?;
10379
10380        encrypt_optional_secret(
10381            &store,
10382            &mut config_to_save.web_search.brave_api_key,
10383            "config.web_search.brave_api_key",
10384        )?;
10385
10386        encrypt_optional_secret(
10387            &store,
10388            &mut config_to_save.storage.provider.config.db_url,
10389            "config.storage.provider.config.db_url",
10390        )?;
10391
10392        for agent in config_to_save.agents.values_mut() {
10393            encrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
10394        }
10395
10396        // Encrypt TTS provider API keys
10397        if let Some(ref mut openai) = config_to_save.tts.openai {
10398            encrypt_optional_secret(&store, &mut openai.api_key, "config.tts.openai.api_key")?;
10399        }
10400        if let Some(ref mut elevenlabs) = config_to_save.tts.elevenlabs {
10401            encrypt_optional_secret(
10402                &store,
10403                &mut elevenlabs.api_key,
10404                "config.tts.elevenlabs.api_key",
10405            )?;
10406        }
10407        if let Some(ref mut google) = config_to_save.tts.google {
10408            encrypt_optional_secret(&store, &mut google.api_key, "config.tts.google.api_key")?;
10409        }
10410
10411        // Encrypt nested STT provider API keys
10412        encrypt_optional_secret(
10413            &store,
10414            &mut config_to_save.transcription.api_key,
10415            "config.transcription.api_key",
10416        )?;
10417        if let Some(ref mut openai) = config_to_save.transcription.openai {
10418            encrypt_optional_secret(
10419                &store,
10420                &mut openai.api_key,
10421                "config.transcription.openai.api_key",
10422            )?;
10423        }
10424        if let Some(ref mut deepgram) = config_to_save.transcription.deepgram {
10425            encrypt_optional_secret(
10426                &store,
10427                &mut deepgram.api_key,
10428                "config.transcription.deepgram.api_key",
10429            )?;
10430        }
10431        if let Some(ref mut assemblyai) = config_to_save.transcription.assemblyai {
10432            encrypt_optional_secret(
10433                &store,
10434                &mut assemblyai.api_key,
10435                "config.transcription.assemblyai.api_key",
10436            )?;
10437        }
10438        if let Some(ref mut google) = config_to_save.transcription.google {
10439            encrypt_optional_secret(
10440                &store,
10441                &mut google.api_key,
10442                "config.transcription.google.api_key",
10443            )?;
10444        }
10445        if let Some(ref mut local) = config_to_save.transcription.local_whisper {
10446            encrypt_optional_secret(
10447                &store,
10448                &mut local.bearer_token,
10449                "config.transcription.local_whisper.bearer_token",
10450            )?;
10451        }
10452
10453        #[cfg(feature = "channel-nostr")]
10454        if let Some(ref mut ns) = config_to_save.channels_config.nostr {
10455            encrypt_secret(
10456                &store,
10457                &mut ns.private_key,
10458                "config.channels_config.nostr.private_key",
10459            )?;
10460        }
10461        if let Some(ref mut fs) = config_to_save.channels_config.feishu {
10462            encrypt_secret(
10463                &store,
10464                &mut fs.app_secret,
10465                "config.channels_config.feishu.app_secret",
10466            )?;
10467            encrypt_optional_secret(
10468                &store,
10469                &mut fs.encrypt_key,
10470                "config.channels_config.feishu.encrypt_key",
10471            )?;
10472            encrypt_optional_secret(
10473                &store,
10474                &mut fs.verification_token,
10475                "config.channels_config.feishu.verification_token",
10476            )?;
10477        }
10478
10479        // Encrypt channel secrets
10480        if let Some(ref mut tg) = config_to_save.channels_config.telegram {
10481            encrypt_secret(
10482                &store,
10483                &mut tg.bot_token,
10484                "config.channels_config.telegram.bot_token",
10485            )?;
10486        }
10487        if let Some(ref mut dc) = config_to_save.channels_config.discord {
10488            encrypt_secret(
10489                &store,
10490                &mut dc.bot_token,
10491                "config.channels_config.discord.bot_token",
10492            )?;
10493        }
10494        if let Some(ref mut sl) = config_to_save.channels_config.slack {
10495            encrypt_secret(
10496                &store,
10497                &mut sl.bot_token,
10498                "config.channels_config.slack.bot_token",
10499            )?;
10500            encrypt_optional_secret(
10501                &store,
10502                &mut sl.app_token,
10503                "config.channels_config.slack.app_token",
10504            )?;
10505        }
10506        if let Some(ref mut mm) = config_to_save.channels_config.mattermost {
10507            encrypt_secret(
10508                &store,
10509                &mut mm.bot_token,
10510                "config.channels_config.mattermost.bot_token",
10511            )?;
10512        }
10513        if let Some(ref mut mx) = config_to_save.channels_config.matrix {
10514            encrypt_secret(
10515                &store,
10516                &mut mx.access_token,
10517                "config.channels_config.matrix.access_token",
10518            )?;
10519            encrypt_optional_secret(
10520                &store,
10521                &mut mx.recovery_key,
10522                "config.channels_config.matrix.recovery_key",
10523            )?;
10524        }
10525        if let Some(ref mut wa) = config_to_save.channels_config.whatsapp {
10526            encrypt_optional_secret(
10527                &store,
10528                &mut wa.access_token,
10529                "config.channels_config.whatsapp.access_token",
10530            )?;
10531            encrypt_optional_secret(
10532                &store,
10533                &mut wa.app_secret,
10534                "config.channels_config.whatsapp.app_secret",
10535            )?;
10536            encrypt_optional_secret(
10537                &store,
10538                &mut wa.verify_token,
10539                "config.channels_config.whatsapp.verify_token",
10540            )?;
10541        }
10542        if let Some(ref mut lq) = config_to_save.channels_config.linq {
10543            encrypt_secret(
10544                &store,
10545                &mut lq.api_token,
10546                "config.channels_config.linq.api_token",
10547            )?;
10548            encrypt_optional_secret(
10549                &store,
10550                &mut lq.signing_secret,
10551                "config.channels_config.linq.signing_secret",
10552            )?;
10553        }
10554        if let Some(ref mut wt) = config_to_save.channels_config.wati {
10555            encrypt_secret(
10556                &store,
10557                &mut wt.api_token,
10558                "config.channels_config.wati.api_token",
10559            )?;
10560        }
10561        if let Some(ref mut nc) = config_to_save.channels_config.nextcloud_talk {
10562            encrypt_secret(
10563                &store,
10564                &mut nc.app_token,
10565                "config.channels_config.nextcloud_talk.app_token",
10566            )?;
10567            encrypt_optional_secret(
10568                &store,
10569                &mut nc.webhook_secret,
10570                "config.channels_config.nextcloud_talk.webhook_secret",
10571            )?;
10572        }
10573        if let Some(ref mut em) = config_to_save.channels_config.email {
10574            encrypt_secret(
10575                &store,
10576                &mut em.password,
10577                "config.channels_config.email.password",
10578            )?;
10579        }
10580        if let Some(ref mut gp) = config_to_save.channels_config.gmail_push {
10581            encrypt_secret(
10582                &store,
10583                &mut gp.oauth_token,
10584                "config.channels_config.gmail_push.oauth_token",
10585            )?;
10586        }
10587        if let Some(ref mut irc) = config_to_save.channels_config.irc {
10588            encrypt_optional_secret(
10589                &store,
10590                &mut irc.server_password,
10591                "config.channels_config.irc.server_password",
10592            )?;
10593            encrypt_optional_secret(
10594                &store,
10595                &mut irc.nickserv_password,
10596                "config.channels_config.irc.nickserv_password",
10597            )?;
10598            encrypt_optional_secret(
10599                &store,
10600                &mut irc.sasl_password,
10601                "config.channels_config.irc.sasl_password",
10602            )?;
10603        }
10604        if let Some(ref mut lk) = config_to_save.channels_config.lark {
10605            encrypt_secret(
10606                &store,
10607                &mut lk.app_secret,
10608                "config.channels_config.lark.app_secret",
10609            )?;
10610            encrypt_optional_secret(
10611                &store,
10612                &mut lk.encrypt_key,
10613                "config.channels_config.lark.encrypt_key",
10614            )?;
10615            encrypt_optional_secret(
10616                &store,
10617                &mut lk.verification_token,
10618                "config.channels_config.lark.verification_token",
10619            )?;
10620        }
10621        if let Some(ref mut fs) = config_to_save.channels_config.feishu {
10622            encrypt_secret(
10623                &store,
10624                &mut fs.app_secret,
10625                "config.channels_config.feishu.app_secret",
10626            )?;
10627            encrypt_optional_secret(
10628                &store,
10629                &mut fs.encrypt_key,
10630                "config.channels_config.feishu.encrypt_key",
10631            )?;
10632            encrypt_optional_secret(
10633                &store,
10634                &mut fs.verification_token,
10635                "config.channels_config.feishu.verification_token",
10636            )?;
10637        }
10638        if let Some(ref mut dt) = config_to_save.channels_config.dingtalk {
10639            encrypt_secret(
10640                &store,
10641                &mut dt.client_secret,
10642                "config.channels_config.dingtalk.client_secret",
10643            )?;
10644        }
10645        if let Some(ref mut wc) = config_to_save.channels_config.wecom {
10646            encrypt_secret(
10647                &store,
10648                &mut wc.webhook_key,
10649                "config.channels_config.wecom.webhook_key",
10650            )?;
10651        }
10652        if let Some(ref mut qq) = config_to_save.channels_config.qq {
10653            encrypt_secret(
10654                &store,
10655                &mut qq.app_secret,
10656                "config.channels_config.qq.app_secret",
10657            )?;
10658        }
10659        if let Some(ref mut wh) = config_to_save.channels_config.webhook {
10660            encrypt_optional_secret(
10661                &store,
10662                &mut wh.secret,
10663                "config.channels_config.webhook.secret",
10664            )?;
10665        }
10666        if let Some(ref mut ct) = config_to_save.channels_config.clawdtalk {
10667            encrypt_secret(
10668                &store,
10669                &mut ct.api_key,
10670                "config.channels_config.clawdtalk.api_key",
10671            )?;
10672            encrypt_optional_secret(
10673                &store,
10674                &mut ct.webhook_secret,
10675                "config.channels_config.clawdtalk.webhook_secret",
10676            )?;
10677        }
10678
10679        // Encrypt gateway paired tokens
10680        for token in &mut config_to_save.gateway.paired_tokens {
10681            encrypt_secret(&store, token, "config.gateway.paired_tokens[]")?;
10682        }
10683
10684        // Encrypt Nevis IAM secret
10685        encrypt_optional_secret(
10686            &store,
10687            &mut config_to_save.security.nevis.client_secret,
10688            "config.security.nevis.client_secret",
10689        )?;
10690
10691        // Notion API key (top-level, not in ChannelsConfig)
10692        if !config_to_save.notion.api_key.is_empty() {
10693            encrypt_secret(
10694                &store,
10695                &mut config_to_save.notion.api_key,
10696                "config.notion.api_key",
10697            )?;
10698        }
10699
10700        // Jira API token
10701        if !config_to_save.jira.api_token.is_empty() {
10702            encrypt_secret(
10703                &store,
10704                &mut config_to_save.jira.api_token,
10705                "config.jira.api_token",
10706            )?;
10707        }
10708
10709        let toml_str =
10710            toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
10711
10712        let parent_dir = config_path
10713            .parent()
10714            .context("Config path must have a parent directory")?;
10715
10716        fs::create_dir_all(parent_dir).await.with_context(|| {
10717            format!(
10718                "Failed to create config directory: {}",
10719                parent_dir.display()
10720            )
10721        })?;
10722
10723        let file_name = config_path
10724            .file_name()
10725            .and_then(|v| v.to_str())
10726            .unwrap_or("config.toml");
10727        let temp_path = parent_dir.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
10728        let backup_path = parent_dir.join(format!("{file_name}.bak"));
10729
10730        let mut temp_file = OpenOptions::new()
10731            .create_new(true)
10732            .write(true)
10733            .open(&temp_path)
10734            .await
10735            .with_context(|| {
10736                format!(
10737                    "Failed to create temporary config file: {}",
10738                    temp_path.display()
10739                )
10740            })?;
10741        temp_file
10742            .write_all(toml_str.as_bytes())
10743            .await
10744            .context("Failed to write temporary config contents")?;
10745        temp_file
10746            .sync_all()
10747            .await
10748            .context("Failed to fsync temporary config file")?;
10749        drop(temp_file);
10750
10751        let had_existing_config = config_path.exists();
10752        if had_existing_config {
10753            fs::copy(&config_path, &backup_path)
10754                .await
10755                .with_context(|| {
10756                    format!(
10757                        "Failed to create config backup before atomic replace: {}",
10758                        backup_path.display()
10759                    )
10760                })?;
10761        }
10762
10763        if let Err(e) = fs::rename(&temp_path, &config_path).await {
10764            let _ = fs::remove_file(&temp_path).await;
10765            if had_existing_config && backup_path.exists() {
10766                fs::copy(&backup_path, &config_path)
10767                    .await
10768                    .context("Failed to restore config backup")?;
10769            }
10770            anyhow::bail!("Failed to atomically replace config file: {e}");
10771        }
10772
10773        #[cfg(unix)]
10774        {
10775            use std::{fs::Permissions, os::unix::fs::PermissionsExt};
10776            if let Err(err) = fs::set_permissions(&config_path, Permissions::from_mode(0o600)).await
10777            {
10778                tracing::warn!(
10779                    "Failed to harden config permissions to 0600 at {}: {}",
10780                    config_path.display(),
10781                    err
10782                );
10783            }
10784        }
10785
10786        sync_directory(parent_dir).await?;
10787
10788        if had_existing_config {
10789            let _ = fs::remove_file(&backup_path).await;
10790        }
10791
10792        Ok(())
10793    }
10794}
10795
10796#[allow(clippy::unused_async)] // async needed on unix for tokio File I/O; no-op on other platforms
10797async fn sync_directory(path: &Path) -> Result<()> {
10798    #[cfg(unix)]
10799    {
10800        let dir = File::open(path)
10801            .await
10802            .with_context(|| format!("Failed to open directory for fsync: {}", path.display()))?;
10803        dir.sync_all()
10804            .await
10805            .with_context(|| format!("Failed to fsync directory metadata: {}", path.display()))?;
10806        Ok(())
10807    }
10808
10809    #[cfg(windows)]
10810    {
10811        use std::os::windows::fs::OpenOptionsExt;
10812        const FILE_FLAG_BACKUP_SEMANTICS: u32 = 0x02000000;
10813        let dir = std::fs::OpenOptions::new()
10814            .read(true)
10815            .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
10816            .open(path)
10817            .with_context(|| format!("Failed to open directory for fsync: {}", path.display()))?;
10818        // FlushFileBuffers on a directory handle is not portably supported on
10819        // Windows: NTFS commonly returns ERROR_ACCESS_DENIED (os error 5) for
10820        // user-profile paths like C:\Users\<user>\.construct, even when the
10821        // process owns the directory. NTFS journals directory metadata
10822        // updates internally, so the file-level sync_all() that callers
10823        // perform on the actual file inside the directory already covers the
10824        // crash-safety guarantee that fsync(dir) provides on Unix. Swallow
10825        // the sync error rather than failing the operation it gates.
10826        let _ = dir.sync_all();
10827        Ok(())
10828    }
10829
10830    #[cfg(not(any(unix, windows)))]
10831    {
10832        let _ = path;
10833        Ok(())
10834    }
10835}
10836
10837// ── SOP engine configuration ───────────────────────────────────
10838
10839/// Standard Operating Procedures engine configuration (`[sop]`).
10840///
10841/// The `default_execution_mode` field uses the `SopExecutionMode` type from
10842/// `sop::types` (re-exported via `sop::SopExecutionMode`). To avoid circular
10843/// module references, config stores it using the same enum definition.
10844#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
10845pub struct SopConfig {
10846    /// Directory containing SOP definitions (subdirs with SOP.toml + SOP.md).
10847    /// Falls back to `<workspace>/sops` when omitted.
10848    #[serde(default)]
10849    pub sops_dir: Option<String>,
10850
10851    /// Default execution mode for SOPs that omit `execution_mode`.
10852    /// Values: `auto`, `supervised` (default), `step_by_step`,
10853    /// `priority_based`, `deterministic`.
10854    #[serde(default = "default_sop_execution_mode")]
10855    pub default_execution_mode: String,
10856
10857    /// Maximum total concurrent SOP runs across all SOPs.
10858    #[serde(default = "default_sop_max_concurrent_total")]
10859    pub max_concurrent_total: usize,
10860
10861    /// Approval timeout in seconds. When a run waits for approval longer than
10862    /// this, Critical/High-priority SOPs auto-approve; others stay waiting.
10863    /// Set to 0 to disable timeout.
10864    #[serde(default = "default_sop_approval_timeout_secs")]
10865    pub approval_timeout_secs: u64,
10866
10867    /// Maximum number of finished runs kept in memory for status queries.
10868    /// Oldest runs are evicted when over capacity. 0 = unlimited.
10869    #[serde(default = "default_sop_max_finished_runs")]
10870    pub max_finished_runs: usize,
10871}
10872
10873fn default_sop_execution_mode() -> String {
10874    "supervised".to_string()
10875}
10876
10877fn default_sop_max_concurrent_total() -> usize {
10878    4
10879}
10880
10881fn default_sop_approval_timeout_secs() -> u64 {
10882    300
10883}
10884
10885fn default_sop_max_finished_runs() -> usize {
10886    100
10887}
10888
10889impl Default for SopConfig {
10890    fn default() -> Self {
10891        Self {
10892            sops_dir: None,
10893            default_execution_mode: default_sop_execution_mode(),
10894            max_concurrent_total: default_sop_max_concurrent_total(),
10895            approval_timeout_secs: default_sop_approval_timeout_secs(),
10896            max_finished_runs: default_sop_max_finished_runs(),
10897        }
10898    }
10899}
10900
10901#[cfg(test)]
10902mod tests {
10903    use super::*;
10904    use std::io;
10905    #[cfg(unix)]
10906    use std::os::unix::fs::PermissionsExt;
10907    use std::path::PathBuf;
10908    use std::sync::{Arc, Mutex as StdMutex};
10909    use tempfile::TempDir;
10910    use tokio::sync::{Mutex, MutexGuard};
10911    use tokio::test;
10912    use tokio_stream::StreamExt;
10913    use tokio_stream::wrappers::ReadDirStream;
10914
10915    // ── Tilde expansion ───────────────────────────────────────
10916
10917    #[test]
10918    async fn expand_tilde_path_handles_absolute_path() {
10919        let path = expand_tilde_path("/absolute/path");
10920        assert_eq!(path, PathBuf::from("/absolute/path"));
10921    }
10922
10923    #[test]
10924    async fn expand_tilde_path_handles_relative_path() {
10925        let path = expand_tilde_path("relative/path");
10926        assert_eq!(path, PathBuf::from("relative/path"));
10927    }
10928
10929    #[test]
10930    async fn expand_tilde_path_expands_tilde_when_home_set() {
10931        // This test verifies that tilde expansion works when HOME is set.
10932        // In normal environments, HOME is set, so ~ should expand.
10933        let path = expand_tilde_path("~/.construct");
10934        // The path should not literally start with '~' if HOME is set
10935        // (it should be expanded to the actual home directory)
10936        if std::env::var("HOME").is_ok() {
10937            assert!(
10938                !path.to_string_lossy().starts_with('~'),
10939                "Tilde should be expanded when HOME is set"
10940            );
10941        }
10942    }
10943
10944    // ── Defaults ─────────────────────────────────────────────
10945
10946    fn has_test_table(raw: &str, table: &str) -> bool {
10947        let exact = format!("[{table}]");
10948        let nested = format!("[{table}.");
10949        raw.lines()
10950            .map(str::trim)
10951            .any(|line| line == exact || line.starts_with(&nested))
10952    }
10953
10954    fn parse_test_config(raw: &str) -> Config {
10955        let mut merged = raw.trim().to_string();
10956        for table in [
10957            "data_retention",
10958            "cloud_ops",
10959            "conversational_ai",
10960            "security",
10961            "security_ops",
10962        ] {
10963            if has_test_table(&merged, table) {
10964                continue;
10965            }
10966            if !merged.is_empty() {
10967                merged.push_str("\n\n");
10968            }
10969            merged.push('[');
10970            merged.push_str(table);
10971            merged.push(']');
10972        }
10973        merged.push('\n');
10974        let mut config: Config = toml::from_str(&merged).unwrap();
10975        config.autonomy.ensure_default_auto_approve();
10976        config
10977    }
10978
10979    #[test]
10980    async fn http_request_config_default_has_correct_values() {
10981        let cfg = HttpRequestConfig::default();
10982        assert_eq!(cfg.timeout_secs, 30);
10983        assert_eq!(cfg.max_response_size, 1_000_000);
10984        assert!(cfg.enabled);
10985        assert_eq!(cfg.allowed_domains, vec!["*".to_string()]);
10986    }
10987
10988    #[test]
10989    async fn config_default_has_sane_values() {
10990        let c = Config::default();
10991        assert_eq!(c.default_provider.as_deref(), Some("openrouter"));
10992        assert!(c.default_model.as_deref().unwrap().contains("claude"));
10993        assert!((c.default_temperature - 0.7).abs() < f64::EPSILON);
10994        assert!(c.api_key.is_none());
10995        assert!(!c.skills.open_skills_enabled);
10996        assert!(!c.skills.allow_scripts);
10997        assert_eq!(
10998            c.skills.prompt_injection_mode,
10999            SkillsPromptInjectionMode::Full
11000        );
11001        assert_eq!(c.provider_timeout_secs, 120);
11002        assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
11003        assert!(c.config_path.to_string_lossy().contains("config.toml"));
11004    }
11005
11006    #[derive(Clone, Default)]
11007    struct SharedLogBuffer(Arc<StdMutex<Vec<u8>>>);
11008
11009    struct SharedLogWriter(Arc<StdMutex<Vec<u8>>>);
11010
11011    impl SharedLogBuffer {
11012        fn captured(&self) -> String {
11013            String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
11014        }
11015    }
11016
11017    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedLogBuffer {
11018        type Writer = SharedLogWriter;
11019
11020        fn make_writer(&'a self) -> Self::Writer {
11021            SharedLogWriter(self.0.clone())
11022        }
11023    }
11024
11025    impl io::Write for SharedLogWriter {
11026        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
11027            self.0.lock().unwrap().extend_from_slice(buf);
11028            Ok(buf.len())
11029        }
11030
11031        fn flush(&mut self) -> io::Result<()> {
11032            Ok(())
11033        }
11034    }
11035
11036    #[test]
11037    async fn config_dir_creation_error_mentions_openrc_and_path() {
11038        let msg = config_dir_creation_error(Path::new("/etc/construct"));
11039        assert!(msg.contains("/etc/construct"));
11040        assert!(msg.contains("OpenRC"));
11041        assert!(msg.contains("construct"));
11042    }
11043
11044    #[test]
11045    async fn config_schema_export_contains_expected_contract_shape() {
11046        let schema = schemars::schema_for!(Config);
11047        let schema_json = serde_json::to_value(&schema).expect("schema should serialize to json");
11048
11049        assert_eq!(
11050            schema_json
11051                .get("$schema")
11052                .and_then(serde_json::Value::as_str),
11053            Some("https://json-schema.org/draft/2020-12/schema")
11054        );
11055
11056        let properties = schema_json
11057            .get("properties")
11058            .and_then(serde_json::Value::as_object)
11059            .expect("schema should expose top-level properties");
11060
11061        assert!(properties.contains_key("default_provider"));
11062        assert!(properties.contains_key("skills"));
11063        assert!(properties.contains_key("gateway"));
11064        assert!(properties.contains_key("channels_config"));
11065        assert!(!properties.contains_key("workspace_dir"));
11066        assert!(!properties.contains_key("config_path"));
11067
11068        assert!(
11069            schema_json
11070                .get("$defs")
11071                .and_then(serde_json::Value::as_object)
11072                .is_some(),
11073            "schema should include reusable type definitions"
11074        );
11075    }
11076
11077    #[cfg(unix)]
11078    #[test]
11079    async fn save_sets_config_permissions_on_new_file() {
11080        let temp = TempDir::new().expect("temp dir");
11081        let config_path = temp.path().join("config.toml");
11082        let workspace_dir = temp.path().join("workspace");
11083
11084        let mut config = Config::default();
11085        config.config_path = config_path.clone();
11086        config.workspace_dir = workspace_dir;
11087
11088        config.save().await.expect("save config");
11089
11090        let mode = std::fs::metadata(&config_path)
11091            .expect("config metadata")
11092            .permissions()
11093            .mode()
11094            & 0o777;
11095        assert_eq!(mode, 0o600);
11096    }
11097
11098    #[test]
11099    async fn observability_config_default() {
11100        let o = ObservabilityConfig::default();
11101        assert_eq!(o.backend, "none");
11102        assert_eq!(o.runtime_trace_mode, "none");
11103        assert_eq!(o.runtime_trace_path, "state/runtime-trace.jsonl");
11104        assert_eq!(o.runtime_trace_max_entries, 200);
11105    }
11106
11107    #[test]
11108    async fn autonomy_config_default() {
11109        let a = AutonomyConfig::default();
11110        assert_eq!(a.level, AutonomyLevel::Supervised);
11111        assert!(a.workspace_only);
11112        assert!(a.allowed_commands.contains(&"git".to_string()));
11113        assert!(a.allowed_commands.contains(&"cargo".to_string()));
11114        assert!(a.forbidden_paths.contains(&"/etc".to_string()));
11115        assert_eq!(a.max_actions_per_hour, 20);
11116        assert_eq!(a.max_cost_per_day_cents, 500);
11117        assert!(a.require_approval_for_medium_risk);
11118        assert!(a.block_high_risk_commands);
11119        assert!(a.shell_env_passthrough.is_empty());
11120    }
11121
11122    #[test]
11123    async fn runtime_config_default() {
11124        let r = RuntimeConfig::default();
11125        assert_eq!(r.kind, "native");
11126        assert_eq!(r.docker.image, "alpine:3.20");
11127        assert_eq!(r.docker.network, "none");
11128        assert_eq!(r.docker.memory_limit_mb, Some(512));
11129        assert_eq!(r.docker.cpu_limit, Some(1.0));
11130        assert!(r.docker.read_only_rootfs);
11131        assert!(r.docker.mount_workspace);
11132    }
11133
11134    #[test]
11135    async fn heartbeat_config_default() {
11136        let h = HeartbeatConfig::default();
11137        assert!(!h.enabled);
11138        assert_eq!(h.interval_minutes, 30);
11139        assert!(h.message.is_none());
11140        assert!(h.target.is_none());
11141        assert!(h.to.is_none());
11142    }
11143
11144    #[test]
11145    async fn heartbeat_config_parses_delivery_aliases() {
11146        let raw = r#"
11147enabled = true
11148interval_minutes = 10
11149message = "Ping"
11150channel = "telegram"
11151recipient = "42"
11152"#;
11153        let parsed: HeartbeatConfig = toml::from_str(raw).unwrap();
11154        assert!(parsed.enabled);
11155        assert_eq!(parsed.interval_minutes, 10);
11156        assert_eq!(parsed.message.as_deref(), Some("Ping"));
11157        assert_eq!(parsed.target.as_deref(), Some("telegram"));
11158        assert_eq!(parsed.to.as_deref(), Some("42"));
11159    }
11160
11161    #[test]
11162    async fn cron_config_default() {
11163        let c = CronConfig::default();
11164        assert!(c.enabled);
11165        assert_eq!(c.max_run_history, 50);
11166    }
11167
11168    #[test]
11169    async fn cron_config_serde_roundtrip() {
11170        let c = CronConfig {
11171            enabled: false,
11172            catch_up_on_startup: false,
11173            max_run_history: 100,
11174            jobs: Vec::new(),
11175        };
11176        let json = serde_json::to_string(&c).unwrap();
11177        let parsed: CronConfig = serde_json::from_str(&json).unwrap();
11178        assert!(!parsed.enabled);
11179        assert!(!parsed.catch_up_on_startup);
11180        assert_eq!(parsed.max_run_history, 100);
11181    }
11182
11183    #[test]
11184    async fn config_defaults_cron_when_section_missing() {
11185        let toml_str = r#"
11186workspace_dir = "/tmp/workspace"
11187config_path = "/tmp/config.toml"
11188default_temperature = 0.7
11189"#;
11190
11191        let parsed = parse_test_config(toml_str);
11192        assert!(parsed.cron.enabled);
11193        assert!(parsed.cron.catch_up_on_startup);
11194        assert_eq!(parsed.cron.max_run_history, 50);
11195    }
11196
11197    #[test]
11198    async fn memory_config_default_hygiene_settings() {
11199        let m = MemoryConfig::default();
11200        assert_eq!(m.backend, "none");
11201        assert!(m.auto_save);
11202        assert!(m.hygiene_enabled);
11203        assert_eq!(m.archive_after_days, 7);
11204        assert_eq!(m.purge_after_days, 30);
11205        assert_eq!(m.conversation_retention_days, 30);
11206    }
11207
11208    #[test]
11209    async fn storage_provider_config_defaults() {
11210        let storage = StorageConfig::default();
11211        assert!(storage.provider.config.provider.is_empty());
11212        assert!(storage.provider.config.db_url.is_none());
11213        assert_eq!(storage.provider.config.schema, "public");
11214        assert_eq!(storage.provider.config.table, "memories");
11215        assert!(storage.provider.config.connect_timeout_secs.is_none());
11216    }
11217
11218    #[test]
11219    async fn channels_config_default() {
11220        let c = ChannelsConfig::default();
11221        assert!(c.cli);
11222        assert!(c.telegram.is_none());
11223        assert!(c.discord.is_none());
11224        assert!(!c.show_tool_calls);
11225    }
11226
11227    // ── Serde round-trip ─────────────────────────────────────
11228
11229    #[test]
11230    async fn config_toml_roundtrip() {
11231        let config = Config {
11232            workspace_dir: PathBuf::from("/tmp/test/workspace"),
11233            config_path: PathBuf::from("/tmp/test/config.toml"),
11234            api_key: Some("sk-test-key".into()),
11235            api_url: None,
11236            api_path: None,
11237            default_provider: Some("openrouter".into()),
11238            default_model: Some("gpt-4o".into()),
11239            model_providers: HashMap::new(),
11240            default_temperature: 0.5,
11241            provider_timeout_secs: 120,
11242            provider_max_tokens: None,
11243            extra_headers: HashMap::new(),
11244            observability: ObservabilityConfig {
11245                backend: "log".into(),
11246                ..ObservabilityConfig::default()
11247            },
11248            autonomy: AutonomyConfig {
11249                level: AutonomyLevel::Full,
11250                workspace_only: false,
11251                allowed_commands: vec!["docker".into()],
11252                forbidden_paths: vec!["/secret".into()],
11253                max_actions_per_hour: 50,
11254                max_cost_per_day_cents: 1000,
11255                require_approval_for_medium_risk: false,
11256                block_high_risk_commands: true,
11257                shell_env_passthrough: vec!["DATABASE_URL".into()],
11258                auto_approve: vec!["file_read".into()],
11259                always_ask: vec![],
11260                allowed_roots: vec![],
11261                non_cli_excluded_tools: vec![],
11262            },
11263            trust: crate::trust::TrustConfig::default(),
11264            backup: BackupConfig::default(),
11265            data_retention: DataRetentionConfig::default(),
11266            cloud_ops: CloudOpsConfig::default(),
11267            conversational_ai: ConversationalAiConfig::default(),
11268            security: SecurityConfig::default(),
11269            security_ops: SecurityOpsConfig::default(),
11270            runtime: RuntimeConfig {
11271                kind: "docker".into(),
11272                ..RuntimeConfig::default()
11273            },
11274            reliability: ReliabilityConfig::default(),
11275            scheduler: SchedulerConfig::default(),
11276            skills: SkillsConfig::default(),
11277            pipeline: PipelineConfig::default(),
11278            model_routes: Vec::new(),
11279            embedding_routes: Vec::new(),
11280            query_classification: QueryClassificationConfig::default(),
11281            heartbeat: HeartbeatConfig {
11282                enabled: true,
11283                interval_minutes: 15,
11284                two_phase: true,
11285                message: Some("Check London time".into()),
11286                target: Some("telegram".into()),
11287                to: Some("123456".into()),
11288                ..HeartbeatConfig::default()
11289            },
11290            cron: CronConfig::default(),
11291            channels_config: ChannelsConfig {
11292                cli: true,
11293                telegram: Some(TelegramConfig {
11294                    bot_token: "123:ABC".into(),
11295                    allowed_users: vec!["user1".into()],
11296                    stream_mode: StreamMode::default(),
11297                    draft_update_interval_ms: default_draft_update_interval_ms(),
11298                    interrupt_on_new_message: false,
11299                    mention_only: false,
11300                    ack_reactions: None,
11301                    proxy_url: None,
11302                    notification_chat_id: None,
11303                }),
11304                discord: None,
11305                discord_history: None,
11306                slack: None,
11307                mattermost: None,
11308                webhook: None,
11309                imessage: None,
11310                matrix: None,
11311                signal: None,
11312                whatsapp: None,
11313                linq: None,
11314                wati: None,
11315                nextcloud_talk: None,
11316                email: None,
11317                gmail_push: None,
11318                irc: None,
11319                lark: None,
11320                feishu: None,
11321                dingtalk: None,
11322                wecom: None,
11323                qq: None,
11324                twitter: None,
11325                mochat: None,
11326                #[cfg(feature = "channel-nostr")]
11327                nostr: None,
11328                clawdtalk: None,
11329                reddit: None,
11330                bluesky: None,
11331                voice_call: None,
11332                #[cfg(feature = "voice-wake")]
11333                voice_wake: None,
11334                message_timeout_secs: 300,
11335                ack_reactions: true,
11336                show_tool_calls: true,
11337                session_persistence: true,
11338                session_backend: default_session_backend(),
11339                session_ttl_hours: 0,
11340                debounce_ms: 0,
11341            },
11342            memory: MemoryConfig::default(),
11343            storage: StorageConfig::default(),
11344            tunnel: TunnelConfig::default(),
11345            gateway: GatewayConfig::default(),
11346            composio: ComposioConfig::default(),
11347            microsoft365: Microsoft365Config::default(),
11348            secrets: SecretsConfig::default(),
11349            browser: BrowserConfig::default(),
11350            browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig::default(),
11351            http_request: HttpRequestConfig::default(),
11352            multimodal: MultimodalConfig::default(),
11353            media_pipeline: MediaPipelineConfig::default(),
11354            web_fetch: WebFetchConfig::default(),
11355            link_enricher: LinkEnricherConfig::default(),
11356            text_browser: TextBrowserConfig::default(),
11357            web_search: WebSearchConfig::default(),
11358            project_intel: ProjectIntelConfig::default(),
11359            google_workspace: GoogleWorkspaceConfig::default(),
11360            proxy: ProxyConfig::default(),
11361            agent: AgentConfig::default(),
11362            pacing: PacingConfig::default(),
11363            identity: IdentityConfig::default(),
11364            cost: CostConfig::default(),
11365            peripherals: PeripheralsConfig::default(),
11366            delegate: DelegateToolConfig::default(),
11367            agents: HashMap::new(),
11368            swarms: HashMap::new(),
11369            hooks: HooksConfig::default(),
11370            hardware: HardwareConfig::default(),
11371            transcription: TranscriptionConfig::default(),
11372            tts: TtsConfig::default(),
11373            mcp: McpConfig::default(),
11374            kumiho: KumihoConfig::default(),
11375            operator: OperatorConfig::default(),
11376            nodes: NodesConfig::default(),
11377            clawhub: ClawHubConfig::default(),
11378            workspace: WorkspaceConfig::default(),
11379            notion: NotionConfig::default(),
11380            jira: JiraConfig::default(),
11381            node_transport: NodeTransportConfig::default(),
11382            linkedin: LinkedInConfig::default(),
11383            image_gen: ImageGenConfig::default(),
11384            plugins: PluginsConfig::default(),
11385            locale: None,
11386            verifiable_intent: VerifiableIntentConfig::default(),
11387            claude_code: ClaudeCodeConfig::default(),
11388            claude_code_runner: ClaudeCodeRunnerConfig::default(),
11389            codex_cli: CodexCliConfig::default(),
11390            gemini_cli: GeminiCliConfig::default(),
11391            opencode_cli: OpenCodeCliConfig::default(),
11392            sop: SopConfig::default(),
11393            shell_tool: ShellToolConfig::default(),
11394        };
11395
11396        let toml_str = toml::to_string_pretty(&config).unwrap();
11397        let parsed = parse_test_config(&toml_str);
11398
11399        assert_eq!(parsed.api_key, config.api_key);
11400        assert_eq!(parsed.default_provider, config.default_provider);
11401        assert_eq!(parsed.default_model, config.default_model);
11402        assert!((parsed.default_temperature - config.default_temperature).abs() < f64::EPSILON);
11403        assert_eq!(parsed.observability.backend, "log");
11404        assert_eq!(parsed.observability.runtime_trace_mode, "none");
11405        assert_eq!(parsed.autonomy.level, AutonomyLevel::Full);
11406        assert!(!parsed.autonomy.workspace_only);
11407        assert_eq!(parsed.runtime.kind, "docker");
11408        assert!(parsed.heartbeat.enabled);
11409        assert_eq!(parsed.heartbeat.interval_minutes, 15);
11410        assert_eq!(
11411            parsed.heartbeat.message.as_deref(),
11412            Some("Check London time")
11413        );
11414        assert_eq!(parsed.heartbeat.target.as_deref(), Some("telegram"));
11415        assert_eq!(parsed.heartbeat.to.as_deref(), Some("123456"));
11416        assert!(parsed.channels_config.telegram.is_some());
11417        assert_eq!(
11418            parsed.channels_config.telegram.unwrap().bot_token,
11419            "123:ABC"
11420        );
11421    }
11422
11423    #[test]
11424    async fn config_minimal_toml_uses_defaults() {
11425        let minimal = r#"
11426workspace_dir = "/tmp/ws"
11427config_path = "/tmp/config.toml"
11428default_temperature = 0.7
11429"#;
11430        let parsed = parse_test_config(minimal);
11431        assert!(parsed.api_key.is_none());
11432        assert!(parsed.default_provider.is_none());
11433        assert_eq!(parsed.observability.backend, "none");
11434        assert_eq!(parsed.observability.runtime_trace_mode, "none");
11435        assert_eq!(parsed.autonomy.level, AutonomyLevel::Supervised);
11436        assert_eq!(parsed.runtime.kind, "native");
11437        assert!(!parsed.heartbeat.enabled);
11438        assert!(parsed.channels_config.cli);
11439        assert!(parsed.memory.hygiene_enabled);
11440        assert_eq!(parsed.memory.archive_after_days, 7);
11441        assert_eq!(parsed.memory.purge_after_days, 30);
11442        assert_eq!(parsed.memory.conversation_retention_days, 30);
11443        // provider_timeout_secs defaults to 120 when not specified
11444        assert_eq!(parsed.provider_timeout_secs, 120);
11445    }
11446
11447    /// Regression test for #4171: the `[autonomy]` section must not be
11448    /// silently dropped when parsing config TOML.
11449    #[test]
11450    async fn autonomy_section_is_not_silently_ignored() {
11451        let raw = r#"
11452default_temperature = 0.7
11453
11454[autonomy]
11455level = "full"
11456max_actions_per_hour = 99
11457auto_approve = ["file_read", "memory_recall", "http_request"]
11458"#;
11459        let parsed = parse_test_config(raw);
11460        assert_eq!(
11461            parsed.autonomy.level,
11462            AutonomyLevel::Full,
11463            "autonomy.level must be parsed from config (was silently defaulting to Supervised)"
11464        );
11465        assert_eq!(
11466            parsed.autonomy.max_actions_per_hour, 99,
11467            "autonomy.max_actions_per_hour must be parsed from config"
11468        );
11469        assert!(
11470            parsed
11471                .autonomy
11472                .auto_approve
11473                .contains(&"http_request".to_string()),
11474            "autonomy.auto_approve must include http_request from config"
11475        );
11476    }
11477
11478    /// Regression test for #4247: when a user provides a custom auto_approve
11479    /// list, the built-in defaults must still be present.
11480    #[test]
11481    async fn auto_approve_merges_user_entries_with_defaults() {
11482        let raw = r#"
11483default_temperature = 0.7
11484
11485[autonomy]
11486auto_approve = ["my_custom_tool", "another_tool"]
11487"#;
11488        let parsed = parse_test_config(raw);
11489        // User entries are preserved
11490        assert!(
11491            parsed
11492                .autonomy
11493                .auto_approve
11494                .contains(&"my_custom_tool".to_string()),
11495            "user-supplied tool must remain in auto_approve"
11496        );
11497        assert!(
11498            parsed
11499                .autonomy
11500                .auto_approve
11501                .contains(&"another_tool".to_string()),
11502            "user-supplied tool must remain in auto_approve"
11503        );
11504        // Defaults are merged in
11505        for default_tool in &[
11506            "file_read",
11507            "memory_recall",
11508            "weather",
11509            "calculator",
11510            "web_fetch",
11511        ] {
11512            assert!(
11513                parsed
11514                    .autonomy
11515                    .auto_approve
11516                    .contains(&String::from(*default_tool)),
11517                "default tool '{default_tool}' must be present in auto_approve even when user provides custom list"
11518            );
11519        }
11520    }
11521
11522    /// Regression test: empty auto_approve still gets defaults merged.
11523    #[test]
11524    async fn auto_approve_empty_list_gets_defaults() {
11525        let raw = r#"
11526default_temperature = 0.7
11527
11528[autonomy]
11529auto_approve = []
11530"#;
11531        let parsed = parse_test_config(raw);
11532        let defaults = default_auto_approve();
11533        for tool in &defaults {
11534            assert!(
11535                parsed.autonomy.auto_approve.contains(tool),
11536                "default tool '{tool}' must be present even when user sets auto_approve = []"
11537            );
11538        }
11539    }
11540
11541    /// When no autonomy section is provided, defaults are applied normally.
11542    #[test]
11543    async fn auto_approve_defaults_when_no_autonomy_section() {
11544        let raw = r#"
11545default_temperature = 0.7
11546"#;
11547        let parsed = parse_test_config(raw);
11548        let defaults = default_auto_approve();
11549        for tool in &defaults {
11550            assert!(
11551                parsed.autonomy.auto_approve.contains(tool),
11552                "default tool '{tool}' must be present when no [autonomy] section"
11553            );
11554        }
11555    }
11556
11557    /// Duplicates are not introduced when ensure_default_auto_approve runs
11558    /// on a list that already contains the defaults.
11559    #[test]
11560    async fn auto_approve_no_duplicates() {
11561        let raw = r#"
11562default_temperature = 0.7
11563
11564[autonomy]
11565auto_approve = ["weather", "file_read"]
11566"#;
11567        let parsed = parse_test_config(raw);
11568        let weather_count = parsed
11569            .autonomy
11570            .auto_approve
11571            .iter()
11572            .filter(|t| *t == "weather")
11573            .count();
11574        assert_eq!(weather_count, 1, "weather must not be duplicated");
11575        let file_read_count = parsed
11576            .autonomy
11577            .auto_approve
11578            .iter()
11579            .filter(|t| *t == "file_read")
11580            .count();
11581        assert_eq!(file_read_count, 1, "file_read must not be duplicated");
11582    }
11583
11584    #[test]
11585    async fn provider_timeout_secs_parses_from_toml() {
11586        let raw = r#"
11587default_temperature = 0.7
11588provider_timeout_secs = 300
11589"#;
11590        let parsed = parse_test_config(raw);
11591        assert_eq!(parsed.provider_timeout_secs, 300);
11592    }
11593
11594    #[test]
11595    async fn parse_extra_headers_env_basic() {
11596        let headers = parse_extra_headers_env("User-Agent:MyApp/1.0,X-Title:construct");
11597        assert_eq!(headers.len(), 2);
11598        assert_eq!(
11599            headers[0],
11600            ("User-Agent".to_string(), "MyApp/1.0".to_string())
11601        );
11602        assert_eq!(headers[1], ("X-Title".to_string(), "construct".to_string()));
11603    }
11604
11605    #[test]
11606    async fn parse_extra_headers_env_with_url_value() {
11607        let headers = parse_extra_headers_env("HTTP-Referer:https://github.com/KumihoIO/construct");
11608        assert_eq!(headers.len(), 1);
11609        // Only splits on first colon, preserving URL colons in value
11610        assert_eq!(headers[0].0, "HTTP-Referer");
11611        assert_eq!(headers[0].1, "https://github.com/KumihoIO/construct");
11612    }
11613
11614    #[test]
11615    async fn parse_extra_headers_env_empty_string() {
11616        let headers = parse_extra_headers_env("");
11617        assert!(headers.is_empty());
11618    }
11619
11620    #[test]
11621    async fn parse_extra_headers_env_whitespace_trimming() {
11622        let headers = parse_extra_headers_env("  X-Title : construct , User-Agent : cli/1.0 ");
11623        assert_eq!(headers.len(), 2);
11624        assert_eq!(headers[0], ("X-Title".to_string(), "construct".to_string()));
11625        assert_eq!(
11626            headers[1],
11627            ("User-Agent".to_string(), "cli/1.0".to_string())
11628        );
11629    }
11630
11631    #[test]
11632    async fn parse_extra_headers_env_skips_malformed() {
11633        let headers = parse_extra_headers_env("X-Valid:value,no-colon-here,Another:ok");
11634        assert_eq!(headers.len(), 2);
11635        assert_eq!(headers[0], ("X-Valid".to_string(), "value".to_string()));
11636        assert_eq!(headers[1], ("Another".to_string(), "ok".to_string()));
11637    }
11638
11639    #[test]
11640    async fn parse_extra_headers_env_skips_empty_key() {
11641        let headers = parse_extra_headers_env(":value,X-Valid:ok");
11642        assert_eq!(headers.len(), 1);
11643        assert_eq!(headers[0], ("X-Valid".to_string(), "ok".to_string()));
11644    }
11645
11646    #[test]
11647    async fn parse_extra_headers_env_allows_empty_value() {
11648        let headers = parse_extra_headers_env("X-Empty:");
11649        assert_eq!(headers.len(), 1);
11650        assert_eq!(headers[0], ("X-Empty".to_string(), String::new()));
11651    }
11652
11653    #[test]
11654    async fn parse_extra_headers_env_trailing_comma() {
11655        let headers = parse_extra_headers_env("X-Title:construct,");
11656        assert_eq!(headers.len(), 1);
11657        assert_eq!(headers[0], ("X-Title".to_string(), "construct".to_string()));
11658    }
11659
11660    #[test]
11661    async fn extra_headers_parses_from_toml() {
11662        let raw = r#"
11663default_temperature = 0.7
11664
11665[extra_headers]
11666User-Agent = "MyApp/1.0"
11667X-Title = "construct"
11668"#;
11669        let parsed = parse_test_config(raw);
11670        assert_eq!(parsed.extra_headers.len(), 2);
11671        assert_eq!(parsed.extra_headers.get("User-Agent").unwrap(), "MyApp/1.0");
11672        assert_eq!(parsed.extra_headers.get("X-Title").unwrap(), "construct");
11673    }
11674
11675    #[test]
11676    async fn extra_headers_defaults_to_empty() {
11677        let raw = r#"
11678default_temperature = 0.7
11679"#;
11680        let parsed = parse_test_config(raw);
11681        assert!(parsed.extra_headers.is_empty());
11682    }
11683
11684    #[test]
11685    async fn storage_provider_dburl_alias_deserializes() {
11686        let raw = r#"
11687default_temperature = 0.7
11688
11689[storage.provider.config]
11690provider = "qdrant"
11691dbURL = "http://localhost:6333"
11692schema = "public"
11693table = "memories"
11694connect_timeout_secs = 12
11695"#;
11696
11697        let parsed = parse_test_config(raw);
11698        assert_eq!(parsed.storage.provider.config.provider, "qdrant");
11699        assert_eq!(
11700            parsed.storage.provider.config.db_url.as_deref(),
11701            Some("http://localhost:6333")
11702        );
11703        assert_eq!(parsed.storage.provider.config.schema, "public");
11704        assert_eq!(parsed.storage.provider.config.table, "memories");
11705        assert_eq!(
11706            parsed.storage.provider.config.connect_timeout_secs,
11707            Some(12)
11708        );
11709    }
11710
11711    #[test]
11712    async fn runtime_reasoning_enabled_deserializes() {
11713        let raw = r#"
11714default_temperature = 0.7
11715
11716[runtime]
11717reasoning_enabled = false
11718"#;
11719
11720        let parsed = parse_test_config(raw);
11721        assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
11722    }
11723
11724    #[test]
11725    async fn runtime_reasoning_effort_deserializes() {
11726        let raw = r#"
11727default_temperature = 0.7
11728
11729[runtime]
11730reasoning_effort = "HIGH"
11731"#;
11732
11733        let parsed: Config = toml::from_str(raw).unwrap();
11734        assert_eq!(parsed.runtime.reasoning_effort.as_deref(), Some("high"));
11735    }
11736
11737    #[test]
11738    async fn runtime_reasoning_effort_rejects_invalid_values() {
11739        let raw = r#"
11740default_temperature = 0.7
11741
11742[runtime]
11743reasoning_effort = "turbo"
11744"#;
11745
11746        let error = toml::from_str::<Config>(raw).expect_err("invalid value should fail");
11747        assert!(error.to_string().contains("reasoning_effort"));
11748    }
11749
11750    #[test]
11751    async fn agent_config_defaults() {
11752        let cfg = AgentConfig::default();
11753        assert!(cfg.compact_context);
11754        assert_eq!(cfg.max_tool_iterations, 10);
11755        assert_eq!(cfg.max_history_messages, 50);
11756        assert!(!cfg.parallel_tools);
11757        assert_eq!(cfg.tool_dispatcher, "auto");
11758    }
11759
11760    #[test]
11761    async fn agent_config_deserializes() {
11762        let raw = r#"
11763default_temperature = 0.7
11764[agent]
11765compact_context = true
11766max_tool_iterations = 20
11767max_history_messages = 80
11768parallel_tools = true
11769tool_dispatcher = "xml"
11770"#;
11771        let parsed = parse_test_config(raw);
11772        assert!(parsed.agent.compact_context);
11773        assert_eq!(parsed.agent.max_tool_iterations, 20);
11774        assert_eq!(parsed.agent.max_history_messages, 80);
11775        assert!(parsed.agent.parallel_tools);
11776        assert_eq!(parsed.agent.tool_dispatcher, "xml");
11777    }
11778
11779    #[test]
11780    async fn pacing_config_defaults_are_all_none_or_empty() {
11781        let cfg = PacingConfig::default();
11782        assert!(cfg.step_timeout_secs.is_none());
11783        assert!(cfg.loop_detection_min_elapsed_secs.is_none());
11784        assert!(cfg.loop_ignore_tools.is_empty());
11785        assert!(cfg.message_timeout_scale_max.is_none());
11786    }
11787
11788    #[test]
11789    async fn pacing_config_deserializes_from_toml() {
11790        let raw = r#"
11791default_temperature = 0.7
11792[pacing]
11793step_timeout_secs = 120
11794loop_detection_min_elapsed_secs = 60
11795loop_ignore_tools = ["browser_screenshot", "browser_navigate"]
11796message_timeout_scale_max = 8
11797"#;
11798        let parsed: Config = toml::from_str(raw).unwrap();
11799        assert_eq!(parsed.pacing.step_timeout_secs, Some(120));
11800        assert_eq!(parsed.pacing.loop_detection_min_elapsed_secs, Some(60));
11801        assert_eq!(
11802            parsed.pacing.loop_ignore_tools,
11803            vec!["browser_screenshot", "browser_navigate"]
11804        );
11805        assert_eq!(parsed.pacing.message_timeout_scale_max, Some(8));
11806    }
11807
11808    #[test]
11809    async fn pacing_config_absent_preserves_defaults() {
11810        let raw = r#"
11811default_temperature = 0.7
11812"#;
11813        let parsed: Config = toml::from_str(raw).unwrap();
11814        assert!(parsed.pacing.step_timeout_secs.is_none());
11815        assert!(parsed.pacing.loop_detection_min_elapsed_secs.is_none());
11816        assert!(parsed.pacing.loop_ignore_tools.is_empty());
11817        assert!(parsed.pacing.message_timeout_scale_max.is_none());
11818    }
11819
11820    #[tokio::test]
11821    async fn sync_directory_handles_existing_directory() {
11822        let dir = std::env::temp_dir().join(format!(
11823            "construct_test_sync_directory_{}",
11824            uuid::Uuid::new_v4()
11825        ));
11826        fs::create_dir_all(&dir).await.unwrap();
11827
11828        sync_directory(&dir).await.unwrap();
11829
11830        let _ = fs::remove_dir_all(&dir).await;
11831    }
11832
11833    #[tokio::test]
11834    async fn config_save_and_load_tmpdir() {
11835        let dir = std::env::temp_dir().join("construct_test_config");
11836        let _ = fs::remove_dir_all(&dir).await;
11837        fs::create_dir_all(&dir).await.unwrap();
11838
11839        let config_path = dir.join("config.toml");
11840        let config = Config {
11841            workspace_dir: dir.join("workspace"),
11842            config_path: config_path.clone(),
11843            api_key: Some("sk-roundtrip".into()),
11844            api_url: None,
11845            api_path: None,
11846            default_provider: Some("openrouter".into()),
11847            default_model: Some("test-model".into()),
11848            model_providers: HashMap::new(),
11849            default_temperature: 0.9,
11850            provider_timeout_secs: 120,
11851            provider_max_tokens: None,
11852            extra_headers: HashMap::new(),
11853            observability: ObservabilityConfig::default(),
11854            autonomy: AutonomyConfig::default(),
11855            trust: crate::trust::TrustConfig::default(),
11856            backup: BackupConfig::default(),
11857            data_retention: DataRetentionConfig::default(),
11858            cloud_ops: CloudOpsConfig::default(),
11859            conversational_ai: ConversationalAiConfig::default(),
11860            security: SecurityConfig::default(),
11861            security_ops: SecurityOpsConfig::default(),
11862            runtime: RuntimeConfig::default(),
11863            reliability: ReliabilityConfig::default(),
11864            scheduler: SchedulerConfig::default(),
11865            skills: SkillsConfig::default(),
11866            pipeline: PipelineConfig::default(),
11867            model_routes: Vec::new(),
11868            embedding_routes: Vec::new(),
11869            query_classification: QueryClassificationConfig::default(),
11870            heartbeat: HeartbeatConfig::default(),
11871            cron: CronConfig::default(),
11872            channels_config: ChannelsConfig::default(),
11873            memory: MemoryConfig::default(),
11874            storage: StorageConfig::default(),
11875            tunnel: TunnelConfig::default(),
11876            gateway: GatewayConfig::default(),
11877            composio: ComposioConfig::default(),
11878            microsoft365: Microsoft365Config::default(),
11879            secrets: SecretsConfig::default(),
11880            browser: BrowserConfig::default(),
11881            browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig::default(),
11882            http_request: HttpRequestConfig::default(),
11883            multimodal: MultimodalConfig::default(),
11884            media_pipeline: MediaPipelineConfig::default(),
11885            web_fetch: WebFetchConfig::default(),
11886            link_enricher: LinkEnricherConfig::default(),
11887            text_browser: TextBrowserConfig::default(),
11888            web_search: WebSearchConfig::default(),
11889            project_intel: ProjectIntelConfig::default(),
11890            google_workspace: GoogleWorkspaceConfig::default(),
11891            proxy: ProxyConfig::default(),
11892            agent: AgentConfig::default(),
11893            pacing: PacingConfig::default(),
11894            identity: IdentityConfig::default(),
11895            cost: CostConfig::default(),
11896            peripherals: PeripheralsConfig::default(),
11897            delegate: DelegateToolConfig::default(),
11898            agents: HashMap::new(),
11899            swarms: HashMap::new(),
11900            hooks: HooksConfig::default(),
11901            hardware: HardwareConfig::default(),
11902            transcription: TranscriptionConfig::default(),
11903            tts: TtsConfig::default(),
11904            mcp: McpConfig::default(),
11905            kumiho: KumihoConfig::default(),
11906            operator: OperatorConfig::default(),
11907            nodes: NodesConfig::default(),
11908            clawhub: ClawHubConfig::default(),
11909            workspace: WorkspaceConfig::default(),
11910            notion: NotionConfig::default(),
11911            jira: JiraConfig::default(),
11912            node_transport: NodeTransportConfig::default(),
11913            linkedin: LinkedInConfig::default(),
11914            image_gen: ImageGenConfig::default(),
11915            plugins: PluginsConfig::default(),
11916            locale: None,
11917            verifiable_intent: VerifiableIntentConfig::default(),
11918            claude_code: ClaudeCodeConfig::default(),
11919            claude_code_runner: ClaudeCodeRunnerConfig::default(),
11920            codex_cli: CodexCliConfig::default(),
11921            gemini_cli: GeminiCliConfig::default(),
11922            opencode_cli: OpenCodeCliConfig::default(),
11923            sop: SopConfig::default(),
11924            shell_tool: ShellToolConfig::default(),
11925        };
11926
11927        config.save().await.unwrap();
11928        assert!(config_path.exists());
11929
11930        let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
11931        let loaded: Config = toml::from_str(&contents).unwrap();
11932        assert!(
11933            loaded
11934                .api_key
11935                .as_deref()
11936                .is_some_and(crate::security::SecretStore::is_encrypted)
11937        );
11938        let store = crate::security::SecretStore::new(&dir, true);
11939        let decrypted = store.decrypt(loaded.api_key.as_deref().unwrap()).unwrap();
11940        assert_eq!(decrypted, "sk-roundtrip");
11941        assert_eq!(loaded.default_model.as_deref(), Some("test-model"));
11942        assert!((loaded.default_temperature - 0.9).abs() < f64::EPSILON);
11943
11944        let _ = fs::remove_dir_all(&dir).await;
11945    }
11946
11947    #[tokio::test]
11948    async fn config_save_encrypts_nested_credentials() {
11949        let dir = std::env::temp_dir().join(format!(
11950            "construct_test_nested_credentials_{}",
11951            uuid::Uuid::new_v4()
11952        ));
11953        fs::create_dir_all(&dir).await.unwrap();
11954
11955        let mut config = Config::default();
11956        config.workspace_dir = dir.join("workspace");
11957        config.config_path = dir.join("config.toml");
11958        config.api_key = Some("root-credential".into());
11959        config.composio.api_key = Some("composio-credential".into());
11960        config.browser.computer_use.api_key = Some("browser-credential".into());
11961        config.web_search.brave_api_key = Some("brave-credential".into());
11962        config.storage.provider.config.db_url = Some("postgres://user:pw@host/db".into());
11963        config.channels_config.feishu = Some(FeishuConfig {
11964            app_id: "cli_feishu_123".into(),
11965            app_secret: "feishu-secret".into(),
11966            encrypt_key: Some("feishu-encrypt".into()),
11967            verification_token: Some("feishu-verify".into()),
11968            allowed_users: vec!["*".into()],
11969            receive_mode: LarkReceiveMode::Websocket,
11970            port: None,
11971            proxy_url: None,
11972        });
11973
11974        config.agents.insert(
11975            "worker".into(),
11976            DelegateAgentConfig {
11977                provider: "openrouter".into(),
11978                model: "model-test".into(),
11979                system_prompt: None,
11980                api_key: Some("agent-credential".into()),
11981                temperature: None,
11982                max_depth: 3,
11983                agentic: false,
11984                allowed_tools: Vec::new(),
11985                max_iterations: 10,
11986                timeout_secs: None,
11987                agentic_timeout_secs: None,
11988                skills_directory: None,
11989            },
11990        );
11991
11992        config.save().await.unwrap();
11993
11994        let contents = tokio::fs::read_to_string(config.config_path.clone())
11995            .await
11996            .unwrap();
11997        let stored: Config = toml::from_str(&contents).unwrap();
11998        let store = crate::security::SecretStore::new(&dir, true);
11999
12000        let root_encrypted = stored.api_key.as_deref().unwrap();
12001        assert!(crate::security::SecretStore::is_encrypted(root_encrypted));
12002        assert_eq!(store.decrypt(root_encrypted).unwrap(), "root-credential");
12003
12004        let composio_encrypted = stored.composio.api_key.as_deref().unwrap();
12005        assert!(crate::security::SecretStore::is_encrypted(
12006            composio_encrypted
12007        ));
12008        assert_eq!(
12009            store.decrypt(composio_encrypted).unwrap(),
12010            "composio-credential"
12011        );
12012
12013        let browser_encrypted = stored.browser.computer_use.api_key.as_deref().unwrap();
12014        assert!(crate::security::SecretStore::is_encrypted(
12015            browser_encrypted
12016        ));
12017        assert_eq!(
12018            store.decrypt(browser_encrypted).unwrap(),
12019            "browser-credential"
12020        );
12021
12022        let web_search_encrypted = stored.web_search.brave_api_key.as_deref().unwrap();
12023        assert!(crate::security::SecretStore::is_encrypted(
12024            web_search_encrypted
12025        ));
12026        assert_eq!(
12027            store.decrypt(web_search_encrypted).unwrap(),
12028            "brave-credential"
12029        );
12030
12031        let worker = stored.agents.get("worker").unwrap();
12032        let worker_encrypted = worker.api_key.as_deref().unwrap();
12033        assert!(crate::security::SecretStore::is_encrypted(worker_encrypted));
12034        assert_eq!(store.decrypt(worker_encrypted).unwrap(), "agent-credential");
12035
12036        let storage_db_url = stored.storage.provider.config.db_url.as_deref().unwrap();
12037        assert!(crate::security::SecretStore::is_encrypted(storage_db_url));
12038        assert_eq!(
12039            store.decrypt(storage_db_url).unwrap(),
12040            "postgres://user:pw@host/db"
12041        );
12042
12043        let feishu = stored.channels_config.feishu.as_ref().unwrap();
12044        assert!(crate::security::SecretStore::is_encrypted(
12045            &feishu.app_secret
12046        ));
12047        assert_eq!(store.decrypt(&feishu.app_secret).unwrap(), "feishu-secret");
12048        assert!(
12049            feishu
12050                .encrypt_key
12051                .as_deref()
12052                .is_some_and(crate::security::SecretStore::is_encrypted)
12053        );
12054        assert_eq!(
12055            store
12056                .decrypt(feishu.encrypt_key.as_deref().unwrap())
12057                .unwrap(),
12058            "feishu-encrypt"
12059        );
12060        assert!(
12061            feishu
12062                .verification_token
12063                .as_deref()
12064                .is_some_and(crate::security::SecretStore::is_encrypted)
12065        );
12066        assert_eq!(
12067            store
12068                .decrypt(feishu.verification_token.as_deref().unwrap())
12069                .unwrap(),
12070            "feishu-verify"
12071        );
12072
12073        let _ = fs::remove_dir_all(&dir).await;
12074    }
12075
12076    #[tokio::test]
12077    async fn config_save_atomic_cleanup() {
12078        let dir =
12079            std::env::temp_dir().join(format!("construct_test_config_{}", uuid::Uuid::new_v4()));
12080        fs::create_dir_all(&dir).await.unwrap();
12081
12082        let config_path = dir.join("config.toml");
12083        let mut config = Config::default();
12084        config.workspace_dir = dir.join("workspace");
12085        config.config_path = config_path.clone();
12086        config.default_model = Some("model-a".into());
12087        config.save().await.unwrap();
12088        assert!(config_path.exists());
12089
12090        config.default_model = Some("model-b".into());
12091        config.save().await.unwrap();
12092
12093        let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
12094        assert!(contents.contains("model-b"));
12095
12096        let names: Vec<String> = ReadDirStream::new(fs::read_dir(&dir).await.unwrap())
12097            .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
12098            .collect()
12099            .await;
12100        assert!(!names.iter().any(|name| name.contains(".tmp-")));
12101        assert!(!names.iter().any(|name| name.ends_with(".bak")));
12102
12103        let _ = fs::remove_dir_all(&dir).await;
12104    }
12105
12106    // ── Telegram / Discord config ────────────────────────────
12107
12108    #[test]
12109    async fn telegram_config_serde() {
12110        let tc = TelegramConfig {
12111            bot_token: "123:XYZ".into(),
12112            allowed_users: vec!["alice".into(), "bob".into()],
12113            stream_mode: StreamMode::Partial,
12114            draft_update_interval_ms: 500,
12115            interrupt_on_new_message: true,
12116            mention_only: false,
12117            ack_reactions: None,
12118            proxy_url: None,
12119            notification_chat_id: None,
12120        };
12121        let json = serde_json::to_string(&tc).unwrap();
12122        let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
12123        assert_eq!(parsed.bot_token, "123:XYZ");
12124        assert_eq!(parsed.allowed_users.len(), 2);
12125        assert_eq!(parsed.stream_mode, StreamMode::Partial);
12126        assert_eq!(parsed.draft_update_interval_ms, 500);
12127        assert!(parsed.interrupt_on_new_message);
12128    }
12129
12130    #[test]
12131    async fn telegram_config_defaults_stream_off() {
12132        let json = r#"{"bot_token":"tok","allowed_users":[]}"#;
12133        let parsed: TelegramConfig = serde_json::from_str(json).unwrap();
12134        assert_eq!(parsed.stream_mode, StreamMode::Off);
12135        assert_eq!(parsed.draft_update_interval_ms, 1000);
12136        assert!(!parsed.interrupt_on_new_message);
12137    }
12138
12139    #[test]
12140    async fn discord_config_serde() {
12141        let dc = DiscordConfig {
12142            bot_token: "discord-token".into(),
12143            guild_id: Some("12345".into()),
12144            allowed_users: vec![],
12145            listen_to_bots: false,
12146            interrupt_on_new_message: false,
12147            mention_only: false,
12148            proxy_url: None,
12149            stream_mode: StreamMode::default(),
12150            draft_update_interval_ms: 1000,
12151            multi_message_delay_ms: 800,
12152            notification_channel_id: None,
12153        };
12154        let json = serde_json::to_string(&dc).unwrap();
12155        let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
12156        assert_eq!(parsed.bot_token, "discord-token");
12157        assert_eq!(parsed.guild_id.as_deref(), Some("12345"));
12158    }
12159
12160    #[test]
12161    async fn discord_config_optional_guild() {
12162        let dc = DiscordConfig {
12163            bot_token: "tok".into(),
12164            guild_id: None,
12165            allowed_users: vec![],
12166            listen_to_bots: false,
12167            interrupt_on_new_message: false,
12168            mention_only: false,
12169            proxy_url: None,
12170            stream_mode: StreamMode::default(),
12171            draft_update_interval_ms: 1000,
12172            multi_message_delay_ms: 800,
12173            notification_channel_id: None,
12174        };
12175        let json = serde_json::to_string(&dc).unwrap();
12176        let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
12177        assert!(parsed.guild_id.is_none());
12178    }
12179
12180    // ── iMessage / Matrix config ────────────────────────────
12181
12182    #[test]
12183    async fn imessage_config_serde() {
12184        let ic = IMessageConfig {
12185            allowed_contacts: vec!["+1234567890".into(), "user@icloud.com".into()],
12186        };
12187        let json = serde_json::to_string(&ic).unwrap();
12188        let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
12189        assert_eq!(parsed.allowed_contacts.len(), 2);
12190        assert_eq!(parsed.allowed_contacts[0], "+1234567890");
12191    }
12192
12193    #[test]
12194    async fn imessage_config_empty_contacts() {
12195        let ic = IMessageConfig {
12196            allowed_contacts: vec![],
12197        };
12198        let json = serde_json::to_string(&ic).unwrap();
12199        let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
12200        assert!(parsed.allowed_contacts.is_empty());
12201    }
12202
12203    #[test]
12204    async fn imessage_config_wildcard() {
12205        let ic = IMessageConfig {
12206            allowed_contacts: vec!["*".into()],
12207        };
12208        let toml_str = toml::to_string(&ic).unwrap();
12209        let parsed: IMessageConfig = toml::from_str(&toml_str).unwrap();
12210        assert_eq!(parsed.allowed_contacts, vec!["*"]);
12211    }
12212
12213    #[test]
12214    async fn matrix_config_serde() {
12215        let mc = MatrixConfig {
12216            homeserver: "https://matrix.org".into(),
12217            access_token: "syt_token_abc".into(),
12218            user_id: Some("@bot:matrix.org".into()),
12219            device_id: Some("DEVICE123".into()),
12220            room_id: "!room123:matrix.org".into(),
12221            allowed_users: vec!["@user:matrix.org".into()],
12222            allowed_rooms: vec![],
12223            interrupt_on_new_message: false,
12224            stream_mode: StreamMode::default(),
12225            draft_update_interval_ms: 1500,
12226            multi_message_delay_ms: 800,
12227            recovery_key: None,
12228        };
12229        let json = serde_json::to_string(&mc).unwrap();
12230        let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
12231        assert_eq!(parsed.homeserver, "https://matrix.org");
12232        assert_eq!(parsed.access_token, "syt_token_abc");
12233        assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org"));
12234        assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123"));
12235        assert_eq!(parsed.room_id, "!room123:matrix.org");
12236        assert_eq!(parsed.allowed_users.len(), 1);
12237    }
12238
12239    #[test]
12240    async fn matrix_config_toml_roundtrip() {
12241        let mc = MatrixConfig {
12242            homeserver: "https://synapse.local:8448".into(),
12243            access_token: "tok".into(),
12244            user_id: None,
12245            device_id: None,
12246            room_id: "!abc:synapse.local".into(),
12247            allowed_users: vec!["@admin:synapse.local".into(), "*".into()],
12248            allowed_rooms: vec![],
12249            interrupt_on_new_message: false,
12250            stream_mode: StreamMode::default(),
12251            draft_update_interval_ms: 1500,
12252            multi_message_delay_ms: 800,
12253            recovery_key: None,
12254        };
12255        let toml_str = toml::to_string(&mc).unwrap();
12256        let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
12257        assert_eq!(parsed.homeserver, "https://synapse.local:8448");
12258        assert_eq!(parsed.allowed_users.len(), 2);
12259    }
12260
12261    #[test]
12262    async fn matrix_config_backward_compatible_without_session_hints() {
12263        let toml = r#"
12264homeserver = "https://matrix.org"
12265access_token = "tok"
12266room_id = "!ops:matrix.org"
12267allowed_users = ["@ops:matrix.org"]
12268"#;
12269
12270        let parsed: MatrixConfig = toml::from_str(toml).unwrap();
12271        assert_eq!(parsed.homeserver, "https://matrix.org");
12272        assert!(parsed.user_id.is_none());
12273        assert!(parsed.device_id.is_none());
12274    }
12275
12276    #[test]
12277    async fn signal_config_serde() {
12278        let sc = SignalConfig {
12279            http_url: "http://127.0.0.1:8686".into(),
12280            account: "+1234567890".into(),
12281            group_id: Some("group123".into()),
12282            allowed_from: vec!["+1111111111".into()],
12283            ignore_attachments: true,
12284            ignore_stories: false,
12285            proxy_url: None,
12286        };
12287        let json = serde_json::to_string(&sc).unwrap();
12288        let parsed: SignalConfig = serde_json::from_str(&json).unwrap();
12289        assert_eq!(parsed.http_url, "http://127.0.0.1:8686");
12290        assert_eq!(parsed.account, "+1234567890");
12291        assert_eq!(parsed.group_id.as_deref(), Some("group123"));
12292        assert_eq!(parsed.allowed_from.len(), 1);
12293        assert!(parsed.ignore_attachments);
12294        assert!(!parsed.ignore_stories);
12295    }
12296
12297    #[test]
12298    async fn signal_config_toml_roundtrip() {
12299        let sc = SignalConfig {
12300            http_url: "http://localhost:8080".into(),
12301            account: "+9876543210".into(),
12302            group_id: None,
12303            allowed_from: vec!["*".into()],
12304            ignore_attachments: false,
12305            ignore_stories: true,
12306            proxy_url: None,
12307        };
12308        let toml_str = toml::to_string(&sc).unwrap();
12309        let parsed: SignalConfig = toml::from_str(&toml_str).unwrap();
12310        assert_eq!(parsed.http_url, "http://localhost:8080");
12311        assert_eq!(parsed.account, "+9876543210");
12312        assert!(parsed.group_id.is_none());
12313        assert!(parsed.ignore_stories);
12314    }
12315
12316    #[test]
12317    async fn signal_config_defaults() {
12318        let json = r#"{"http_url":"http://127.0.0.1:8686","account":"+1234567890"}"#;
12319        let parsed: SignalConfig = serde_json::from_str(json).unwrap();
12320        assert!(parsed.group_id.is_none());
12321        assert!(parsed.allowed_from.is_empty());
12322        assert!(!parsed.ignore_attachments);
12323        assert!(!parsed.ignore_stories);
12324    }
12325
12326    #[test]
12327    async fn channels_config_with_imessage_and_matrix() {
12328        let c = ChannelsConfig {
12329            cli: true,
12330            telegram: None,
12331            discord: None,
12332            discord_history: None,
12333            slack: None,
12334            mattermost: None,
12335            webhook: None,
12336            imessage: Some(IMessageConfig {
12337                allowed_contacts: vec!["+1".into()],
12338            }),
12339            matrix: Some(MatrixConfig {
12340                homeserver: "https://m.org".into(),
12341                access_token: "tok".into(),
12342                user_id: None,
12343                device_id: None,
12344                room_id: "!r:m".into(),
12345                allowed_users: vec!["@u:m".into()],
12346                allowed_rooms: vec![],
12347                interrupt_on_new_message: false,
12348                stream_mode: StreamMode::default(),
12349                draft_update_interval_ms: 1500,
12350                multi_message_delay_ms: 800,
12351                recovery_key: None,
12352            }),
12353            signal: None,
12354            whatsapp: None,
12355            linq: None,
12356            wati: None,
12357            nextcloud_talk: None,
12358            email: None,
12359            gmail_push: None,
12360            irc: None,
12361            lark: None,
12362            feishu: None,
12363            dingtalk: None,
12364            wecom: None,
12365            qq: None,
12366            twitter: None,
12367            mochat: None,
12368            #[cfg(feature = "channel-nostr")]
12369            nostr: None,
12370            clawdtalk: None,
12371            reddit: None,
12372            bluesky: None,
12373            voice_call: None,
12374            #[cfg(feature = "voice-wake")]
12375            voice_wake: None,
12376            message_timeout_secs: 300,
12377            ack_reactions: true,
12378            show_tool_calls: true,
12379            session_persistence: true,
12380            session_backend: default_session_backend(),
12381            session_ttl_hours: 0,
12382            debounce_ms: 0,
12383        };
12384        let toml_str = toml::to_string_pretty(&c).unwrap();
12385        let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
12386        assert!(parsed.imessage.is_some());
12387        assert!(parsed.matrix.is_some());
12388        assert_eq!(parsed.imessage.unwrap().allowed_contacts, vec!["+1"]);
12389        assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org");
12390    }
12391
12392    #[test]
12393    async fn channels_config_default_has_no_imessage_matrix() {
12394        let c = ChannelsConfig::default();
12395        assert!(c.imessage.is_none());
12396        assert!(c.matrix.is_none());
12397    }
12398
12399    // ── Edge cases: serde(default) for allowed_users ─────────
12400
12401    #[test]
12402    async fn discord_config_deserializes_without_allowed_users() {
12403        // Old configs won't have allowed_users — serde(default) should fill vec![]
12404        let json = r#"{"bot_token":"tok","guild_id":"123"}"#;
12405        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12406        assert!(parsed.allowed_users.is_empty());
12407    }
12408
12409    #[test]
12410    async fn discord_config_deserializes_with_allowed_users() {
12411        let json = r#"{"bot_token":"tok","guild_id":"123","allowed_users":["111","222"]}"#;
12412        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12413        assert_eq!(parsed.allowed_users, vec!["111", "222"]);
12414    }
12415
12416    #[test]
12417    async fn slack_config_deserializes_without_allowed_users() {
12418        let json = r#"{"bot_token":"xoxb-tok"}"#;
12419        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12420        assert!(parsed.channel_ids.is_empty());
12421        assert!(parsed.allowed_users.is_empty());
12422        assert!(!parsed.interrupt_on_new_message);
12423        assert_eq!(parsed.thread_replies, None);
12424        assert!(!parsed.mention_only);
12425    }
12426
12427    #[test]
12428    async fn slack_config_deserializes_with_allowed_users() {
12429        let json = r#"{"bot_token":"xoxb-tok","allowed_users":["U111"]}"#;
12430        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12431        assert!(parsed.channel_ids.is_empty());
12432        assert_eq!(parsed.allowed_users, vec!["U111"]);
12433        assert!(!parsed.interrupt_on_new_message);
12434        assert_eq!(parsed.thread_replies, None);
12435        assert!(!parsed.mention_only);
12436    }
12437
12438    #[test]
12439    async fn slack_config_deserializes_with_channel_ids() {
12440        let json = r#"{"bot_token":"xoxb-tok","channel_ids":["C111","D222"]}"#;
12441        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12442        assert_eq!(parsed.channel_ids, vec!["C111", "D222"]);
12443        assert!(parsed.allowed_users.is_empty());
12444        assert!(!parsed.interrupt_on_new_message);
12445        assert_eq!(parsed.thread_replies, None);
12446        assert!(!parsed.mention_only);
12447    }
12448
12449    #[test]
12450    async fn slack_config_deserializes_with_mention_only() {
12451        let json = r#"{"bot_token":"xoxb-tok","mention_only":true}"#;
12452        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12453        assert!(parsed.mention_only);
12454        assert!(!parsed.interrupt_on_new_message);
12455        assert_eq!(parsed.thread_replies, None);
12456    }
12457
12458    #[test]
12459    async fn slack_config_deserializes_interrupt_on_new_message() {
12460        let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
12461        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12462        assert!(parsed.interrupt_on_new_message);
12463        assert_eq!(parsed.thread_replies, None);
12464        assert!(!parsed.mention_only);
12465    }
12466
12467    #[test]
12468    async fn slack_config_deserializes_thread_replies() {
12469        let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#;
12470        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12471        assert_eq!(parsed.thread_replies, Some(false));
12472        assert!(!parsed.interrupt_on_new_message);
12473        assert!(!parsed.mention_only);
12474    }
12475
12476    #[test]
12477    async fn discord_config_default_interrupt_on_new_message_is_false() {
12478        let json = r#"{"bot_token":"tok"}"#;
12479        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12480        assert!(!parsed.interrupt_on_new_message);
12481    }
12482
12483    #[test]
12484    async fn discord_config_deserializes_interrupt_on_new_message_true() {
12485        let json = r#"{"bot_token":"tok","interrupt_on_new_message":true}"#;
12486        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12487        assert!(parsed.interrupt_on_new_message);
12488    }
12489
12490    #[test]
12491    async fn discord_config_toml_backward_compat() {
12492        let toml_str = r#"
12493bot_token = "tok"
12494guild_id = "123"
12495"#;
12496        let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
12497        assert!(parsed.allowed_users.is_empty());
12498        assert_eq!(parsed.bot_token, "tok");
12499    }
12500
12501    #[test]
12502    async fn slack_config_toml_backward_compat() {
12503        let toml_str = r#"
12504bot_token = "xoxb-tok"
12505channel_id = "C123"
12506"#;
12507        let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
12508        assert!(parsed.channel_ids.is_empty());
12509        assert!(parsed.allowed_users.is_empty());
12510        assert!(!parsed.interrupt_on_new_message);
12511        assert_eq!(parsed.thread_replies, None);
12512        assert!(!parsed.mention_only);
12513        assert_eq!(parsed.channel_id.as_deref(), Some("C123"));
12514    }
12515
12516    #[test]
12517    async fn slack_config_toml_accepts_channel_ids() {
12518        let toml_str = r#"
12519bot_token = "xoxb-tok"
12520channel_ids = ["C123", "D456"]
12521"#;
12522        let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
12523        assert_eq!(parsed.channel_ids, vec!["C123", "D456"]);
12524        assert!(parsed.allowed_users.is_empty());
12525        assert!(!parsed.interrupt_on_new_message);
12526        assert_eq!(parsed.thread_replies, None);
12527        assert!(!parsed.mention_only);
12528        assert!(parsed.channel_id.is_none());
12529    }
12530
12531    #[test]
12532    async fn mattermost_config_default_interrupt_on_new_message_is_false() {
12533        let json = r#"{"url":"https://mm.example.com","bot_token":"tok"}"#;
12534        let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
12535        assert!(!parsed.interrupt_on_new_message);
12536    }
12537
12538    #[test]
12539    async fn mattermost_config_deserializes_interrupt_on_new_message_true() {
12540        let json =
12541            r#"{"url":"https://mm.example.com","bot_token":"tok","interrupt_on_new_message":true}"#;
12542        let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
12543        assert!(parsed.interrupt_on_new_message);
12544    }
12545
12546    #[test]
12547    async fn webhook_config_with_secret() {
12548        let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
12549        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
12550        assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
12551    }
12552
12553    #[test]
12554    async fn webhook_config_without_secret() {
12555        let json = r#"{"port":8080}"#;
12556        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
12557        assert!(parsed.secret.is_none());
12558        assert_eq!(parsed.port, 8080);
12559    }
12560
12561    // ── WhatsApp config ──────────────────────────────────────
12562
12563    #[test]
12564    async fn whatsapp_config_serde() {
12565        let wc = WhatsAppConfig {
12566            access_token: Some("EAABx...".into()),
12567            phone_number_id: Some("123456789".into()),
12568            verify_token: Some("my-verify-token".into()),
12569            app_secret: None,
12570            session_path: None,
12571            pair_phone: None,
12572            pair_code: None,
12573            allowed_numbers: vec!["+1234567890".into(), "+9876543210".into()],
12574            mode: WhatsAppWebMode::default(),
12575            dm_policy: WhatsAppChatPolicy::default(),
12576            group_policy: WhatsAppChatPolicy::default(),
12577            self_chat_mode: false,
12578            dm_mention_patterns: vec![],
12579            group_mention_patterns: vec![],
12580            proxy_url: None,
12581        };
12582        let json = serde_json::to_string(&wc).unwrap();
12583        let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
12584        assert_eq!(parsed.access_token, Some("EAABx...".into()));
12585        assert_eq!(parsed.phone_number_id, Some("123456789".into()));
12586        assert_eq!(parsed.verify_token, Some("my-verify-token".into()));
12587        assert_eq!(parsed.allowed_numbers.len(), 2);
12588    }
12589
12590    #[test]
12591    async fn whatsapp_config_toml_roundtrip() {
12592        let wc = WhatsAppConfig {
12593            access_token: Some("tok".into()),
12594            phone_number_id: Some("12345".into()),
12595            verify_token: Some("verify".into()),
12596            app_secret: Some("secret123".into()),
12597            session_path: None,
12598            pair_phone: None,
12599            pair_code: None,
12600            allowed_numbers: vec!["+1".into()],
12601            mode: WhatsAppWebMode::default(),
12602            dm_policy: WhatsAppChatPolicy::default(),
12603            group_policy: WhatsAppChatPolicy::default(),
12604            self_chat_mode: false,
12605            dm_mention_patterns: vec![],
12606            group_mention_patterns: vec![],
12607            proxy_url: None,
12608        };
12609        let toml_str = toml::to_string(&wc).unwrap();
12610        let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
12611        assert_eq!(parsed.phone_number_id, Some("12345".into()));
12612        assert_eq!(parsed.allowed_numbers, vec!["+1"]);
12613    }
12614
12615    #[test]
12616    async fn whatsapp_config_deserializes_without_allowed_numbers() {
12617        let json = r#"{"access_token":"tok","phone_number_id":"123","verify_token":"ver"}"#;
12618        let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
12619        assert!(parsed.allowed_numbers.is_empty());
12620    }
12621
12622    #[test]
12623    async fn whatsapp_config_wildcard_allowed() {
12624        let wc = WhatsAppConfig {
12625            access_token: Some("tok".into()),
12626            phone_number_id: Some("123".into()),
12627            verify_token: Some("ver".into()),
12628            app_secret: None,
12629            session_path: None,
12630            pair_phone: None,
12631            pair_code: None,
12632            allowed_numbers: vec!["*".into()],
12633            mode: WhatsAppWebMode::default(),
12634            dm_policy: WhatsAppChatPolicy::default(),
12635            group_policy: WhatsAppChatPolicy::default(),
12636            self_chat_mode: false,
12637            dm_mention_patterns: vec![],
12638            group_mention_patterns: vec![],
12639            proxy_url: None,
12640        };
12641        let toml_str = toml::to_string(&wc).unwrap();
12642        let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
12643        assert_eq!(parsed.allowed_numbers, vec!["*"]);
12644    }
12645
12646    #[test]
12647    async fn whatsapp_config_backend_type_cloud_precedence_when_ambiguous() {
12648        let wc = WhatsAppConfig {
12649            access_token: Some("tok".into()),
12650            phone_number_id: Some("123".into()),
12651            verify_token: Some("ver".into()),
12652            app_secret: None,
12653            session_path: Some("~/.construct/state/whatsapp-web/session.db".into()),
12654            pair_phone: None,
12655            pair_code: None,
12656            allowed_numbers: vec!["+1".into()],
12657            mode: WhatsAppWebMode::default(),
12658            dm_policy: WhatsAppChatPolicy::default(),
12659            group_policy: WhatsAppChatPolicy::default(),
12660            self_chat_mode: false,
12661            dm_mention_patterns: vec![],
12662            group_mention_patterns: vec![],
12663            proxy_url: None,
12664        };
12665        assert!(wc.is_ambiguous_config());
12666        assert_eq!(wc.backend_type(), "cloud");
12667    }
12668
12669    #[test]
12670    async fn whatsapp_config_backend_type_web() {
12671        let wc = WhatsAppConfig {
12672            access_token: None,
12673            phone_number_id: None,
12674            verify_token: None,
12675            app_secret: None,
12676            session_path: Some("~/.construct/state/whatsapp-web/session.db".into()),
12677            pair_phone: None,
12678            pair_code: None,
12679            allowed_numbers: vec![],
12680            mode: WhatsAppWebMode::default(),
12681            dm_policy: WhatsAppChatPolicy::default(),
12682            group_policy: WhatsAppChatPolicy::default(),
12683            self_chat_mode: false,
12684            dm_mention_patterns: vec![],
12685            group_mention_patterns: vec![],
12686            proxy_url: None,
12687        };
12688        assert!(!wc.is_ambiguous_config());
12689        assert_eq!(wc.backend_type(), "web");
12690    }
12691
12692    #[test]
12693    async fn channels_config_with_whatsapp() {
12694        let c = ChannelsConfig {
12695            cli: true,
12696            telegram: None,
12697            discord: None,
12698            discord_history: None,
12699            slack: None,
12700            mattermost: None,
12701            webhook: None,
12702            imessage: None,
12703            matrix: None,
12704            signal: None,
12705            whatsapp: Some(WhatsAppConfig {
12706                access_token: Some("tok".into()),
12707                phone_number_id: Some("123".into()),
12708                verify_token: Some("ver".into()),
12709                app_secret: None,
12710                session_path: None,
12711                pair_phone: None,
12712                pair_code: None,
12713                allowed_numbers: vec!["+1".into()],
12714                mode: WhatsAppWebMode::default(),
12715                dm_policy: WhatsAppChatPolicy::default(),
12716                group_policy: WhatsAppChatPolicy::default(),
12717                self_chat_mode: false,
12718                dm_mention_patterns: vec![],
12719                group_mention_patterns: vec![],
12720                proxy_url: None,
12721            }),
12722            linq: None,
12723            wati: None,
12724            nextcloud_talk: None,
12725            email: None,
12726            gmail_push: None,
12727            irc: None,
12728            lark: None,
12729            feishu: None,
12730            dingtalk: None,
12731            wecom: None,
12732            qq: None,
12733            twitter: None,
12734            mochat: None,
12735            #[cfg(feature = "channel-nostr")]
12736            nostr: None,
12737            clawdtalk: None,
12738            reddit: None,
12739            bluesky: None,
12740            voice_call: None,
12741            #[cfg(feature = "voice-wake")]
12742            voice_wake: None,
12743            message_timeout_secs: 300,
12744            ack_reactions: true,
12745            show_tool_calls: true,
12746            session_persistence: true,
12747            session_backend: default_session_backend(),
12748            session_ttl_hours: 0,
12749            debounce_ms: 0,
12750        };
12751        let toml_str = toml::to_string_pretty(&c).unwrap();
12752        let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
12753        assert!(parsed.whatsapp.is_some());
12754        let wa = parsed.whatsapp.unwrap();
12755        assert_eq!(wa.phone_number_id, Some("123".into()));
12756        assert_eq!(wa.allowed_numbers, vec!["+1"]);
12757    }
12758
12759    #[test]
12760    async fn channels_config_default_has_no_whatsapp() {
12761        let c = ChannelsConfig::default();
12762        assert!(c.whatsapp.is_none());
12763    }
12764
12765    #[test]
12766    async fn channels_config_default_has_no_nextcloud_talk() {
12767        let c = ChannelsConfig::default();
12768        assert!(c.nextcloud_talk.is_none());
12769    }
12770
12771    // ══════════════════════════════════════════════════════════
12772    // SECURITY CHECKLIST TESTS — Gateway config
12773    // ══════════════════════════════════════════════════════════
12774
12775    #[test]
12776    async fn checklist_gateway_default_requires_pairing() {
12777        let g = GatewayConfig::default();
12778        assert!(g.require_pairing, "Pairing must be required by default");
12779    }
12780
12781    #[test]
12782    async fn checklist_gateway_default_blocks_public_bind() {
12783        let g = GatewayConfig::default();
12784        assert!(
12785            !g.allow_public_bind,
12786            "Public bind must be blocked by default"
12787        );
12788    }
12789
12790    #[test]
12791    async fn checklist_gateway_default_no_tokens() {
12792        let g = GatewayConfig::default();
12793        assert!(
12794            g.paired_tokens.is_empty(),
12795            "No pre-paired tokens by default"
12796        );
12797        assert_eq!(g.pair_rate_limit_per_minute, 10);
12798        assert_eq!(g.webhook_rate_limit_per_minute, 60);
12799        assert!(!g.trust_forwarded_headers);
12800        assert_eq!(g.rate_limit_max_keys, 10_000);
12801        assert_eq!(g.idempotency_ttl_secs, 300);
12802        assert_eq!(g.idempotency_max_keys, 10_000);
12803    }
12804
12805    #[test]
12806    async fn checklist_gateway_cli_default_host_is_localhost() {
12807        // The CLI default for --host is 127.0.0.1 (checked in main.rs)
12808        // Here we verify the config default matches
12809        let c = Config::default();
12810        assert!(
12811            c.gateway.require_pairing,
12812            "Config default must require pairing"
12813        );
12814        assert!(
12815            !c.gateway.allow_public_bind,
12816            "Config default must block public bind"
12817        );
12818    }
12819
12820    #[test]
12821    async fn checklist_gateway_serde_roundtrip() {
12822        let g = GatewayConfig {
12823            port: 42617,
12824            host: "127.0.0.1".into(),
12825            require_pairing: true,
12826            allow_public_bind: false,
12827            paired_tokens: vec!["zc_test_token".into()],
12828            pair_rate_limit_per_minute: 12,
12829            webhook_rate_limit_per_minute: 80,
12830            trust_forwarded_headers: true,
12831            path_prefix: Some("/construct".into()),
12832            rate_limit_max_keys: 2048,
12833            idempotency_ttl_secs: 600,
12834            idempotency_max_keys: 4096,
12835            session_persistence: true,
12836            session_ttl_hours: 0,
12837            pairing_dashboard: PairingDashboardConfig::default(),
12838            tls: None,
12839        };
12840        let toml_str = toml::to_string(&g).unwrap();
12841        let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
12842        assert!(parsed.require_pairing);
12843        assert!(parsed.session_persistence);
12844        assert_eq!(parsed.session_ttl_hours, 0);
12845        assert!(!parsed.allow_public_bind);
12846        assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
12847        assert_eq!(parsed.pair_rate_limit_per_minute, 12);
12848        assert_eq!(parsed.webhook_rate_limit_per_minute, 80);
12849        assert!(parsed.trust_forwarded_headers);
12850        assert_eq!(parsed.path_prefix.as_deref(), Some("/construct"));
12851        assert_eq!(parsed.rate_limit_max_keys, 2048);
12852        assert_eq!(parsed.idempotency_ttl_secs, 600);
12853        assert_eq!(parsed.idempotency_max_keys, 4096);
12854    }
12855
12856    #[test]
12857    async fn checklist_gateway_backward_compat_no_gateway_section() {
12858        // Old configs without [gateway] should get secure defaults
12859        let minimal = r#"
12860workspace_dir = "/tmp/ws"
12861config_path = "/tmp/config.toml"
12862default_temperature = 0.7
12863"#;
12864        let parsed = parse_test_config(minimal);
12865        assert!(
12866            parsed.gateway.require_pairing,
12867            "Missing [gateway] must default to require_pairing=true"
12868        );
12869        assert!(
12870            !parsed.gateway.allow_public_bind,
12871            "Missing [gateway] must default to allow_public_bind=false"
12872        );
12873    }
12874
12875    #[test]
12876    async fn checklist_autonomy_default_is_workspace_scoped() {
12877        let a = AutonomyConfig::default();
12878        assert!(a.workspace_only, "Default autonomy must be workspace_only");
12879        assert!(
12880            a.forbidden_paths.contains(&"/etc".to_string()),
12881            "Must block /etc"
12882        );
12883        assert!(
12884            a.forbidden_paths.contains(&"/proc".to_string()),
12885            "Must block /proc"
12886        );
12887        assert!(
12888            a.forbidden_paths.contains(&"~/.ssh".to_string()),
12889            "Must block ~/.ssh"
12890        );
12891    }
12892
12893    // ══════════════════════════════════════════════════════════
12894    // COMPOSIO CONFIG TESTS
12895    // ══════════════════════════════════════════════════════════
12896
12897    #[test]
12898    async fn composio_config_default_disabled() {
12899        let c = ComposioConfig::default();
12900        assert!(!c.enabled, "Composio must be disabled by default");
12901        assert!(c.api_key.is_none(), "No API key by default");
12902        assert_eq!(c.entity_id, "default");
12903    }
12904
12905    #[test]
12906    async fn composio_config_serde_roundtrip() {
12907        let c = ComposioConfig {
12908            enabled: true,
12909            api_key: Some("comp-key-123".into()),
12910            entity_id: "user42".into(),
12911        };
12912        let toml_str = toml::to_string(&c).unwrap();
12913        let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
12914        assert!(parsed.enabled);
12915        assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
12916        assert_eq!(parsed.entity_id, "user42");
12917    }
12918
12919    #[test]
12920    async fn composio_config_backward_compat_missing_section() {
12921        let minimal = r#"
12922workspace_dir = "/tmp/ws"
12923config_path = "/tmp/config.toml"
12924default_temperature = 0.7
12925"#;
12926        let parsed = parse_test_config(minimal);
12927        assert!(
12928            !parsed.composio.enabled,
12929            "Missing [composio] must default to disabled"
12930        );
12931        assert!(parsed.composio.api_key.is_none());
12932    }
12933
12934    #[test]
12935    async fn composio_config_partial_toml() {
12936        let toml_str = r"
12937enabled = true
12938";
12939        let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
12940        assert!(parsed.enabled);
12941        assert!(parsed.api_key.is_none());
12942        assert_eq!(parsed.entity_id, "default");
12943    }
12944
12945    #[test]
12946    async fn composio_config_enable_alias_supported() {
12947        let toml_str = r"
12948enable = true
12949";
12950        let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
12951        assert!(parsed.enabled);
12952        assert!(parsed.api_key.is_none());
12953        assert_eq!(parsed.entity_id, "default");
12954    }
12955
12956    // ══════════════════════════════════════════════════════════
12957    // SECRETS CONFIG TESTS
12958    // ══════════════════════════════════════════════════════════
12959
12960    #[test]
12961    async fn secrets_config_default_encrypts() {
12962        let s = SecretsConfig::default();
12963        assert!(s.encrypt, "Encryption must be enabled by default");
12964    }
12965
12966    #[test]
12967    async fn secrets_config_serde_roundtrip() {
12968        let s = SecretsConfig { encrypt: false };
12969        let toml_str = toml::to_string(&s).unwrap();
12970        let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
12971        assert!(!parsed.encrypt);
12972    }
12973
12974    #[test]
12975    async fn secrets_config_backward_compat_missing_section() {
12976        let minimal = r#"
12977workspace_dir = "/tmp/ws"
12978config_path = "/tmp/config.toml"
12979default_temperature = 0.7
12980"#;
12981        let parsed = parse_test_config(minimal);
12982        assert!(
12983            parsed.secrets.encrypt,
12984            "Missing [secrets] must default to encrypt=true"
12985        );
12986    }
12987
12988    #[test]
12989    async fn config_default_has_composio_and_secrets() {
12990        let c = Config::default();
12991        assert!(!c.composio.enabled);
12992        assert!(c.composio.api_key.is_none());
12993        assert!(c.secrets.encrypt);
12994        assert!(c.browser.enabled);
12995        assert_eq!(c.browser.allowed_domains, vec!["*".to_string()]);
12996    }
12997
12998    #[test]
12999    async fn browser_config_default_enabled() {
13000        let b = BrowserConfig::default();
13001        assert!(b.enabled);
13002        assert_eq!(b.allowed_domains, vec!["*".to_string()]);
13003        assert_eq!(b.backend, "agent_browser");
13004        assert!(b.native_headless);
13005        assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
13006        assert!(b.native_chrome_path.is_none());
13007        assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions");
13008        assert_eq!(b.computer_use.timeout_ms, 15_000);
13009        assert!(!b.computer_use.allow_remote_endpoint);
13010        assert!(b.computer_use.window_allowlist.is_empty());
13011        assert!(b.computer_use.max_coordinate_x.is_none());
13012        assert!(b.computer_use.max_coordinate_y.is_none());
13013    }
13014
13015    #[test]
13016    async fn browser_config_serde_roundtrip() {
13017        let b = BrowserConfig {
13018            enabled: true,
13019            allowed_domains: vec!["example.com".into(), "docs.example.com".into()],
13020            session_name: None,
13021            backend: "auto".into(),
13022            native_headless: false,
13023            native_webdriver_url: "http://localhost:4444".into(),
13024            native_chrome_path: Some("/usr/bin/chromium".into()),
13025            computer_use: BrowserComputerUseConfig {
13026                endpoint: "https://computer-use.example.com/v1/actions".into(),
13027                api_key: Some("test-token".into()),
13028                timeout_ms: 8_000,
13029                allow_remote_endpoint: true,
13030                window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()],
13031                max_coordinate_x: Some(3840),
13032                max_coordinate_y: Some(2160),
13033            },
13034        };
13035        let toml_str = toml::to_string(&b).unwrap();
13036        let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap();
13037        assert!(parsed.enabled);
13038        assert_eq!(parsed.allowed_domains.len(), 2);
13039        assert_eq!(parsed.allowed_domains[0], "example.com");
13040        assert_eq!(parsed.backend, "auto");
13041        assert!(!parsed.native_headless);
13042        assert_eq!(parsed.native_webdriver_url, "http://localhost:4444");
13043        assert_eq!(
13044            parsed.native_chrome_path.as_deref(),
13045            Some("/usr/bin/chromium")
13046        );
13047        assert_eq!(
13048            parsed.computer_use.endpoint,
13049            "https://computer-use.example.com/v1/actions"
13050        );
13051        assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token"));
13052        assert_eq!(parsed.computer_use.timeout_ms, 8_000);
13053        assert!(parsed.computer_use.allow_remote_endpoint);
13054        assert_eq!(parsed.computer_use.window_allowlist.len(), 2);
13055        assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840));
13056        assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160));
13057    }
13058
13059    #[test]
13060    async fn browser_config_backward_compat_missing_section() {
13061        let minimal = r#"
13062workspace_dir = "/tmp/ws"
13063config_path = "/tmp/config.toml"
13064default_temperature = 0.7
13065"#;
13066        let parsed = parse_test_config(minimal);
13067        assert!(parsed.browser.enabled);
13068        assert_eq!(parsed.browser.allowed_domains, vec!["*".to_string()]);
13069    }
13070
13071    // ── Environment variable overrides (Docker support) ─────────
13072
13073    async fn env_override_lock() -> MutexGuard<'static, ()> {
13074        static ENV_OVERRIDE_TEST_LOCK: Mutex<()> = Mutex::const_new(());
13075        ENV_OVERRIDE_TEST_LOCK.lock().await
13076    }
13077
13078    fn clear_proxy_env_test_vars() {
13079        for key in [
13080            "CONSTRUCT_PROXY_ENABLED",
13081            "CONSTRUCT_HTTP_PROXY",
13082            "CONSTRUCT_HTTPS_PROXY",
13083            "CONSTRUCT_ALL_PROXY",
13084            "CONSTRUCT_NO_PROXY",
13085            "CONSTRUCT_PROXY_SCOPE",
13086            "CONSTRUCT_PROXY_SERVICES",
13087            "HTTP_PROXY",
13088            "HTTPS_PROXY",
13089            "ALL_PROXY",
13090            "NO_PROXY",
13091            "http_proxy",
13092            "https_proxy",
13093            "all_proxy",
13094            "no_proxy",
13095        ] {
13096            // SAFETY: test-only, single-threaded test runner.
13097            unsafe { std::env::remove_var(key) };
13098        }
13099    }
13100
13101    #[test]
13102    async fn env_override_api_key() {
13103        let _env_guard = env_override_lock().await;
13104        let mut config = Config::default();
13105        assert!(config.api_key.is_none());
13106
13107        // SAFETY: test-only, single-threaded test runner.
13108        unsafe { std::env::set_var("CONSTRUCT_API_KEY", "sk-test-env-key") };
13109        config.apply_env_overrides();
13110        assert_eq!(config.api_key.as_deref(), Some("sk-test-env-key"));
13111
13112        // SAFETY: test-only, single-threaded test runner.
13113        unsafe { std::env::remove_var("CONSTRUCT_API_KEY") };
13114    }
13115
13116    #[test]
13117    async fn env_override_api_key_fallback() {
13118        let _env_guard = env_override_lock().await;
13119        let mut config = Config::default();
13120
13121        // SAFETY: test-only, single-threaded test runner.
13122        unsafe { std::env::remove_var("CONSTRUCT_API_KEY") };
13123        // SAFETY: test-only, single-threaded test runner.
13124        unsafe { std::env::set_var("API_KEY", "sk-fallback-key") };
13125        config.apply_env_overrides();
13126        assert_eq!(config.api_key.as_deref(), Some("sk-fallback-key"));
13127
13128        // SAFETY: test-only, single-threaded test runner.
13129        unsafe { std::env::remove_var("API_KEY") };
13130    }
13131
13132    #[test]
13133    async fn env_override_provider() {
13134        let _env_guard = env_override_lock().await;
13135        let mut config = Config::default();
13136
13137        // SAFETY: test-only, single-threaded test runner.
13138        unsafe { std::env::set_var("CONSTRUCT_PROVIDER", "anthropic") };
13139        config.apply_env_overrides();
13140        assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
13141
13142        // SAFETY: test-only, single-threaded test runner.
13143        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13144    }
13145
13146    #[test]
13147    async fn env_override_model_provider_alias() {
13148        let _env_guard = env_override_lock().await;
13149        let mut config = Config::default();
13150
13151        // SAFETY: test-only, single-threaded test runner.
13152        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13153        // SAFETY: test-only, single-threaded test runner.
13154        unsafe { std::env::set_var("CONSTRUCT_MODEL_PROVIDER", "openai-codex") };
13155        config.apply_env_overrides();
13156        assert_eq!(config.default_provider.as_deref(), Some("openai-codex"));
13157
13158        // SAFETY: test-only, single-threaded test runner.
13159        unsafe { std::env::remove_var("CONSTRUCT_MODEL_PROVIDER") };
13160    }
13161
13162    #[test]
13163    async fn toml_supports_model_provider_and_model_alias_fields() {
13164        let raw = r#"
13165default_temperature = 0.7
13166model_provider = "sub2api"
13167model = "gpt-5.3-codex"
13168
13169[model_providers.sub2api]
13170name = "sub2api"
13171base_url = "https://api.tonsof.blue/v1"
13172wire_api = "responses"
13173requires_openai_auth = true
13174"#;
13175
13176        let parsed = parse_test_config(raw);
13177        assert_eq!(parsed.default_provider.as_deref(), Some("sub2api"));
13178        assert_eq!(parsed.default_model.as_deref(), Some("gpt-5.3-codex"));
13179        let profile = parsed
13180            .model_providers
13181            .get("sub2api")
13182            .expect("profile should exist");
13183        assert_eq!(profile.wire_api.as_deref(), Some("responses"));
13184        assert!(profile.requires_openai_auth);
13185    }
13186
13187    #[test]
13188    async fn env_override_open_skills_enabled_and_dir() {
13189        let _env_guard = env_override_lock().await;
13190        let mut config = Config::default();
13191        assert!(!config.skills.open_skills_enabled);
13192        assert!(config.skills.open_skills_dir.is_none());
13193        assert_eq!(
13194            config.skills.prompt_injection_mode,
13195            SkillsPromptInjectionMode::Full
13196        );
13197
13198        // SAFETY: test-only, single-threaded test runner.
13199        unsafe { std::env::set_var("CONSTRUCT_OPEN_SKILLS_ENABLED", "true") };
13200        // SAFETY: test-only, single-threaded test runner.
13201        unsafe { std::env::set_var("CONSTRUCT_OPEN_SKILLS_DIR", "/tmp/open-skills") };
13202        // SAFETY: test-only, single-threaded test runner.
13203        unsafe { std::env::set_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS", "yes") };
13204        // SAFETY: test-only, single-threaded test runner.
13205        unsafe { std::env::set_var("CONSTRUCT_SKILLS_PROMPT_MODE", "compact") };
13206        config.apply_env_overrides();
13207
13208        assert!(config.skills.open_skills_enabled);
13209        assert!(config.skills.allow_scripts);
13210        assert_eq!(
13211            config.skills.open_skills_dir.as_deref(),
13212            Some("/tmp/open-skills")
13213        );
13214        assert_eq!(
13215            config.skills.prompt_injection_mode,
13216            SkillsPromptInjectionMode::Compact
13217        );
13218
13219        // SAFETY: test-only, single-threaded test runner.
13220        unsafe { std::env::remove_var("CONSTRUCT_OPEN_SKILLS_ENABLED") };
13221        // SAFETY: test-only, single-threaded test runner.
13222        unsafe { std::env::remove_var("CONSTRUCT_OPEN_SKILLS_DIR") };
13223        // SAFETY: test-only, single-threaded test runner.
13224        unsafe { std::env::remove_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS") };
13225        // SAFETY: test-only, single-threaded test runner.
13226        unsafe { std::env::remove_var("CONSTRUCT_SKILLS_PROMPT_MODE") };
13227    }
13228
13229    #[test]
13230    async fn env_override_open_skills_enabled_invalid_value_keeps_existing_value() {
13231        let _env_guard = env_override_lock().await;
13232        let mut config = Config::default();
13233        config.skills.open_skills_enabled = true;
13234        config.skills.allow_scripts = true;
13235        config.skills.prompt_injection_mode = SkillsPromptInjectionMode::Compact;
13236
13237        // SAFETY: test-only, single-threaded test runner.
13238        unsafe { std::env::set_var("CONSTRUCT_OPEN_SKILLS_ENABLED", "maybe") };
13239        // SAFETY: test-only, single-threaded test runner.
13240        unsafe { std::env::set_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS", "maybe") };
13241        // SAFETY: test-only, single-threaded test runner.
13242        unsafe { std::env::set_var("CONSTRUCT_SKILLS_PROMPT_MODE", "invalid") };
13243        config.apply_env_overrides();
13244
13245        assert!(config.skills.open_skills_enabled);
13246        assert!(config.skills.allow_scripts);
13247        assert_eq!(
13248            config.skills.prompt_injection_mode,
13249            SkillsPromptInjectionMode::Compact
13250        );
13251        // SAFETY: test-only, single-threaded test runner.
13252        unsafe { std::env::remove_var("CONSTRUCT_OPEN_SKILLS_ENABLED") };
13253        // SAFETY: test-only, single-threaded test runner.
13254        unsafe { std::env::remove_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS") };
13255        // SAFETY: test-only, single-threaded test runner.
13256        unsafe { std::env::remove_var("CONSTRUCT_SKILLS_PROMPT_MODE") };
13257    }
13258
13259    #[test]
13260    async fn env_override_provider_fallback() {
13261        let _env_guard = env_override_lock().await;
13262        let mut config = Config::default();
13263
13264        // SAFETY: test-only, single-threaded test runner.
13265        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13266        // SAFETY: test-only, single-threaded test runner.
13267        unsafe { std::env::set_var("PROVIDER", "openai") };
13268        config.apply_env_overrides();
13269        assert_eq!(config.default_provider.as_deref(), Some("openai"));
13270
13271        // SAFETY: test-only, single-threaded test runner.
13272        unsafe { std::env::remove_var("PROVIDER") };
13273    }
13274
13275    #[test]
13276    async fn env_override_provider_fallback_does_not_replace_non_default_provider() {
13277        let _env_guard = env_override_lock().await;
13278        let mut config = Config {
13279            default_provider: Some("custom:https://proxy.example.com/v1".to_string()),
13280            ..Config::default()
13281        };
13282
13283        // SAFETY: test-only, single-threaded test runner.
13284        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13285        // SAFETY: test-only, single-threaded test runner.
13286        unsafe { std::env::set_var("PROVIDER", "openrouter") };
13287        config.apply_env_overrides();
13288        assert_eq!(
13289            config.default_provider.as_deref(),
13290            Some("custom:https://proxy.example.com/v1")
13291        );
13292
13293        // SAFETY: test-only, single-threaded test runner.
13294        unsafe { std::env::remove_var("PROVIDER") };
13295    }
13296
13297    #[test]
13298    async fn env_override_zero_claw_provider_overrides_non_default_provider() {
13299        let _env_guard = env_override_lock().await;
13300        let mut config = Config {
13301            default_provider: Some("custom:https://proxy.example.com/v1".to_string()),
13302            ..Config::default()
13303        };
13304
13305        // SAFETY: test-only, single-threaded test runner.
13306        unsafe { std::env::set_var("CONSTRUCT_PROVIDER", "openrouter") };
13307        // SAFETY: test-only, single-threaded test runner.
13308        unsafe { std::env::set_var("PROVIDER", "anthropic") };
13309        config.apply_env_overrides();
13310        assert_eq!(config.default_provider.as_deref(), Some("openrouter"));
13311
13312        // SAFETY: test-only, single-threaded test runner.
13313        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13314        // SAFETY: test-only, single-threaded test runner.
13315        unsafe { std::env::remove_var("PROVIDER") };
13316    }
13317
13318    #[test]
13319    async fn env_override_glm_api_key_for_regional_aliases() {
13320        let _env_guard = env_override_lock().await;
13321        let mut config = Config {
13322            default_provider: Some("glm-cn".to_string()),
13323            ..Config::default()
13324        };
13325
13326        // SAFETY: test-only, single-threaded test runner.
13327        unsafe { std::env::set_var("GLM_API_KEY", "glm-regional-key") };
13328        config.apply_env_overrides();
13329        assert_eq!(config.api_key.as_deref(), Some("glm-regional-key"));
13330
13331        // SAFETY: test-only, single-threaded test runner.
13332        unsafe { std::env::remove_var("GLM_API_KEY") };
13333    }
13334
13335    #[test]
13336    async fn env_override_zai_api_key_for_regional_aliases() {
13337        let _env_guard = env_override_lock().await;
13338        let mut config = Config {
13339            default_provider: Some("zai-cn".to_string()),
13340            ..Config::default()
13341        };
13342
13343        // SAFETY: test-only, single-threaded test runner.
13344        unsafe { std::env::set_var("ZAI_API_KEY", "zai-regional-key") };
13345        config.apply_env_overrides();
13346        assert_eq!(config.api_key.as_deref(), Some("zai-regional-key"));
13347
13348        // SAFETY: test-only, single-threaded test runner.
13349        unsafe { std::env::remove_var("ZAI_API_KEY") };
13350    }
13351
13352    #[test]
13353    async fn env_override_model() {
13354        let _env_guard = env_override_lock().await;
13355        let mut config = Config::default();
13356
13357        // SAFETY: test-only, single-threaded test runner.
13358        unsafe { std::env::set_var("CONSTRUCT_MODEL", "gpt-4o") };
13359        config.apply_env_overrides();
13360        assert_eq!(config.default_model.as_deref(), Some("gpt-4o"));
13361
13362        // SAFETY: test-only, single-threaded test runner.
13363        unsafe { std::env::remove_var("CONSTRUCT_MODEL") };
13364    }
13365
13366    #[test]
13367    async fn model_provider_profile_maps_to_custom_endpoint() {
13368        let _env_guard = env_override_lock().await;
13369        let mut config = Config {
13370            default_provider: Some("sub2api".to_string()),
13371            model_providers: HashMap::from([(
13372                "sub2api".to_string(),
13373                ModelProviderConfig {
13374                    name: Some("sub2api".to_string()),
13375                    base_url: Some("https://api.tonsof.blue/v1".to_string()),
13376                    wire_api: None,
13377                    requires_openai_auth: false,
13378                    azure_openai_resource: None,
13379                    azure_openai_deployment: None,
13380                    azure_openai_api_version: None,
13381                    api_path: None,
13382                    max_tokens: None,
13383                },
13384            )]),
13385            ..Config::default()
13386        };
13387
13388        config.apply_env_overrides();
13389        assert_eq!(
13390            config.default_provider.as_deref(),
13391            Some("custom:https://api.tonsof.blue/v1")
13392        );
13393        assert_eq!(
13394            config.api_url.as_deref(),
13395            Some("https://api.tonsof.blue/v1")
13396        );
13397    }
13398
13399    #[test]
13400    async fn model_provider_profile_responses_uses_openai_codex_and_openai_key() {
13401        let _env_guard = env_override_lock().await;
13402        let mut config = Config {
13403            default_provider: Some("sub2api".to_string()),
13404            model_providers: HashMap::from([(
13405                "sub2api".to_string(),
13406                ModelProviderConfig {
13407                    name: Some("sub2api".to_string()),
13408                    base_url: Some("https://api.tonsof.blue".to_string()),
13409                    wire_api: Some("responses".to_string()),
13410                    requires_openai_auth: true,
13411                    azure_openai_resource: None,
13412                    azure_openai_deployment: None,
13413                    azure_openai_api_version: None,
13414                    api_path: None,
13415                    max_tokens: None,
13416                },
13417            )]),
13418            api_key: None,
13419            ..Config::default()
13420        };
13421
13422        // SAFETY: test-only, single-threaded test runner.
13423        unsafe { std::env::set_var("OPENAI_API_KEY", "sk-test-codex-key") };
13424        config.apply_env_overrides();
13425        // SAFETY: test-only, single-threaded test runner.
13426        unsafe { std::env::remove_var("OPENAI_API_KEY") };
13427
13428        assert_eq!(config.default_provider.as_deref(), Some("openai-codex"));
13429        assert_eq!(config.api_url.as_deref(), Some("https://api.tonsof.blue"));
13430        assert_eq!(config.api_key.as_deref(), Some("sk-test-codex-key"));
13431    }
13432
13433    #[test]
13434    async fn save_repairs_bare_config_filename_using_runtime_resolution() {
13435        let _env_guard = env_override_lock().await;
13436        let temp_home =
13437            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13438        let workspace_dir = temp_home.join("workspace");
13439        let resolved_config_path = temp_home.join(".construct").join("config.toml");
13440
13441        let original_home = std::env::var("HOME").ok();
13442        // SAFETY: test-only, single-threaded test runner.
13443        unsafe { std::env::set_var("HOME", &temp_home) };
13444        // SAFETY: test-only, single-threaded test runner.
13445        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13446
13447        let mut config = Config::default();
13448        config.workspace_dir = workspace_dir;
13449        config.config_path = PathBuf::from("config.toml");
13450        config.default_temperature = 0.5;
13451        config.save().await.unwrap();
13452
13453        assert!(resolved_config_path.exists());
13454        let saved = tokio::fs::read_to_string(&resolved_config_path)
13455            .await
13456            .unwrap();
13457        let parsed = parse_test_config(&saved);
13458        assert_eq!(parsed.default_temperature, 0.5);
13459
13460        // SAFETY: test-only, single-threaded test runner.
13461        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13462        if let Some(home) = original_home {
13463            // SAFETY: test-only, single-threaded test runner.
13464            unsafe { std::env::set_var("HOME", home) };
13465        } else {
13466            // SAFETY: test-only, single-threaded test runner.
13467            unsafe { std::env::remove_var("HOME") };
13468        }
13469        let _ = tokio::fs::remove_dir_all(temp_home).await;
13470    }
13471
13472    #[test]
13473    async fn validate_ollama_cloud_model_requires_remote_api_url() {
13474        let _env_guard = env_override_lock().await;
13475        let config = Config {
13476            default_provider: Some("ollama".to_string()),
13477            default_model: Some("glm-5:cloud".to_string()),
13478            api_url: None,
13479            api_key: Some("ollama-key".to_string()),
13480            ..Config::default()
13481        };
13482
13483        let error = config.validate().expect_err("expected validation to fail");
13484        assert!(error.to_string().contains(
13485            "default_model uses ':cloud' with provider 'ollama', but api_url is local or unset"
13486        ));
13487    }
13488
13489    #[test]
13490    async fn validate_ollama_cloud_model_accepts_remote_endpoint_and_env_key() {
13491        let _env_guard = env_override_lock().await;
13492        let config = Config {
13493            default_provider: Some("ollama".to_string()),
13494            default_model: Some("glm-5:cloud".to_string()),
13495            api_url: Some("https://ollama.com/api".to_string()),
13496            api_key: None,
13497            ..Config::default()
13498        };
13499
13500        // SAFETY: test-only, single-threaded test runner.
13501        unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-env-key") };
13502        let result = config.validate();
13503        // SAFETY: test-only, single-threaded test runner.
13504        unsafe { std::env::remove_var("OLLAMA_API_KEY") };
13505
13506        assert!(result.is_ok(), "expected validation to pass: {result:?}");
13507    }
13508
13509    #[test]
13510    async fn validate_rejects_unknown_model_provider_wire_api() {
13511        let _env_guard = env_override_lock().await;
13512        let config = Config {
13513            default_provider: Some("sub2api".to_string()),
13514            model_providers: HashMap::from([(
13515                "sub2api".to_string(),
13516                ModelProviderConfig {
13517                    name: Some("sub2api".to_string()),
13518                    base_url: Some("https://api.tonsof.blue/v1".to_string()),
13519                    wire_api: Some("ws".to_string()),
13520                    requires_openai_auth: false,
13521                    azure_openai_resource: None,
13522                    azure_openai_deployment: None,
13523                    azure_openai_api_version: None,
13524                    api_path: None,
13525                    max_tokens: None,
13526                },
13527            )]),
13528            ..Config::default()
13529        };
13530
13531        let error = config.validate().expect_err("expected validation failure");
13532        assert!(
13533            error
13534                .to_string()
13535                .contains("wire_api must be one of: responses, chat_completions")
13536        );
13537    }
13538
13539    #[test]
13540    async fn env_override_model_fallback() {
13541        let _env_guard = env_override_lock().await;
13542        let mut config = Config::default();
13543
13544        // SAFETY: test-only, single-threaded test runner.
13545        unsafe { std::env::remove_var("CONSTRUCT_MODEL") };
13546        // SAFETY: test-only, single-threaded test runner.
13547        unsafe { std::env::set_var("MODEL", "anthropic/claude-3.5-sonnet") };
13548        config.apply_env_overrides();
13549        assert_eq!(
13550            config.default_model.as_deref(),
13551            Some("anthropic/claude-3.5-sonnet")
13552        );
13553
13554        // SAFETY: test-only, single-threaded test runner.
13555        unsafe { std::env::remove_var("MODEL") };
13556    }
13557
13558    #[test]
13559    async fn env_override_workspace() {
13560        let _env_guard = env_override_lock().await;
13561        let mut config = Config::default();
13562
13563        // SAFETY: test-only, single-threaded test runner.
13564        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", "/custom/workspace") };
13565        config.apply_env_overrides();
13566        assert_eq!(config.workspace_dir, PathBuf::from("/custom/workspace"));
13567
13568        // SAFETY: test-only, single-threaded test runner.
13569        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13570    }
13571
13572    #[test]
13573    async fn resolve_runtime_config_dirs_uses_env_workspace_first() {
13574        let _env_guard = env_override_lock().await;
13575        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13576        let default_workspace_dir = default_config_dir.join("workspace");
13577        let workspace_dir = default_config_dir.join("profile-a");
13578
13579        // SAFETY: test-only, single-threaded test runner.
13580        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13581        let (config_dir, resolved_workspace_dir, source) =
13582            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13583                .await
13584                .unwrap();
13585
13586        assert_eq!(source, ConfigResolutionSource::EnvWorkspace);
13587        assert_eq!(config_dir, workspace_dir);
13588        assert_eq!(resolved_workspace_dir, workspace_dir.join("workspace"));
13589
13590        // SAFETY: test-only, single-threaded test runner.
13591        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13592        let _ = fs::remove_dir_all(default_config_dir).await;
13593    }
13594
13595    #[test]
13596    async fn resolve_runtime_config_dirs_uses_env_config_dir_first() {
13597        let _env_guard = env_override_lock().await;
13598        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13599        let default_workspace_dir = default_config_dir.join("workspace");
13600        let explicit_config_dir = default_config_dir.join("explicit-config");
13601        let marker_config_dir = default_config_dir.join("profiles").join("alpha");
13602        let state_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
13603
13604        fs::create_dir_all(&default_config_dir).await.unwrap();
13605        let state = ActiveWorkspaceState {
13606            config_dir: marker_config_dir.to_string_lossy().into_owned(),
13607        };
13608        fs::write(&state_path, toml::to_string(&state).unwrap())
13609            .await
13610            .unwrap();
13611
13612        // SAFETY: test-only, single-threaded test runner.
13613        unsafe { std::env::set_var("CONSTRUCT_CONFIG_DIR", &explicit_config_dir) };
13614        // SAFETY: test-only, single-threaded test runner.
13615        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13616
13617        let (config_dir, resolved_workspace_dir, source) =
13618            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13619                .await
13620                .unwrap();
13621
13622        assert_eq!(source, ConfigResolutionSource::EnvConfigDir);
13623        assert_eq!(config_dir, explicit_config_dir);
13624        assert_eq!(
13625            resolved_workspace_dir,
13626            explicit_config_dir.join("workspace")
13627        );
13628
13629        // SAFETY: test-only, single-threaded test runner.
13630        unsafe { std::env::remove_var("CONSTRUCT_CONFIG_DIR") };
13631        let _ = fs::remove_dir_all(default_config_dir).await;
13632    }
13633
13634    #[test]
13635    async fn resolve_runtime_config_dirs_uses_active_workspace_marker() {
13636        let _env_guard = env_override_lock().await;
13637        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13638        let default_workspace_dir = default_config_dir.join("workspace");
13639        let marker_config_dir = default_config_dir.join("profiles").join("alpha");
13640        let state_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
13641
13642        // SAFETY: test-only, single-threaded test runner.
13643        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13644        fs::create_dir_all(&default_config_dir).await.unwrap();
13645        let state = ActiveWorkspaceState {
13646            config_dir: marker_config_dir.to_string_lossy().into_owned(),
13647        };
13648        fs::write(&state_path, toml::to_string(&state).unwrap())
13649            .await
13650            .unwrap();
13651
13652        let (config_dir, resolved_workspace_dir, source) =
13653            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13654                .await
13655                .unwrap();
13656
13657        assert_eq!(source, ConfigResolutionSource::ActiveWorkspaceMarker);
13658        assert_eq!(config_dir, marker_config_dir);
13659        assert_eq!(resolved_workspace_dir, marker_config_dir.join("workspace"));
13660
13661        let _ = fs::remove_dir_all(default_config_dir).await;
13662    }
13663
13664    #[test]
13665    async fn resolve_runtime_config_dirs_falls_back_to_default_layout() {
13666        let _env_guard = env_override_lock().await;
13667        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13668        let default_workspace_dir = default_config_dir.join("workspace");
13669
13670        // SAFETY: test-only, single-threaded test runner.
13671        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13672        let (config_dir, resolved_workspace_dir, source) =
13673            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13674                .await
13675                .unwrap();
13676
13677        assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
13678        assert_eq!(config_dir, default_config_dir);
13679        assert_eq!(resolved_workspace_dir, default_workspace_dir);
13680
13681        let _ = fs::remove_dir_all(default_config_dir).await;
13682    }
13683
13684    #[test]
13685    async fn load_or_init_workspace_override_uses_workspace_root_for_config() {
13686        let _env_guard = env_override_lock().await;
13687        let temp_home =
13688            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13689        let workspace_dir = temp_home.join("profile-a");
13690
13691        let original_home = std::env::var("HOME").ok();
13692        // SAFETY: test-only, single-threaded test runner.
13693        unsafe { std::env::set_var("HOME", &temp_home) };
13694        // SAFETY: test-only, single-threaded test runner.
13695        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13696
13697        let config = Box::pin(Config::load_or_init()).await.unwrap();
13698
13699        assert_eq!(config.workspace_dir, workspace_dir.join("workspace"));
13700        assert_eq!(config.config_path, workspace_dir.join("config.toml"));
13701        assert!(workspace_dir.join("config.toml").exists());
13702
13703        // SAFETY: test-only, single-threaded test runner.
13704        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13705        if let Some(home) = original_home {
13706            // SAFETY: test-only, single-threaded test runner.
13707            unsafe { std::env::set_var("HOME", home) };
13708        } else {
13709            // SAFETY: test-only, single-threaded test runner.
13710            unsafe { std::env::remove_var("HOME") };
13711        }
13712        let _ = fs::remove_dir_all(temp_home).await;
13713    }
13714
13715    #[test]
13716    async fn load_or_init_workspace_suffix_uses_legacy_config_layout() {
13717        let _env_guard = env_override_lock().await;
13718        let temp_home =
13719            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13720        let workspace_dir = temp_home.join("workspace");
13721        let legacy_config_path = temp_home.join(".construct").join("config.toml");
13722
13723        let original_home = std::env::var("HOME").ok();
13724        // SAFETY: test-only, single-threaded test runner.
13725        unsafe { std::env::set_var("HOME", &temp_home) };
13726        // SAFETY: test-only, single-threaded test runner.
13727        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13728
13729        let config = Box::pin(Config::load_or_init()).await.unwrap();
13730
13731        assert_eq!(config.workspace_dir, workspace_dir);
13732        assert_eq!(config.config_path, legacy_config_path);
13733        assert!(config.config_path.exists());
13734
13735        // SAFETY: test-only, single-threaded test runner.
13736        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13737        if let Some(home) = original_home {
13738            // SAFETY: test-only, single-threaded test runner.
13739            unsafe { std::env::set_var("HOME", home) };
13740        } else {
13741            // SAFETY: test-only, single-threaded test runner.
13742            unsafe { std::env::remove_var("HOME") };
13743        }
13744        let _ = fs::remove_dir_all(temp_home).await;
13745    }
13746
13747    #[test]
13748    async fn load_or_init_workspace_override_keeps_existing_legacy_config() {
13749        let _env_guard = env_override_lock().await;
13750        let temp_home =
13751            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13752        let workspace_dir = temp_home.join("custom-workspace");
13753        let legacy_config_dir = temp_home.join(".construct");
13754        let legacy_config_path = legacy_config_dir.join("config.toml");
13755
13756        fs::create_dir_all(&legacy_config_dir).await.unwrap();
13757        fs::write(
13758            &legacy_config_path,
13759            r#"default_temperature = 0.7
13760default_model = "legacy-model"
13761"#,
13762        )
13763        .await
13764        .unwrap();
13765
13766        let original_home = std::env::var("HOME").ok();
13767        // SAFETY: test-only, single-threaded test runner.
13768        unsafe { std::env::set_var("HOME", &temp_home) };
13769        // SAFETY: test-only, single-threaded test runner.
13770        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13771
13772        let config = Box::pin(Config::load_or_init()).await.unwrap();
13773
13774        assert_eq!(config.workspace_dir, workspace_dir);
13775        assert_eq!(config.config_path, legacy_config_path);
13776        assert_eq!(config.default_model.as_deref(), Some("legacy-model"));
13777
13778        // SAFETY: test-only, single-threaded test runner.
13779        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13780        if let Some(home) = original_home {
13781            // SAFETY: test-only, single-threaded test runner.
13782            unsafe { std::env::set_var("HOME", home) };
13783        } else {
13784            // SAFETY: test-only, single-threaded test runner.
13785            unsafe { std::env::remove_var("HOME") };
13786        }
13787        let _ = fs::remove_dir_all(temp_home).await;
13788    }
13789
13790    #[test]
13791    async fn load_or_init_decrypts_feishu_channel_secrets() {
13792        let _env_guard = env_override_lock().await;
13793        let temp_home =
13794            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13795        let config_dir = temp_home.join(".construct");
13796        let config_path = config_dir.join("config.toml");
13797
13798        fs::create_dir_all(&config_dir).await.unwrap();
13799
13800        let original_home = std::env::var("HOME").ok();
13801        // SAFETY: test-only, single-threaded test runner.
13802        unsafe { std::env::set_var("HOME", &temp_home) };
13803        // SAFETY: test-only, single-threaded test runner.
13804        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13805
13806        let mut config = Config::default();
13807        config.config_path = config_path.clone();
13808        config.workspace_dir = config_dir.join("workspace");
13809        config.secrets.encrypt = true;
13810        config.channels_config.feishu = Some(FeishuConfig {
13811            app_id: "cli_feishu_123".into(),
13812            app_secret: "feishu-secret".into(),
13813            encrypt_key: Some("feishu-encrypt".into()),
13814            verification_token: Some("feishu-verify".into()),
13815            allowed_users: vec!["*".into()],
13816            receive_mode: LarkReceiveMode::Websocket,
13817            port: None,
13818            proxy_url: None,
13819        });
13820        config.save().await.unwrap();
13821
13822        let loaded = Box::pin(Config::load_or_init()).await.unwrap();
13823        let feishu = loaded.channels_config.feishu.as_ref().unwrap();
13824        assert_eq!(feishu.app_secret, "feishu-secret");
13825        assert_eq!(feishu.encrypt_key.as_deref(), Some("feishu-encrypt"));
13826        assert_eq!(feishu.verification_token.as_deref(), Some("feishu-verify"));
13827
13828        if let Some(home) = original_home {
13829            // SAFETY: test-only, single-threaded test runner.
13830            unsafe { std::env::set_var("HOME", home) };
13831        } else {
13832            // SAFETY: test-only, single-threaded test runner.
13833            unsafe { std::env::remove_var("HOME") };
13834        }
13835        let _ = fs::remove_dir_all(temp_home).await;
13836    }
13837
13838    #[test]
13839    async fn load_or_init_uses_persisted_active_workspace_marker() {
13840        let _env_guard = env_override_lock().await;
13841        let temp_home =
13842            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13843        let temp_default_dir = temp_home.join(".construct");
13844        let custom_config_dir = temp_home.join("profiles").join("agent-alpha");
13845
13846        fs::create_dir_all(&custom_config_dir).await.unwrap();
13847        // Pre-create the default dir so is_temp_directory() can canonicalize
13848        // the path on macOS (where /var → /private/var symlink requires
13849        // the directory to exist for canonicalize to resolve correctly).
13850        fs::create_dir_all(&temp_default_dir).await.unwrap();
13851        fs::write(
13852            custom_config_dir.join("config.toml"),
13853            "default_temperature = 0.7\ndefault_model = \"persisted-profile\"\n",
13854        )
13855        .await
13856        .unwrap();
13857
13858        // Write the marker using the explicit default dir (no HOME manipulation
13859        // needed for the persist call itself).
13860        persist_active_workspace_config_dir_in(&custom_config_dir, &temp_default_dir)
13861            .await
13862            .unwrap();
13863
13864        // Config::load_or_init still reads HOME to find the marker, so we
13865        // must override HOME here. The persist above already wrote to the
13866        // correct temp location, so no stale marker can leak.
13867        let original_home = std::env::var("HOME").ok();
13868        // SAFETY: test-only, single-threaded test runner.
13869        unsafe { std::env::set_var("HOME", &temp_home) };
13870        // SAFETY: test-only, single-threaded test runner.
13871        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13872
13873        let config = Box::pin(Config::load_or_init()).await.unwrap();
13874
13875        assert_eq!(config.config_path, custom_config_dir.join("config.toml"));
13876        assert_eq!(config.workspace_dir, custom_config_dir.join("workspace"));
13877        assert_eq!(config.default_model.as_deref(), Some("persisted-profile"));
13878
13879        if let Some(home) = original_home {
13880            // SAFETY: test-only, single-threaded test runner.
13881            unsafe { std::env::set_var("HOME", home) };
13882        } else {
13883            // SAFETY: test-only, single-threaded test runner.
13884            unsafe { std::env::remove_var("HOME") };
13885        }
13886        let _ = fs::remove_dir_all(temp_home).await;
13887    }
13888
13889    #[test]
13890    async fn load_or_init_env_workspace_override_takes_priority_over_marker() {
13891        let _env_guard = env_override_lock().await;
13892        let temp_home =
13893            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13894        let temp_default_dir = temp_home.join(".construct");
13895        let marker_config_dir = temp_home.join("profiles").join("persisted-profile");
13896        let env_workspace_dir = temp_home.join("env-workspace");
13897
13898        fs::create_dir_all(&marker_config_dir).await.unwrap();
13899        fs::write(
13900            marker_config_dir.join("config.toml"),
13901            "default_temperature = 0.7\ndefault_model = \"marker-model\"\n",
13902        )
13903        .await
13904        .unwrap();
13905
13906        // Write marker via explicit default dir, then set HOME for load_or_init.
13907        persist_active_workspace_config_dir_in(&marker_config_dir, &temp_default_dir)
13908            .await
13909            .unwrap();
13910
13911        let original_home = std::env::var("HOME").ok();
13912        // SAFETY: test-only, single-threaded test runner.
13913        unsafe { std::env::set_var("HOME", &temp_home) };
13914        // SAFETY: test-only, single-threaded test runner.
13915        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &env_workspace_dir) };
13916
13917        let config = Box::pin(Config::load_or_init()).await.unwrap();
13918
13919        assert_eq!(config.workspace_dir, env_workspace_dir.join("workspace"));
13920        assert_eq!(config.config_path, env_workspace_dir.join("config.toml"));
13921
13922        // SAFETY: test-only, single-threaded test runner.
13923        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13924        if let Some(home) = original_home {
13925            // SAFETY: test-only, single-threaded test runner.
13926            unsafe { std::env::set_var("HOME", home) };
13927        } else {
13928            // SAFETY: test-only, single-threaded test runner.
13929            unsafe { std::env::remove_var("HOME") };
13930        }
13931        let _ = fs::remove_dir_all(temp_home).await;
13932    }
13933
13934    #[test]
13935    async fn persist_active_workspace_marker_is_cleared_for_default_config_dir() {
13936        let temp_home =
13937            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13938        let default_config_dir = temp_home.join(".construct");
13939        let custom_config_dir = temp_home.join("profiles").join("custom-profile");
13940        let marker_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
13941
13942        // Use the _in variant directly -- no HOME manipulation needed since
13943        // this test only exercises persist/clear logic, not Config::load_or_init.
13944        persist_active_workspace_config_dir_in(&custom_config_dir, &default_config_dir)
13945            .await
13946            .unwrap();
13947        assert!(marker_path.exists());
13948
13949        persist_active_workspace_config_dir_in(&default_config_dir, &default_config_dir)
13950            .await
13951            .unwrap();
13952        assert!(!marker_path.exists());
13953
13954        let _ = fs::remove_dir_all(temp_home).await;
13955    }
13956
13957    #[test]
13958    #[allow(clippy::large_futures)]
13959    async fn load_or_init_logs_existing_config_as_initialized() {
13960        let _env_guard = env_override_lock().await;
13961        let temp_home =
13962            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13963        let workspace_dir = temp_home.join("profile-a");
13964        let config_path = workspace_dir.join("config.toml");
13965
13966        fs::create_dir_all(&workspace_dir).await.unwrap();
13967        fs::write(
13968            &config_path,
13969            r#"default_temperature = 0.7
13970default_model = "persisted-profile"
13971"#,
13972        )
13973        .await
13974        .unwrap();
13975
13976        let original_home = std::env::var("HOME").ok();
13977        // SAFETY: test-only, single-threaded test runner.
13978        unsafe { std::env::set_var("HOME", &temp_home) };
13979        // SAFETY: test-only, single-threaded test runner.
13980        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13981
13982        let capture = SharedLogBuffer::default();
13983        let subscriber = tracing_subscriber::fmt()
13984            .with_ansi(false)
13985            .without_time()
13986            .with_target(false)
13987            .with_writer(capture.clone())
13988            .finish();
13989        let dispatch = tracing::Dispatch::new(subscriber);
13990        let guard = tracing::dispatcher::set_default(&dispatch);
13991
13992        let config = Box::pin(Config::load_or_init()).await.unwrap();
13993
13994        drop(guard);
13995        let logs = capture.captured();
13996
13997        assert_eq!(config.workspace_dir, workspace_dir.join("workspace"));
13998        assert_eq!(config.config_path, config_path);
13999        assert_eq!(config.default_model.as_deref(), Some("persisted-profile"));
14000        assert!(logs.contains("Config loaded"), "{logs}");
14001        assert!(logs.contains("initialized=true"), "{logs}");
14002        assert!(!logs.contains("initialized=false"), "{logs}");
14003
14004        // SAFETY: test-only, single-threaded test runner.
14005        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
14006        if let Some(home) = original_home {
14007            // SAFETY: test-only, single-threaded test runner.
14008            unsafe { std::env::set_var("HOME", home) };
14009        } else {
14010            // SAFETY: test-only, single-threaded test runner.
14011            unsafe { std::env::remove_var("HOME") };
14012        }
14013        let _ = fs::remove_dir_all(temp_home).await;
14014    }
14015
14016    #[test]
14017    async fn env_override_empty_values_ignored() {
14018        let _env_guard = env_override_lock().await;
14019        let mut config = Config::default();
14020        let original_provider = config.default_provider.clone();
14021
14022        // SAFETY: test-only, single-threaded test runner.
14023        unsafe { std::env::set_var("CONSTRUCT_PROVIDER", "") };
14024        config.apply_env_overrides();
14025        assert_eq!(config.default_provider, original_provider);
14026
14027        // SAFETY: test-only, single-threaded test runner.
14028        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
14029    }
14030
14031    #[test]
14032    async fn env_override_gateway_port() {
14033        let _env_guard = env_override_lock().await;
14034        let mut config = Config::default();
14035        assert_eq!(config.gateway.port, 42617);
14036
14037        // SAFETY: test-only, single-threaded test runner.
14038        unsafe { std::env::set_var("CONSTRUCT_GATEWAY_PORT", "8080") };
14039        config.apply_env_overrides();
14040        assert_eq!(config.gateway.port, 8080);
14041
14042        // SAFETY: test-only, single-threaded test runner.
14043        unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_PORT") };
14044    }
14045
14046    #[test]
14047    async fn env_override_port_fallback() {
14048        let _env_guard = env_override_lock().await;
14049        let mut config = Config::default();
14050
14051        // SAFETY: test-only, single-threaded test runner.
14052        unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_PORT") };
14053        // SAFETY: test-only, single-threaded test runner.
14054        unsafe { std::env::set_var("PORT", "9000") };
14055        config.apply_env_overrides();
14056        assert_eq!(config.gateway.port, 9000);
14057
14058        // SAFETY: test-only, single-threaded test runner.
14059        unsafe { std::env::remove_var("PORT") };
14060    }
14061
14062    #[test]
14063    async fn env_override_gateway_host() {
14064        let _env_guard = env_override_lock().await;
14065        let mut config = Config::default();
14066        assert_eq!(config.gateway.host, "127.0.0.1");
14067
14068        // SAFETY: test-only, single-threaded test runner.
14069        unsafe { std::env::set_var("CONSTRUCT_GATEWAY_HOST", "0.0.0.0") };
14070        config.apply_env_overrides();
14071        assert_eq!(config.gateway.host, "0.0.0.0");
14072
14073        // SAFETY: test-only, single-threaded test runner.
14074        unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_HOST") };
14075    }
14076
14077    #[test]
14078    async fn env_override_host_fallback() {
14079        let _env_guard = env_override_lock().await;
14080        let mut config = Config::default();
14081
14082        // SAFETY: test-only, single-threaded test runner.
14083        unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_HOST") };
14084        // SAFETY: test-only, single-threaded test runner.
14085        unsafe { std::env::set_var("HOST", "0.0.0.0") };
14086        config.apply_env_overrides();
14087        assert_eq!(config.gateway.host, "0.0.0.0");
14088
14089        // SAFETY: test-only, single-threaded test runner.
14090        unsafe { std::env::remove_var("HOST") };
14091    }
14092
14093    #[test]
14094    async fn env_override_require_pairing() {
14095        let _env_guard = env_override_lock().await;
14096        let mut config = Config::default();
14097        assert!(config.gateway.require_pairing);
14098
14099        // SAFETY: test-only, single-threaded test runner.
14100        unsafe { std::env::set_var("CONSTRUCT_REQUIRE_PAIRING", "false") };
14101        config.apply_env_overrides();
14102        assert!(!config.gateway.require_pairing);
14103
14104        // SAFETY: test-only, single-threaded test runner.
14105        unsafe { std::env::set_var("CONSTRUCT_REQUIRE_PAIRING", "true") };
14106        config.apply_env_overrides();
14107        assert!(config.gateway.require_pairing);
14108
14109        // SAFETY: test-only, single-threaded test runner.
14110        unsafe { std::env::remove_var("CONSTRUCT_REQUIRE_PAIRING") };
14111    }
14112
14113    #[test]
14114    async fn env_override_temperature() {
14115        let _env_guard = env_override_lock().await;
14116        let mut config = Config::default();
14117
14118        // SAFETY: test-only, single-threaded test runner.
14119        unsafe { std::env::set_var("CONSTRUCT_TEMPERATURE", "0.5") };
14120        config.apply_env_overrides();
14121        assert!((config.default_temperature - 0.5).abs() < f64::EPSILON);
14122
14123        // SAFETY: test-only, single-threaded test runner.
14124        unsafe { std::env::remove_var("CONSTRUCT_TEMPERATURE") };
14125    }
14126
14127    #[test]
14128    async fn env_override_temperature_out_of_range_ignored() {
14129        let _env_guard = env_override_lock().await;
14130        // Clean up any leftover env vars from other tests
14131        // SAFETY: test-only, single-threaded test runner.
14132        unsafe { std::env::remove_var("CONSTRUCT_TEMPERATURE") };
14133
14134        let mut config = Config::default();
14135        let original_temp = config.default_temperature;
14136
14137        // Temperature > 2.0 should be ignored
14138        // SAFETY: test-only, single-threaded test runner.
14139        unsafe { std::env::set_var("CONSTRUCT_TEMPERATURE", "3.0") };
14140        config.apply_env_overrides();
14141        assert!(
14142            (config.default_temperature - original_temp).abs() < f64::EPSILON,
14143            "Temperature 3.0 should be ignored (out of range)"
14144        );
14145
14146        // SAFETY: test-only, single-threaded test runner.
14147        unsafe { std::env::remove_var("CONSTRUCT_TEMPERATURE") };
14148    }
14149
14150    #[test]
14151    async fn env_override_reasoning_enabled() {
14152        let _env_guard = env_override_lock().await;
14153        let mut config = Config::default();
14154        assert_eq!(config.runtime.reasoning_enabled, None);
14155
14156        // SAFETY: test-only, single-threaded test runner.
14157        unsafe { std::env::set_var("CONSTRUCT_REASONING_ENABLED", "false") };
14158        config.apply_env_overrides();
14159        assert_eq!(config.runtime.reasoning_enabled, Some(false));
14160
14161        // SAFETY: test-only, single-threaded test runner.
14162        unsafe { std::env::set_var("CONSTRUCT_REASONING_ENABLED", "true") };
14163        config.apply_env_overrides();
14164        assert_eq!(config.runtime.reasoning_enabled, Some(true));
14165
14166        // SAFETY: test-only, single-threaded test runner.
14167        unsafe { std::env::remove_var("CONSTRUCT_REASONING_ENABLED") };
14168    }
14169
14170    #[test]
14171    async fn env_override_reasoning_invalid_value_ignored() {
14172        let _env_guard = env_override_lock().await;
14173        let mut config = Config::default();
14174        config.runtime.reasoning_enabled = Some(false);
14175
14176        // SAFETY: test-only, single-threaded test runner.
14177        unsafe { std::env::set_var("CONSTRUCT_REASONING_ENABLED", "maybe") };
14178        config.apply_env_overrides();
14179        assert_eq!(config.runtime.reasoning_enabled, Some(false));
14180
14181        // SAFETY: test-only, single-threaded test runner.
14182        unsafe { std::env::remove_var("CONSTRUCT_REASONING_ENABLED") };
14183    }
14184
14185    #[test]
14186    async fn env_override_reasoning_effort() {
14187        let _env_guard = env_override_lock().await;
14188        let mut config = Config::default();
14189        assert_eq!(config.runtime.reasoning_effort, None);
14190
14191        // SAFETY: test-only, single-threaded test runner.
14192        unsafe { std::env::set_var("CONSTRUCT_REASONING_EFFORT", "HIGH") };
14193        config.apply_env_overrides();
14194        assert_eq!(config.runtime.reasoning_effort.as_deref(), Some("high"));
14195
14196        // SAFETY: test-only, single-threaded test runner.
14197        unsafe { std::env::remove_var("CONSTRUCT_REASONING_EFFORT") };
14198    }
14199
14200    #[test]
14201    async fn env_override_reasoning_effort_legacy_codex_env() {
14202        let _env_guard = env_override_lock().await;
14203        let mut config = Config::default();
14204
14205        // SAFETY: test-only, single-threaded test runner.
14206        unsafe { std::env::set_var("CONSTRUCT_CODEX_REASONING_EFFORT", "minimal") };
14207        config.apply_env_overrides();
14208        assert_eq!(config.runtime.reasoning_effort.as_deref(), Some("minimal"));
14209
14210        // SAFETY: test-only, single-threaded test runner.
14211        unsafe { std::env::remove_var("CONSTRUCT_CODEX_REASONING_EFFORT") };
14212    }
14213
14214    #[test]
14215    async fn env_override_invalid_port_ignored() {
14216        let _env_guard = env_override_lock().await;
14217        let mut config = Config::default();
14218        let original_port = config.gateway.port;
14219
14220        // SAFETY: test-only, single-threaded test runner.
14221        unsafe { std::env::set_var("PORT", "not_a_number") };
14222        config.apply_env_overrides();
14223        assert_eq!(config.gateway.port, original_port);
14224
14225        // SAFETY: test-only, single-threaded test runner.
14226        unsafe { std::env::remove_var("PORT") };
14227    }
14228
14229    #[test]
14230    async fn env_override_web_search_config() {
14231        let _env_guard = env_override_lock().await;
14232        let mut config = Config::default();
14233
14234        // SAFETY: test-only, single-threaded test runner.
14235        unsafe { std::env::set_var("WEB_SEARCH_ENABLED", "false") };
14236        // SAFETY: test-only, single-threaded test runner.
14237        unsafe { std::env::set_var("WEB_SEARCH_PROVIDER", "brave") };
14238        // SAFETY: test-only, single-threaded test runner.
14239        unsafe { std::env::set_var("WEB_SEARCH_MAX_RESULTS", "7") };
14240        // SAFETY: test-only, single-threaded test runner.
14241        unsafe { std::env::set_var("WEB_SEARCH_TIMEOUT_SECS", "20") };
14242        // SAFETY: test-only, single-threaded test runner.
14243        unsafe { std::env::set_var("BRAVE_API_KEY", "brave-test-key") };
14244
14245        config.apply_env_overrides();
14246
14247        assert!(!config.web_search.enabled);
14248        assert_eq!(config.web_search.provider, "brave");
14249        assert_eq!(config.web_search.max_results, 7);
14250        assert_eq!(config.web_search.timeout_secs, 20);
14251        assert_eq!(
14252            config.web_search.brave_api_key.as_deref(),
14253            Some("brave-test-key")
14254        );
14255
14256        // SAFETY: test-only, single-threaded test runner.
14257        unsafe { std::env::remove_var("WEB_SEARCH_ENABLED") };
14258        // SAFETY: test-only, single-threaded test runner.
14259        unsafe { std::env::remove_var("WEB_SEARCH_PROVIDER") };
14260        // SAFETY: test-only, single-threaded test runner.
14261        unsafe { std::env::remove_var("WEB_SEARCH_MAX_RESULTS") };
14262        // SAFETY: test-only, single-threaded test runner.
14263        unsafe { std::env::remove_var("WEB_SEARCH_TIMEOUT_SECS") };
14264        // SAFETY: test-only, single-threaded test runner.
14265        unsafe { std::env::remove_var("BRAVE_API_KEY") };
14266    }
14267
14268    #[test]
14269    async fn env_override_web_search_invalid_values_ignored() {
14270        let _env_guard = env_override_lock().await;
14271        let mut config = Config::default();
14272        let original_max_results = config.web_search.max_results;
14273        let original_timeout = config.web_search.timeout_secs;
14274
14275        // SAFETY: test-only, single-threaded test runner.
14276        unsafe { std::env::set_var("WEB_SEARCH_MAX_RESULTS", "99") };
14277        // SAFETY: test-only, single-threaded test runner.
14278        unsafe { std::env::set_var("WEB_SEARCH_TIMEOUT_SECS", "0") };
14279
14280        config.apply_env_overrides();
14281
14282        assert_eq!(config.web_search.max_results, original_max_results);
14283        assert_eq!(config.web_search.timeout_secs, original_timeout);
14284
14285        // SAFETY: test-only, single-threaded test runner.
14286        unsafe { std::env::remove_var("WEB_SEARCH_MAX_RESULTS") };
14287        // SAFETY: test-only, single-threaded test runner.
14288        unsafe { std::env::remove_var("WEB_SEARCH_TIMEOUT_SECS") };
14289    }
14290
14291    #[test]
14292    async fn env_override_storage_provider_config() {
14293        let _env_guard = env_override_lock().await;
14294        let mut config = Config::default();
14295
14296        // SAFETY: test-only, single-threaded test runner.
14297        unsafe { std::env::set_var("CONSTRUCT_STORAGE_PROVIDER", "qdrant") };
14298        // SAFETY: test-only, single-threaded test runner.
14299        unsafe { std::env::set_var("CONSTRUCT_STORAGE_DB_URL", "http://localhost:6333") };
14300        // SAFETY: test-only, single-threaded test runner.
14301        unsafe { std::env::set_var("CONSTRUCT_STORAGE_CONNECT_TIMEOUT_SECS", "15") };
14302
14303        config.apply_env_overrides();
14304
14305        assert_eq!(config.storage.provider.config.provider, "qdrant");
14306        assert_eq!(
14307            config.storage.provider.config.db_url.as_deref(),
14308            Some("http://localhost:6333")
14309        );
14310        assert_eq!(
14311            config.storage.provider.config.connect_timeout_secs,
14312            Some(15)
14313        );
14314
14315        // SAFETY: test-only, single-threaded test runner.
14316        unsafe { std::env::remove_var("CONSTRUCT_STORAGE_PROVIDER") };
14317        // SAFETY: test-only, single-threaded test runner.
14318        unsafe { std::env::remove_var("CONSTRUCT_STORAGE_DB_URL") };
14319        // SAFETY: test-only, single-threaded test runner.
14320        unsafe { std::env::remove_var("CONSTRUCT_STORAGE_CONNECT_TIMEOUT_SECS") };
14321    }
14322
14323    #[test]
14324    async fn proxy_config_scope_services_requires_entries_when_enabled() {
14325        let proxy = ProxyConfig {
14326            enabled: true,
14327            http_proxy: Some("http://127.0.0.1:7890".into()),
14328            https_proxy: None,
14329            all_proxy: None,
14330            no_proxy: Vec::new(),
14331            scope: ProxyScope::Services,
14332            services: Vec::new(),
14333        };
14334
14335        let error = proxy.validate().unwrap_err().to_string();
14336        assert!(error.contains("proxy.scope='services'"));
14337    }
14338
14339    #[test]
14340    async fn env_override_proxy_scope_services() {
14341        let _env_guard = env_override_lock().await;
14342        clear_proxy_env_test_vars();
14343
14344        let mut config = Config::default();
14345        // SAFETY: test-only, single-threaded test runner.
14346        unsafe { std::env::set_var("CONSTRUCT_PROXY_ENABLED", "true") };
14347        // SAFETY: test-only, single-threaded test runner.
14348        unsafe { std::env::set_var("CONSTRUCT_HTTP_PROXY", "http://127.0.0.1:7890") };
14349        // SAFETY: test-only, single-threaded test runner.
14350        unsafe {
14351            std::env::set_var(
14352                "CONSTRUCT_PROXY_SERVICES",
14353                "provider.openai, tool.http_request",
14354            );
14355        }
14356        // SAFETY: test-only, single-threaded test runner.
14357        unsafe { std::env::set_var("CONSTRUCT_PROXY_SCOPE", "services") };
14358
14359        config.apply_env_overrides();
14360
14361        assert!(config.proxy.enabled);
14362        assert_eq!(config.proxy.scope, ProxyScope::Services);
14363        assert_eq!(
14364            config.proxy.http_proxy.as_deref(),
14365            Some("http://127.0.0.1:7890")
14366        );
14367        assert!(config.proxy.should_apply_to_service("provider.openai"));
14368        assert!(config.proxy.should_apply_to_service("tool.http_request"));
14369        assert!(!config.proxy.should_apply_to_service("provider.anthropic"));
14370
14371        clear_proxy_env_test_vars();
14372    }
14373
14374    #[test]
14375    async fn env_override_proxy_scope_environment_applies_process_env() {
14376        let _env_guard = env_override_lock().await;
14377        clear_proxy_env_test_vars();
14378
14379        let mut config = Config::default();
14380        // SAFETY: test-only, single-threaded test runner.
14381        unsafe { std::env::set_var("CONSTRUCT_PROXY_ENABLED", "true") };
14382        // SAFETY: test-only, single-threaded test runner.
14383        unsafe { std::env::set_var("CONSTRUCT_PROXY_SCOPE", "environment") };
14384        // SAFETY: test-only, single-threaded test runner.
14385        unsafe { std::env::set_var("CONSTRUCT_HTTP_PROXY", "http://127.0.0.1:7890") };
14386        // SAFETY: test-only, single-threaded test runner.
14387        unsafe { std::env::set_var("CONSTRUCT_HTTPS_PROXY", "http://127.0.0.1:7891") };
14388        // SAFETY: test-only, single-threaded test runner.
14389        unsafe { std::env::set_var("CONSTRUCT_NO_PROXY", "localhost,127.0.0.1") };
14390
14391        config.apply_env_overrides();
14392
14393        assert_eq!(config.proxy.scope, ProxyScope::Environment);
14394        assert_eq!(
14395            std::env::var("HTTP_PROXY").ok().as_deref(),
14396            Some("http://127.0.0.1:7890")
14397        );
14398        assert_eq!(
14399            std::env::var("HTTPS_PROXY").ok().as_deref(),
14400            Some("http://127.0.0.1:7891")
14401        );
14402        assert!(
14403            std::env::var("NO_PROXY")
14404                .ok()
14405                .is_some_and(|value| value.contains("localhost"))
14406        );
14407
14408        clear_proxy_env_test_vars();
14409    }
14410
14411    #[test]
14412    async fn google_workspace_allowed_operations_require_methods() {
14413        let mut config = Config::default();
14414        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
14415            service: "gmail".into(),
14416            resource: "users".into(),
14417            sub_resource: Some("drafts".into()),
14418            methods: Vec::new(),
14419        }];
14420
14421        let err = config.validate().unwrap_err().to_string();
14422        assert!(err.contains("google_workspace.allowed_operations[0].methods"));
14423    }
14424
14425    #[test]
14426    async fn google_workspace_allowed_operations_reject_duplicate_service_resource_sub_resource_entries()
14427     {
14428        let mut config = Config::default();
14429        config.google_workspace.allowed_operations = vec![
14430            GoogleWorkspaceAllowedOperation {
14431                service: "gmail".into(),
14432                resource: "users".into(),
14433                sub_resource: Some("drafts".into()),
14434                methods: vec!["create".into()],
14435            },
14436            GoogleWorkspaceAllowedOperation {
14437                service: "gmail".into(),
14438                resource: "users".into(),
14439                sub_resource: Some("drafts".into()),
14440                methods: vec!["update".into()],
14441            },
14442        ];
14443
14444        let err = config.validate().unwrap_err().to_string();
14445        assert!(err.contains("duplicate service/resource/sub_resource entry"));
14446    }
14447
14448    #[test]
14449    async fn google_workspace_allowed_operations_allow_same_resource_different_sub_resource() {
14450        let mut config = Config::default();
14451        config.google_workspace.allowed_operations = vec![
14452            GoogleWorkspaceAllowedOperation {
14453                service: "gmail".into(),
14454                resource: "users".into(),
14455                sub_resource: Some("messages".into()),
14456                methods: vec!["list".into(), "get".into()],
14457            },
14458            GoogleWorkspaceAllowedOperation {
14459                service: "gmail".into(),
14460                resource: "users".into(),
14461                sub_resource: Some("drafts".into()),
14462                methods: vec!["create".into(), "update".into()],
14463            },
14464        ];
14465
14466        assert!(config.validate().is_ok());
14467    }
14468
14469    #[test]
14470    async fn google_workspace_allowed_operations_reject_duplicate_methods_within_entry() {
14471        let mut config = Config::default();
14472        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
14473            service: "gmail".into(),
14474            resource: "users".into(),
14475            sub_resource: Some("drafts".into()),
14476            methods: vec!["create".into(), "create".into()],
14477        }];
14478
14479        let err = config.validate().unwrap_err().to_string();
14480        assert!(
14481            err.contains("duplicate entry"),
14482            "expected duplicate entry error, got: {err}"
14483        );
14484    }
14485
14486    #[test]
14487    async fn google_workspace_allowed_operations_accept_valid_entries() {
14488        let mut config = Config::default();
14489        config.google_workspace.allowed_operations = vec![
14490            GoogleWorkspaceAllowedOperation {
14491                service: "gmail".into(),
14492                resource: "users".into(),
14493                sub_resource: Some("messages".into()),
14494                methods: vec!["list".into(), "get".into()],
14495            },
14496            GoogleWorkspaceAllowedOperation {
14497                service: "drive".into(),
14498                resource: "files".into(),
14499                sub_resource: None,
14500                methods: vec!["list".into(), "get".into()],
14501            },
14502        ];
14503
14504        assert!(config.validate().is_ok());
14505    }
14506
14507    #[test]
14508    async fn google_workspace_allowed_operations_reject_invalid_sub_resource_characters() {
14509        let mut config = Config::default();
14510        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
14511            service: "gmail".into(),
14512            resource: "users".into(),
14513            sub_resource: Some("bad resource!".into()),
14514            methods: vec!["list".into()],
14515        }];
14516
14517        let err = config.validate().unwrap_err().to_string();
14518        assert!(err.contains("sub_resource contains invalid characters"));
14519    }
14520
14521    fn runtime_proxy_cache_contains(cache_key: &str) -> bool {
14522        match runtime_proxy_client_cache().read() {
14523            Ok(guard) => guard.contains_key(cache_key),
14524            Err(poisoned) => poisoned.into_inner().contains_key(cache_key),
14525        }
14526    }
14527
14528    #[test]
14529    async fn runtime_proxy_client_cache_reuses_default_profile_key() {
14530        let service_key = format!(
14531            "provider.cache_test.{}",
14532            std::time::SystemTime::now()
14533                .duration_since(std::time::UNIX_EPOCH)
14534                .expect("system clock should be after unix epoch")
14535                .as_nanos()
14536        );
14537        let cache_key = runtime_proxy_cache_key(&service_key, None, None);
14538
14539        clear_runtime_proxy_client_cache();
14540        assert!(!runtime_proxy_cache_contains(&cache_key));
14541
14542        let _ = build_runtime_proxy_client(&service_key);
14543        assert!(runtime_proxy_cache_contains(&cache_key));
14544
14545        let _ = build_runtime_proxy_client(&service_key);
14546        assert!(runtime_proxy_cache_contains(&cache_key));
14547    }
14548
14549    #[test]
14550    async fn set_runtime_proxy_config_clears_runtime_proxy_client_cache() {
14551        let service_key = format!(
14552            "provider.cache_timeout_test.{}",
14553            std::time::SystemTime::now()
14554                .duration_since(std::time::UNIX_EPOCH)
14555                .expect("system clock should be after unix epoch")
14556                .as_nanos()
14557        );
14558        let cache_key = runtime_proxy_cache_key(&service_key, Some(30), Some(5));
14559
14560        clear_runtime_proxy_client_cache();
14561        let _ = build_runtime_proxy_client_with_timeouts(&service_key, 30, 5);
14562        assert!(runtime_proxy_cache_contains(&cache_key));
14563
14564        set_runtime_proxy_config(ProxyConfig::default());
14565        assert!(!runtime_proxy_cache_contains(&cache_key));
14566    }
14567
14568    #[test]
14569    async fn gateway_config_default_values() {
14570        let g = GatewayConfig::default();
14571        assert_eq!(g.port, 42617);
14572        assert_eq!(g.host, "127.0.0.1");
14573        assert!(g.require_pairing);
14574        assert!(!g.allow_public_bind);
14575        assert!(g.paired_tokens.is_empty());
14576        assert!(!g.trust_forwarded_headers);
14577        assert_eq!(g.rate_limit_max_keys, 10_000);
14578        assert_eq!(g.idempotency_max_keys, 10_000);
14579    }
14580
14581    // ── Peripherals config ───────────────────────────────────────
14582
14583    #[test]
14584    async fn peripherals_config_default_disabled() {
14585        let p = PeripheralsConfig::default();
14586        assert!(!p.enabled);
14587        assert!(p.boards.is_empty());
14588    }
14589
14590    #[test]
14591    async fn peripheral_board_config_defaults() {
14592        let b = PeripheralBoardConfig::default();
14593        assert!(b.board.is_empty());
14594        assert_eq!(b.transport, "serial");
14595        assert!(b.path.is_none());
14596        assert_eq!(b.baud, 115_200);
14597    }
14598
14599    #[test]
14600    async fn peripherals_config_toml_roundtrip() {
14601        let p = PeripheralsConfig {
14602            enabled: true,
14603            boards: vec![PeripheralBoardConfig {
14604                board: "nucleo-f401re".into(),
14605                transport: "serial".into(),
14606                path: Some("/dev/ttyACM0".into()),
14607                baud: 115_200,
14608            }],
14609            datasheet_dir: None,
14610        };
14611        let toml_str = toml::to_string(&p).unwrap();
14612        let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
14613        assert!(parsed.enabled);
14614        assert_eq!(parsed.boards.len(), 1);
14615        assert_eq!(parsed.boards[0].board, "nucleo-f401re");
14616        assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
14617    }
14618
14619    #[test]
14620    async fn lark_config_serde() {
14621        let lc = LarkConfig {
14622            app_id: "cli_123456".into(),
14623            app_secret: "secret_abc".into(),
14624            encrypt_key: Some("encrypt_key".into()),
14625            verification_token: Some("verify_token".into()),
14626            allowed_users: vec!["user_123".into(), "user_456".into()],
14627            mention_only: false,
14628            use_feishu: true,
14629            receive_mode: LarkReceiveMode::Websocket,
14630            port: None,
14631            proxy_url: None,
14632        };
14633        let json = serde_json::to_string(&lc).unwrap();
14634        let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
14635        assert_eq!(parsed.app_id, "cli_123456");
14636        assert_eq!(parsed.app_secret, "secret_abc");
14637        assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
14638        assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
14639        assert_eq!(parsed.allowed_users.len(), 2);
14640        assert!(parsed.use_feishu);
14641    }
14642
14643    #[test]
14644    async fn lark_config_toml_roundtrip() {
14645        let lc = LarkConfig {
14646            app_id: "cli_123456".into(),
14647            app_secret: "secret_abc".into(),
14648            encrypt_key: Some("encrypt_key".into()),
14649            verification_token: Some("verify_token".into()),
14650            allowed_users: vec!["*".into()],
14651            mention_only: false,
14652            use_feishu: false,
14653            receive_mode: LarkReceiveMode::Webhook,
14654            port: Some(9898),
14655            proxy_url: None,
14656        };
14657        let toml_str = toml::to_string(&lc).unwrap();
14658        let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
14659        assert_eq!(parsed.app_id, "cli_123456");
14660        assert_eq!(parsed.app_secret, "secret_abc");
14661        assert!(!parsed.use_feishu);
14662    }
14663
14664    #[test]
14665    async fn lark_config_deserializes_without_optional_fields() {
14666        let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
14667        let parsed: LarkConfig = serde_json::from_str(json).unwrap();
14668        assert!(parsed.encrypt_key.is_none());
14669        assert!(parsed.verification_token.is_none());
14670        assert!(parsed.allowed_users.is_empty());
14671        assert!(!parsed.mention_only);
14672        assert!(!parsed.use_feishu);
14673    }
14674
14675    #[test]
14676    async fn lark_config_defaults_to_lark_endpoint() {
14677        let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
14678        let parsed: LarkConfig = serde_json::from_str(json).unwrap();
14679        assert!(
14680            !parsed.use_feishu,
14681            "use_feishu should default to false (Lark)"
14682        );
14683    }
14684
14685    #[test]
14686    async fn lark_config_with_wildcard_allowed_users() {
14687        let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#;
14688        let parsed: LarkConfig = serde_json::from_str(json).unwrap();
14689        assert_eq!(parsed.allowed_users, vec!["*"]);
14690    }
14691
14692    #[test]
14693    async fn feishu_config_serde() {
14694        let fc = FeishuConfig {
14695            app_id: "cli_feishu_123".into(),
14696            app_secret: "secret_abc".into(),
14697            encrypt_key: Some("encrypt_key".into()),
14698            verification_token: Some("verify_token".into()),
14699            allowed_users: vec!["user_123".into(), "user_456".into()],
14700            receive_mode: LarkReceiveMode::Websocket,
14701            port: None,
14702            proxy_url: None,
14703        };
14704        let json = serde_json::to_string(&fc).unwrap();
14705        let parsed: FeishuConfig = serde_json::from_str(&json).unwrap();
14706        assert_eq!(parsed.app_id, "cli_feishu_123");
14707        assert_eq!(parsed.app_secret, "secret_abc");
14708        assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
14709        assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
14710        assert_eq!(parsed.allowed_users.len(), 2);
14711    }
14712
14713    #[test]
14714    async fn feishu_config_toml_roundtrip() {
14715        let fc = FeishuConfig {
14716            app_id: "cli_feishu_123".into(),
14717            app_secret: "secret_abc".into(),
14718            encrypt_key: Some("encrypt_key".into()),
14719            verification_token: Some("verify_token".into()),
14720            allowed_users: vec!["*".into()],
14721            receive_mode: LarkReceiveMode::Webhook,
14722            port: Some(9898),
14723            proxy_url: None,
14724        };
14725        let toml_str = toml::to_string(&fc).unwrap();
14726        let parsed: FeishuConfig = toml::from_str(&toml_str).unwrap();
14727        assert_eq!(parsed.app_id, "cli_feishu_123");
14728        assert_eq!(parsed.app_secret, "secret_abc");
14729        assert_eq!(parsed.receive_mode, LarkReceiveMode::Webhook);
14730        assert_eq!(parsed.port, Some(9898));
14731    }
14732
14733    #[test]
14734    async fn feishu_config_deserializes_without_optional_fields() {
14735        let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
14736        let parsed: FeishuConfig = serde_json::from_str(json).unwrap();
14737        assert!(parsed.encrypt_key.is_none());
14738        assert!(parsed.verification_token.is_none());
14739        assert!(parsed.allowed_users.is_empty());
14740        assert_eq!(parsed.receive_mode, LarkReceiveMode::Websocket);
14741        assert!(parsed.port.is_none());
14742    }
14743
14744    #[test]
14745    async fn nextcloud_talk_config_serde() {
14746        let nc = NextcloudTalkConfig {
14747            base_url: "https://cloud.example.com".into(),
14748            app_token: "app-token".into(),
14749            webhook_secret: Some("webhook-secret".into()),
14750            allowed_users: vec!["user_a".into(), "*".into()],
14751            proxy_url: None,
14752            bot_name: None,
14753        };
14754
14755        let json = serde_json::to_string(&nc).unwrap();
14756        let parsed: NextcloudTalkConfig = serde_json::from_str(&json).unwrap();
14757        assert_eq!(parsed.base_url, "https://cloud.example.com");
14758        assert_eq!(parsed.app_token, "app-token");
14759        assert_eq!(parsed.webhook_secret.as_deref(), Some("webhook-secret"));
14760        assert_eq!(parsed.allowed_users, vec!["user_a", "*"]);
14761    }
14762
14763    #[test]
14764    async fn nextcloud_talk_config_defaults_optional_fields() {
14765        let json = r#"{"base_url":"https://cloud.example.com","app_token":"app-token"}"#;
14766        let parsed: NextcloudTalkConfig = serde_json::from_str(json).unwrap();
14767        assert!(parsed.webhook_secret.is_none());
14768        assert!(parsed.allowed_users.is_empty());
14769    }
14770
14771    // ── Config file permission hardening (Unix only) ───────────────
14772
14773    #[cfg(unix)]
14774    #[test]
14775    async fn new_config_file_has_restricted_permissions() {
14776        let tmp = tempfile::TempDir::new().unwrap();
14777        let config_path = tmp.path().join("config.toml");
14778
14779        // Create a config and save it
14780        let mut config = Config::default();
14781        config.config_path = config_path.clone();
14782        config.save().await.unwrap();
14783
14784        let meta = fs::metadata(&config_path).await.unwrap();
14785        let mode = meta.permissions().mode() & 0o777;
14786        assert_eq!(
14787            mode, 0o600,
14788            "New config file should be owner-only (0600), got {mode:o}"
14789        );
14790    }
14791
14792    #[cfg(unix)]
14793    #[test]
14794    async fn save_restricts_existing_world_readable_config_to_owner_only() {
14795        let tmp = tempfile::TempDir::new().unwrap();
14796        let config_path = tmp.path().join("config.toml");
14797
14798        let mut config = Config::default();
14799        config.config_path = config_path.clone();
14800        config.save().await.unwrap();
14801
14802        // Simulate the regression state observed in issue #1345.
14803        std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
14804        let loose_mode = std::fs::metadata(&config_path)
14805            .unwrap()
14806            .permissions()
14807            .mode()
14808            & 0o777;
14809        assert_eq!(
14810            loose_mode, 0o644,
14811            "test setup requires world-readable config"
14812        );
14813
14814        config.default_temperature = 0.6;
14815        config.save().await.unwrap();
14816
14817        let hardened_mode = std::fs::metadata(&config_path)
14818            .unwrap()
14819            .permissions()
14820            .mode()
14821            & 0o777;
14822        assert_eq!(
14823            hardened_mode, 0o600,
14824            "Saving config should restore owner-only permissions (0600)"
14825        );
14826    }
14827
14828    #[cfg(unix)]
14829    #[test]
14830    async fn world_readable_config_is_detectable() {
14831        use std::os::unix::fs::PermissionsExt;
14832
14833        let tmp = tempfile::TempDir::new().unwrap();
14834        let config_path = tmp.path().join("config.toml");
14835
14836        // Create a config file with intentionally loose permissions
14837        std::fs::write(&config_path, "# test config").unwrap();
14838        std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
14839
14840        let meta = std::fs::metadata(&config_path).unwrap();
14841        let mode = meta.permissions().mode();
14842        assert!(
14843            mode & 0o004 != 0,
14844            "Test setup: file should be world-readable (mode {mode:o})"
14845        );
14846    }
14847
14848    #[test]
14849    async fn transcription_config_defaults() {
14850        let tc = TranscriptionConfig::default();
14851        assert!(!tc.enabled);
14852        assert!(tc.api_url.contains("groq.com"));
14853        assert_eq!(tc.model, "whisper-large-v3-turbo");
14854        assert!(tc.language.is_none());
14855        assert_eq!(tc.max_duration_secs, 120);
14856        assert!(!tc.transcribe_non_ptt_audio);
14857    }
14858
14859    #[test]
14860    async fn config_roundtrip_with_transcription() {
14861        let mut config = Config::default();
14862        config.transcription.enabled = true;
14863        config.transcription.language = Some("en".into());
14864
14865        let toml_str = toml::to_string_pretty(&config).unwrap();
14866        let parsed = parse_test_config(&toml_str);
14867
14868        assert!(parsed.transcription.enabled);
14869        assert_eq!(parsed.transcription.language.as_deref(), Some("en"));
14870        assert_eq!(parsed.transcription.model, "whisper-large-v3-turbo");
14871    }
14872
14873    #[test]
14874    async fn config_without_transcription_uses_defaults() {
14875        let toml_str = r#"
14876            default_provider = "openrouter"
14877            default_model = "test-model"
14878            default_temperature = 0.7
14879        "#;
14880        let parsed = parse_test_config(toml_str);
14881        assert!(!parsed.transcription.enabled);
14882        assert_eq!(parsed.transcription.max_duration_secs, 120);
14883    }
14884
14885    #[test]
14886    async fn security_defaults_are_backward_compatible() {
14887        let parsed = parse_test_config(
14888            r#"
14889default_provider = "openrouter"
14890default_model = "anthropic/claude-sonnet-4.6"
14891default_temperature = 0.7
14892"#,
14893        );
14894
14895        assert!(!parsed.security.otp.enabled);
14896        assert_eq!(parsed.security.otp.method, OtpMethod::Totp);
14897        assert!(!parsed.security.estop.enabled);
14898        assert!(parsed.security.estop.require_otp_to_resume);
14899    }
14900
14901    #[test]
14902    async fn security_toml_parses_otp_and_estop_sections() {
14903        let parsed = parse_test_config(
14904            r#"
14905default_provider = "openrouter"
14906default_model = "anthropic/claude-sonnet-4.6"
14907default_temperature = 0.7
14908
14909[security.otp]
14910enabled = true
14911method = "totp"
14912token_ttl_secs = 30
14913cache_valid_secs = 120
14914gated_actions = ["shell", "browser_open"]
14915gated_domains = ["*.chase.com", "accounts.google.com"]
14916gated_domain_categories = ["banking"]
14917
14918[security.estop]
14919enabled = true
14920state_file = "~/.construct/estop-state.json"
14921require_otp_to_resume = true
14922"#,
14923        );
14924
14925        assert!(parsed.security.otp.enabled);
14926        assert!(parsed.security.estop.enabled);
14927        assert_eq!(parsed.security.otp.gated_actions.len(), 2);
14928        assert_eq!(parsed.security.otp.gated_domains.len(), 2);
14929        parsed.validate().unwrap();
14930    }
14931
14932    #[test]
14933    async fn security_validation_rejects_invalid_domain_glob() {
14934        let mut config = Config::default();
14935        config.security.otp.gated_domains = vec!["bad domain.com".into()];
14936
14937        let err = config.validate().expect_err("expected invalid domain glob");
14938        assert!(err.to_string().contains("gated_domains"));
14939    }
14940
14941    #[test]
14942    async fn validate_accepts_local_whisper_as_transcription_default_provider() {
14943        let mut config = Config::default();
14944        config.transcription.default_provider = "local_whisper".to_string();
14945
14946        config.validate().expect(
14947            "local_whisper must be accepted by the transcription.default_provider allowlist",
14948        );
14949    }
14950
14951    #[test]
14952    async fn validate_rejects_unknown_transcription_default_provider() {
14953        let mut config = Config::default();
14954        config.transcription.default_provider = "unknown_stt".to_string();
14955
14956        let err = config
14957            .validate()
14958            .expect_err("expected validation to reject unknown transcription provider");
14959        assert!(
14960            err.to_string().contains("transcription.default_provider"),
14961            "got: {err}"
14962        );
14963    }
14964
14965    #[tokio::test]
14966    async fn channel_secret_telegram_bot_token_roundtrip() {
14967        let dir = std::env::temp_dir().join(format!(
14968            "construct_test_tg_bot_token_{}",
14969            uuid::Uuid::new_v4()
14970        ));
14971        fs::create_dir_all(&dir).await.unwrap();
14972
14973        let plaintext_token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";
14974
14975        let mut config = Config::default();
14976        config.workspace_dir = dir.join("workspace");
14977        config.config_path = dir.join("config.toml");
14978        config.channels_config.telegram = Some(TelegramConfig {
14979            bot_token: plaintext_token.into(),
14980            allowed_users: vec!["user1".into()],
14981            stream_mode: StreamMode::default(),
14982            draft_update_interval_ms: default_draft_update_interval_ms(),
14983            interrupt_on_new_message: false,
14984            mention_only: false,
14985            ack_reactions: None,
14986            proxy_url: None,
14987            notification_chat_id: None,
14988        });
14989
14990        // Save (triggers encryption)
14991        config.save().await.unwrap();
14992
14993        // Read raw TOML and verify plaintext token is NOT present
14994        let raw_toml = tokio::fs::read_to_string(&config.config_path)
14995            .await
14996            .unwrap();
14997        assert!(
14998            !raw_toml.contains(plaintext_token),
14999            "Saved TOML must not contain the plaintext bot_token"
15000        );
15001
15002        // Parse stored TOML and verify the value is encrypted
15003        let stored: Config = toml::from_str(&raw_toml).unwrap();
15004        let stored_token = &stored.channels_config.telegram.as_ref().unwrap().bot_token;
15005        assert!(
15006            crate::security::SecretStore::is_encrypted(stored_token),
15007            "Stored bot_token must be marked as encrypted"
15008        );
15009
15010        // Decrypt and verify it matches the original plaintext
15011        let store = crate::security::SecretStore::new(&dir, true);
15012        assert_eq!(store.decrypt(stored_token).unwrap(), plaintext_token);
15013
15014        // Simulate a full load: deserialize then decrypt (mirrors load_or_init logic)
15015        let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
15016        loaded.config_path = dir.join("config.toml");
15017        let load_store = crate::security::SecretStore::new(&dir, loaded.secrets.encrypt);
15018        if let Some(ref mut tg) = loaded.channels_config.telegram {
15019            decrypt_secret(
15020                &load_store,
15021                &mut tg.bot_token,
15022                "config.channels_config.telegram.bot_token",
15023            )
15024            .unwrap();
15025        }
15026        assert_eq!(
15027            loaded.channels_config.telegram.as_ref().unwrap().bot_token,
15028            plaintext_token,
15029            "Loaded bot_token must match the original plaintext after decryption"
15030        );
15031
15032        let _ = fs::remove_dir_all(&dir).await;
15033    }
15034
15035    #[test]
15036    async fn security_validation_rejects_unknown_domain_category() {
15037        let mut config = Config::default();
15038        config.security.otp.gated_domain_categories = vec!["not_real".into()];
15039
15040        let err = config
15041            .validate()
15042            .expect_err("expected unknown domain category");
15043        assert!(err.to_string().contains("gated_domain_categories"));
15044    }
15045
15046    #[test]
15047    async fn security_validation_rejects_zero_token_ttl() {
15048        let mut config = Config::default();
15049        config.security.otp.token_ttl_secs = 0;
15050
15051        let err = config
15052            .validate()
15053            .expect_err("expected ttl validation failure");
15054        assert!(err.to_string().contains("token_ttl_secs"));
15055    }
15056
15057    // ── MCP config validation ─────────────────────────────────────────────
15058
15059    fn stdio_server(name: &str, command: &str) -> McpServerConfig {
15060        McpServerConfig {
15061            name: name.to_string(),
15062            transport: McpTransport::Stdio,
15063            command: command.to_string(),
15064            ..Default::default()
15065        }
15066    }
15067
15068    fn http_server(name: &str, url: &str) -> McpServerConfig {
15069        McpServerConfig {
15070            name: name.to_string(),
15071            transport: McpTransport::Http,
15072            url: Some(url.to_string()),
15073            ..Default::default()
15074        }
15075    }
15076
15077    fn sse_server(name: &str, url: &str) -> McpServerConfig {
15078        McpServerConfig {
15079            name: name.to_string(),
15080            transport: McpTransport::Sse,
15081            url: Some(url.to_string()),
15082            ..Default::default()
15083        }
15084    }
15085
15086    #[test]
15087    async fn validate_mcp_config_empty_servers_ok() {
15088        let cfg = McpConfig::default();
15089        assert!(validate_mcp_config(&cfg).is_ok());
15090    }
15091
15092    #[test]
15093    async fn validate_mcp_config_valid_stdio_ok() {
15094        let cfg = McpConfig {
15095            enabled: true,
15096            servers: vec![stdio_server("fs", "/usr/bin/mcp-fs")],
15097            ..Default::default()
15098        };
15099        assert!(validate_mcp_config(&cfg).is_ok());
15100    }
15101
15102    #[test]
15103    async fn validate_mcp_config_valid_http_ok() {
15104        let cfg = McpConfig {
15105            enabled: true,
15106            servers: vec![http_server("svc", "http://localhost:8080/mcp")],
15107            ..Default::default()
15108        };
15109        assert!(validate_mcp_config(&cfg).is_ok());
15110    }
15111
15112    #[test]
15113    async fn validate_mcp_config_valid_sse_ok() {
15114        let cfg = McpConfig {
15115            enabled: true,
15116            servers: vec![sse_server("svc", "https://example.com/events")],
15117            ..Default::default()
15118        };
15119        assert!(validate_mcp_config(&cfg).is_ok());
15120    }
15121
15122    #[test]
15123    async fn validate_mcp_config_rejects_empty_name() {
15124        let cfg = McpConfig {
15125            enabled: true,
15126            servers: vec![stdio_server("", "/usr/bin/tool")],
15127            ..Default::default()
15128        };
15129        let err = validate_mcp_config(&cfg).expect_err("empty name should fail");
15130        assert!(
15131            err.to_string().contains("name must not be empty"),
15132            "got: {err}"
15133        );
15134    }
15135
15136    #[test]
15137    async fn validate_mcp_config_rejects_whitespace_name() {
15138        let cfg = McpConfig {
15139            enabled: true,
15140            servers: vec![stdio_server("   ", "/usr/bin/tool")],
15141            ..Default::default()
15142        };
15143        let err = validate_mcp_config(&cfg).expect_err("whitespace name should fail");
15144        assert!(
15145            err.to_string().contains("name must not be empty"),
15146            "got: {err}"
15147        );
15148    }
15149
15150    #[test]
15151    async fn validate_mcp_config_rejects_duplicate_names() {
15152        let cfg = McpConfig {
15153            enabled: true,
15154            servers: vec![
15155                stdio_server("fs", "/usr/bin/mcp-a"),
15156                stdio_server("fs", "/usr/bin/mcp-b"),
15157            ],
15158            ..Default::default()
15159        };
15160        let err = validate_mcp_config(&cfg).expect_err("duplicate name should fail");
15161        assert!(err.to_string().contains("duplicate name"), "got: {err}");
15162    }
15163
15164    #[test]
15165    async fn validate_mcp_config_rejects_zero_timeout() {
15166        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
15167        server.tool_timeout_secs = Some(0);
15168        let cfg = McpConfig {
15169            enabled: true,
15170            servers: vec![server],
15171            ..Default::default()
15172        };
15173        let err = validate_mcp_config(&cfg).expect_err("zero timeout should fail");
15174        assert!(err.to_string().contains("greater than 0"), "got: {err}");
15175    }
15176
15177    #[test]
15178    async fn validate_mcp_config_rejects_timeout_exceeding_max() {
15179        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
15180        server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS + 1);
15181        let cfg = McpConfig {
15182            enabled: true,
15183            servers: vec![server],
15184            ..Default::default()
15185        };
15186        let err = validate_mcp_config(&cfg).expect_err("oversized timeout should fail");
15187        assert!(err.to_string().contains("exceeds max"), "got: {err}");
15188    }
15189
15190    #[test]
15191    async fn validate_mcp_config_allows_max_timeout_exactly() {
15192        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
15193        server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS);
15194        let cfg = McpConfig {
15195            enabled: true,
15196            servers: vec![server],
15197            ..Default::default()
15198        };
15199        assert!(validate_mcp_config(&cfg).is_ok());
15200    }
15201
15202    #[test]
15203    async fn validate_mcp_config_rejects_stdio_with_empty_command() {
15204        let cfg = McpConfig {
15205            enabled: true,
15206            servers: vec![stdio_server("fs", "")],
15207            ..Default::default()
15208        };
15209        let err = validate_mcp_config(&cfg).expect_err("empty command should fail");
15210        assert!(
15211            err.to_string().contains("requires non-empty command"),
15212            "got: {err}"
15213        );
15214    }
15215
15216    #[test]
15217    async fn validate_mcp_config_rejects_http_without_url() {
15218        let cfg = McpConfig {
15219            enabled: true,
15220            servers: vec![McpServerConfig {
15221                name: "svc".to_string(),
15222                transport: McpTransport::Http,
15223                url: None,
15224                ..Default::default()
15225            }],
15226            ..Default::default()
15227        };
15228        let err = validate_mcp_config(&cfg).expect_err("http without url should fail");
15229        assert!(err.to_string().contains("requires url"), "got: {err}");
15230    }
15231
15232    #[test]
15233    async fn validate_mcp_config_rejects_sse_without_url() {
15234        let cfg = McpConfig {
15235            enabled: true,
15236            servers: vec![McpServerConfig {
15237                name: "svc".to_string(),
15238                transport: McpTransport::Sse,
15239                url: None,
15240                ..Default::default()
15241            }],
15242            ..Default::default()
15243        };
15244        let err = validate_mcp_config(&cfg).expect_err("sse without url should fail");
15245        assert!(err.to_string().contains("requires url"), "got: {err}");
15246    }
15247
15248    #[test]
15249    async fn validate_mcp_config_rejects_non_http_scheme() {
15250        let cfg = McpConfig {
15251            enabled: true,
15252            servers: vec![http_server("svc", "ftp://example.com/mcp")],
15253            ..Default::default()
15254        };
15255        let err = validate_mcp_config(&cfg).expect_err("non-http scheme should fail");
15256        assert!(err.to_string().contains("http/https"), "got: {err}");
15257    }
15258
15259    #[test]
15260    async fn validate_mcp_config_rejects_invalid_url() {
15261        let cfg = McpConfig {
15262            enabled: true,
15263            servers: vec![http_server("svc", "not a url at all !!!")],
15264            ..Default::default()
15265        };
15266        let err = validate_mcp_config(&cfg).expect_err("invalid url should fail");
15267        assert!(err.to_string().contains("valid URL"), "got: {err}");
15268    }
15269
15270    #[test]
15271    async fn mcp_config_default_disabled_with_empty_servers() {
15272        let cfg = McpConfig::default();
15273        assert!(!cfg.enabled);
15274        assert!(cfg.servers.is_empty());
15275    }
15276
15277    #[test]
15278    async fn mcp_transport_serde_roundtrip_lowercase() {
15279        let cases = [
15280            (McpTransport::Stdio, "\"stdio\""),
15281            (McpTransport::Http, "\"http\""),
15282            (McpTransport::Sse, "\"sse\""),
15283        ];
15284        for (variant, expected_json) in &cases {
15285            let serialized = serde_json::to_string(variant).expect("serialize");
15286            assert_eq!(&serialized, expected_json, "variant: {variant:?}");
15287            let deserialized: McpTransport =
15288                serde_json::from_str(expected_json).expect("deserialize");
15289            assert_eq!(&deserialized, variant);
15290        }
15291    }
15292
15293    #[test]
15294    async fn swarm_strategy_roundtrip() {
15295        let cases = vec![
15296            (SwarmStrategy::Sequential, "\"sequential\""),
15297            (SwarmStrategy::Parallel, "\"parallel\""),
15298            (SwarmStrategy::Router, "\"router\""),
15299        ];
15300        for (variant, expected_json) in &cases {
15301            let serialized = serde_json::to_string(variant).expect("serialize");
15302            assert_eq!(&serialized, expected_json, "variant: {variant:?}");
15303            let deserialized: SwarmStrategy =
15304                serde_json::from_str(expected_json).expect("deserialize");
15305            assert_eq!(&deserialized, variant);
15306        }
15307    }
15308
15309    #[test]
15310    async fn swarm_config_deserializes_with_defaults() {
15311        let toml_str = r#"
15312            agents = ["researcher", "writer"]
15313            strategy = "sequential"
15314        "#;
15315        let config: SwarmConfig = toml::from_str(toml_str).expect("deserialize");
15316        assert_eq!(config.agents, vec!["researcher", "writer"]);
15317        assert_eq!(config.strategy, SwarmStrategy::Sequential);
15318        assert!(config.router_prompt.is_none());
15319        assert!(config.description.is_none());
15320        assert_eq!(config.timeout_secs, 300);
15321    }
15322
15323    #[test]
15324    async fn swarm_config_deserializes_full() {
15325        let toml_str = r#"
15326            agents = ["a", "b", "c"]
15327            strategy = "router"
15328            router_prompt = "Pick the best."
15329            description = "Multi-agent router"
15330            timeout_secs = 120
15331        "#;
15332        let config: SwarmConfig = toml::from_str(toml_str).expect("deserialize");
15333        assert_eq!(config.agents.len(), 3);
15334        assert_eq!(config.strategy, SwarmStrategy::Router);
15335        assert_eq!(config.router_prompt.as_deref(), Some("Pick the best."));
15336        assert_eq!(config.description.as_deref(), Some("Multi-agent router"));
15337        assert_eq!(config.timeout_secs, 120);
15338    }
15339
15340    #[test]
15341    async fn config_with_swarms_section_deserializes() {
15342        let toml_str = r#"
15343            [agents.researcher]
15344            provider = "ollama"
15345            model = "llama3"
15346
15347            [agents.writer]
15348            provider = "openrouter"
15349            model = "claude-sonnet"
15350
15351            [swarms.pipeline]
15352            agents = ["researcher", "writer"]
15353            strategy = "sequential"
15354        "#;
15355        let config = parse_test_config(toml_str);
15356        assert_eq!(config.agents.len(), 2);
15357        assert_eq!(config.swarms.len(), 1);
15358        assert!(config.swarms.contains_key("pipeline"));
15359    }
15360
15361    #[tokio::test]
15362    async fn nevis_client_secret_encrypt_decrypt_roundtrip() {
15363        let dir = std::env::temp_dir().join(format!(
15364            "construct_test_nevis_secret_{}",
15365            uuid::Uuid::new_v4()
15366        ));
15367        fs::create_dir_all(&dir).await.unwrap();
15368
15369        let plaintext_secret = "nevis-test-client-secret-value";
15370
15371        let mut config = Config::default();
15372        config.workspace_dir = dir.join("workspace");
15373        config.config_path = dir.join("config.toml");
15374        config.security.nevis.client_secret = Some(plaintext_secret.into());
15375
15376        // Save (triggers encryption)
15377        config.save().await.unwrap();
15378
15379        // Read raw TOML and verify plaintext secret is NOT present
15380        let raw_toml = tokio::fs::read_to_string(&config.config_path)
15381            .await
15382            .unwrap();
15383        assert!(
15384            !raw_toml.contains(plaintext_secret),
15385            "Saved TOML must not contain the plaintext client_secret"
15386        );
15387
15388        // Parse stored TOML and verify the value is encrypted
15389        let stored: Config = toml::from_str(&raw_toml).unwrap();
15390        let stored_secret = stored.security.nevis.client_secret.as_ref().unwrap();
15391        assert!(
15392            crate::security::SecretStore::is_encrypted(stored_secret),
15393            "Stored client_secret must be marked as encrypted"
15394        );
15395
15396        // Decrypt and verify it matches the original plaintext
15397        let store = crate::security::SecretStore::new(&dir, true);
15398        assert_eq!(store.decrypt(stored_secret).unwrap(), plaintext_secret);
15399
15400        // Simulate a full load: deserialize then decrypt (mirrors load_or_init logic)
15401        let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
15402        loaded.config_path = dir.join("config.toml");
15403        let load_store = crate::security::SecretStore::new(&dir, loaded.secrets.encrypt);
15404        decrypt_optional_secret(
15405            &load_store,
15406            &mut loaded.security.nevis.client_secret,
15407            "config.security.nevis.client_secret",
15408        )
15409        .unwrap();
15410        assert_eq!(
15411            loaded.security.nevis.client_secret.as_deref().unwrap(),
15412            plaintext_secret,
15413            "Loaded client_secret must match the original plaintext after decryption"
15414        );
15415
15416        let _ = fs::remove_dir_all(&dir).await;
15417    }
15418
15419    // ══════════════════════════════════════════════════════════
15420    // Nevis config validation tests
15421    // ══════════════════════════════════════════════════════════
15422
15423    #[test]
15424    async fn nevis_config_validate_disabled_accepts_empty_fields() {
15425        let cfg = NevisConfig::default();
15426        assert!(!cfg.enabled);
15427        assert!(cfg.validate().is_ok());
15428    }
15429
15430    #[test]
15431    async fn nevis_config_validate_rejects_empty_instance_url() {
15432        let cfg = NevisConfig {
15433            enabled: true,
15434            instance_url: String::new(),
15435            client_id: "test-client".into(),
15436            ..NevisConfig::default()
15437        };
15438        let err = cfg.validate().unwrap_err();
15439        assert!(err.contains("instance_url"));
15440    }
15441
15442    #[test]
15443    async fn nevis_config_validate_rejects_empty_client_id() {
15444        let cfg = NevisConfig {
15445            enabled: true,
15446            instance_url: "https://nevis.example.com".into(),
15447            client_id: String::new(),
15448            ..NevisConfig::default()
15449        };
15450        let err = cfg.validate().unwrap_err();
15451        assert!(err.contains("client_id"));
15452    }
15453
15454    #[test]
15455    async fn nevis_config_validate_rejects_empty_realm() {
15456        let cfg = NevisConfig {
15457            enabled: true,
15458            instance_url: "https://nevis.example.com".into(),
15459            client_id: "test-client".into(),
15460            realm: String::new(),
15461            ..NevisConfig::default()
15462        };
15463        let err = cfg.validate().unwrap_err();
15464        assert!(err.contains("realm"));
15465    }
15466
15467    #[test]
15468    async fn nevis_config_validate_rejects_local_without_jwks() {
15469        let cfg = NevisConfig {
15470            enabled: true,
15471            instance_url: "https://nevis.example.com".into(),
15472            client_id: "test-client".into(),
15473            token_validation: "local".into(),
15474            jwks_url: None,
15475            ..NevisConfig::default()
15476        };
15477        let err = cfg.validate().unwrap_err();
15478        assert!(err.contains("jwks_url"));
15479    }
15480
15481    #[test]
15482    async fn nevis_config_validate_rejects_zero_session_timeout() {
15483        let cfg = NevisConfig {
15484            enabled: true,
15485            instance_url: "https://nevis.example.com".into(),
15486            client_id: "test-client".into(),
15487            token_validation: "remote".into(),
15488            session_timeout_secs: 0,
15489            ..NevisConfig::default()
15490        };
15491        let err = cfg.validate().unwrap_err();
15492        assert!(err.contains("session_timeout_secs"));
15493    }
15494
15495    #[test]
15496    async fn nevis_config_validate_accepts_valid_enabled_config() {
15497        let cfg = NevisConfig {
15498            enabled: true,
15499            instance_url: "https://nevis.example.com".into(),
15500            realm: "master".into(),
15501            client_id: "test-client".into(),
15502            token_validation: "remote".into(),
15503            session_timeout_secs: 3600,
15504            ..NevisConfig::default()
15505        };
15506        assert!(cfg.validate().is_ok());
15507    }
15508
15509    #[test]
15510    async fn nevis_config_validate_rejects_invalid_token_validation() {
15511        let cfg = NevisConfig {
15512            enabled: true,
15513            instance_url: "https://nevis.example.com".into(),
15514            realm: "master".into(),
15515            client_id: "test-client".into(),
15516            token_validation: "invalid_mode".into(),
15517            session_timeout_secs: 3600,
15518            ..NevisConfig::default()
15519        };
15520        let err = cfg.validate().unwrap_err();
15521        assert!(
15522            err.contains("invalid value 'invalid_mode'"),
15523            "Expected invalid token_validation error, got: {err}"
15524        );
15525    }
15526
15527    #[test]
15528    async fn nevis_config_debug_redacts_client_secret() {
15529        let cfg = NevisConfig {
15530            client_secret: Some("super-secret".into()),
15531            ..NevisConfig::default()
15532        };
15533        let debug_output = format!("{:?}", cfg);
15534        assert!(
15535            !debug_output.contains("super-secret"),
15536            "Debug output must not contain the raw client_secret"
15537        );
15538        assert!(
15539            debug_output.contains("[REDACTED]"),
15540            "Debug output must show [REDACTED] for client_secret"
15541        );
15542    }
15543
15544    #[test]
15545    async fn telegram_config_ack_reactions_false_deserializes() {
15546        let toml_str = r#"
15547            bot_token = "123:ABC"
15548            allowed_users = ["alice"]
15549            ack_reactions = false
15550        "#;
15551        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
15552        assert_eq!(cfg.ack_reactions, Some(false));
15553    }
15554
15555    #[test]
15556    async fn telegram_config_ack_reactions_true_deserializes() {
15557        let toml_str = r#"
15558            bot_token = "123:ABC"
15559            allowed_users = ["alice"]
15560            ack_reactions = true
15561        "#;
15562        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
15563        assert_eq!(cfg.ack_reactions, Some(true));
15564    }
15565
15566    #[test]
15567    async fn telegram_config_ack_reactions_missing_defaults_to_none() {
15568        let toml_str = r#"
15569            bot_token = "123:ABC"
15570            allowed_users = ["alice"]
15571        "#;
15572        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
15573        assert_eq!(cfg.ack_reactions, None);
15574    }
15575
15576    #[test]
15577    async fn telegram_config_ack_reactions_channel_overrides_top_level() {
15578        let tg_toml = r#"
15579            bot_token = "123:ABC"
15580            allowed_users = ["alice"]
15581            ack_reactions = false
15582        "#;
15583        let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
15584        let top_level_ack = true;
15585        let effective = tg.ack_reactions.unwrap_or(top_level_ack);
15586        assert!(
15587            !effective,
15588            "channel-level false must override top-level true"
15589        );
15590    }
15591
15592    #[test]
15593    async fn telegram_config_ack_reactions_falls_back_to_top_level() {
15594        let tg_toml = r#"
15595            bot_token = "123:ABC"
15596            allowed_users = ["alice"]
15597        "#;
15598        let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
15599        let top_level_ack = false;
15600        let effective = tg.ack_reactions.unwrap_or(top_level_ack);
15601        assert!(
15602            !effective,
15603            "must fall back to top-level false when channel omits field"
15604        );
15605    }
15606
15607    #[test]
15608    async fn google_workspace_allowed_operations_deserialize_from_toml() {
15609        let toml_str = r#"
15610            enabled = true
15611
15612            [[allowed_operations]]
15613            service = "gmail"
15614            resource = "users"
15615            sub_resource = "drafts"
15616            methods = ["create", "update"]
15617        "#;
15618
15619        let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
15620        assert_eq!(cfg.allowed_operations.len(), 1);
15621        assert_eq!(cfg.allowed_operations[0].service, "gmail");
15622        assert_eq!(cfg.allowed_operations[0].resource, "users");
15623        assert_eq!(
15624            cfg.allowed_operations[0].sub_resource.as_deref(),
15625            Some("drafts")
15626        );
15627        assert_eq!(
15628            cfg.allowed_operations[0].methods,
15629            vec!["create".to_string(), "update".to_string()]
15630        );
15631    }
15632
15633    #[test]
15634    async fn google_workspace_allowed_operations_deserialize_without_sub_resource() {
15635        let toml_str = r#"
15636            enabled = true
15637
15638            [[allowed_operations]]
15639            service = "drive"
15640            resource = "files"
15641            methods = ["list", "get"]
15642        "#;
15643
15644        let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
15645        assert_eq!(cfg.allowed_operations[0].sub_resource, None);
15646    }
15647
15648    #[test]
15649    async fn config_validate_accepts_google_workspace_allowed_operations() {
15650        let mut cfg = Config::default();
15651        cfg.google_workspace.enabled = true;
15652        cfg.google_workspace.allowed_services = vec!["gmail".into()];
15653        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15654            service: "gmail".into(),
15655            resource: "users".into(),
15656            sub_resource: Some("drafts".into()),
15657            methods: vec!["create".into(), "update".into()],
15658        }];
15659
15660        cfg.validate().unwrap();
15661    }
15662
15663    #[test]
15664    async fn config_validate_rejects_duplicate_google_workspace_allowed_operations() {
15665        let mut cfg = Config::default();
15666        cfg.google_workspace.enabled = true;
15667        cfg.google_workspace.allowed_services = vec!["gmail".into()];
15668        cfg.google_workspace.allowed_operations = vec![
15669            GoogleWorkspaceAllowedOperation {
15670                service: "gmail".into(),
15671                resource: "users".into(),
15672                sub_resource: Some("drafts".into()),
15673                methods: vec!["create".into()],
15674            },
15675            GoogleWorkspaceAllowedOperation {
15676                service: "gmail".into(),
15677                resource: "users".into(),
15678                sub_resource: Some("drafts".into()),
15679                methods: vec!["update".into()],
15680            },
15681        ];
15682
15683        let err = cfg.validate().unwrap_err().to_string();
15684        assert!(err.contains("duplicate service/resource/sub_resource entry"));
15685    }
15686
15687    #[test]
15688    async fn config_validate_rejects_operation_service_not_in_allowed_services() {
15689        let mut cfg = Config::default();
15690        cfg.google_workspace.enabled = true;
15691        cfg.google_workspace.allowed_services = vec!["gmail".into()];
15692        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15693            service: "drive".into(), // drive is not in allowed_services
15694            resource: "files".into(),
15695            sub_resource: None,
15696            methods: vec!["list".into()],
15697        }];
15698
15699        let err = cfg.validate().unwrap_err().to_string();
15700        assert!(
15701            err.contains("not in the effective allowed_services"),
15702            "expected not-in-allowed_services error, got: {err}"
15703        );
15704    }
15705
15706    #[test]
15707    async fn config_validate_accepts_default_service_when_allowed_services_empty() {
15708        // When allowed_services is empty the validator uses DEFAULT_GWS_SERVICES.
15709        // A known default service must pass.
15710        let mut cfg = Config::default();
15711        cfg.google_workspace.enabled = true;
15712        // allowed_services deliberately left empty (falls back to defaults)
15713        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15714            service: "drive".into(),
15715            resource: "files".into(),
15716            sub_resource: None,
15717            methods: vec!["list".into()],
15718        }];
15719
15720        assert!(cfg.validate().is_ok());
15721    }
15722
15723    #[test]
15724    async fn config_validate_rejects_unknown_service_when_allowed_services_empty() {
15725        // Even with allowed_services empty (using defaults), an operation whose
15726        // service is not in DEFAULT_GWS_SERVICES must fail validation — not silently
15727        // pass through to be rejected at runtime.
15728        let mut cfg = Config::default();
15729        cfg.google_workspace.enabled = true;
15730        // allowed_services deliberately left empty
15731        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15732            service: "not_a_real_service".into(),
15733            resource: "files".into(),
15734            sub_resource: None,
15735            methods: vec!["list".into()],
15736        }];
15737
15738        let err = cfg.validate().unwrap_err().to_string();
15739        assert!(
15740            err.contains("not in the effective allowed_services"),
15741            "expected effective-allowed_services error, got: {err}"
15742        );
15743    }
15744
15745    // ── Bootstrap files ─────────────────────────────────────
15746
15747    #[tokio::test]
15748    async fn ensure_bootstrap_files_creates_missing_files() {
15749        let tmp = tempfile::TempDir::new().unwrap();
15750        let ws = tmp.path().join("workspace");
15751        let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
15752
15753        ensure_bootstrap_files(&ws).await.unwrap();
15754
15755        let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
15756        let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
15757            .await
15758            .unwrap();
15759        assert!(soul.contains("SOUL.md"));
15760        assert!(identity.contains("IDENTITY.md"));
15761    }
15762
15763    #[tokio::test]
15764    async fn ensure_bootstrap_files_does_not_overwrite_existing() {
15765        let tmp = tempfile::TempDir::new().unwrap();
15766        let ws = tmp.path().join("workspace");
15767        let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
15768
15769        let custom = "# My custom SOUL";
15770        let _: () = tokio::fs::write(ws.join("SOUL.md"), custom).await.unwrap();
15771
15772        ensure_bootstrap_files(&ws).await.unwrap();
15773
15774        let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
15775        assert_eq!(
15776            soul, custom,
15777            "ensure_bootstrap_files must not overwrite existing files"
15778        );
15779
15780        // IDENTITY.md should still be created since it was missing
15781        let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
15782            .await
15783            .unwrap();
15784        assert!(identity.contains("IDENTITY.md"));
15785    }
15786
15787    // ── PacingConfig serde defaults ─────────────────────────────
15788
15789    #[test]
15790    async fn pacing_config_serde_defaults_match_manual_default() {
15791        // Deserialise an empty TOML table and verify the loop-detection
15792        // fields receive the same defaults as `PacingConfig::default()`.
15793        let from_toml: PacingConfig = toml::from_str("").unwrap();
15794        let manual = PacingConfig::default();
15795
15796        assert_eq!(
15797            from_toml.loop_detection_enabled,
15798            manual.loop_detection_enabled
15799        );
15800        assert_eq!(
15801            from_toml.loop_detection_window_size,
15802            manual.loop_detection_window_size
15803        );
15804        assert_eq!(
15805            from_toml.loop_detection_max_repeats,
15806            manual.loop_detection_max_repeats
15807        );
15808
15809        // Verify concrete values so a silent change to the defaults is caught.
15810        assert!(from_toml.loop_detection_enabled, "default should be true");
15811        assert_eq!(from_toml.loop_detection_window_size, 20);
15812        assert_eq!(from_toml.loop_detection_max_repeats, 3);
15813    }
15814
15815    // ── Docker baked config template ────────────────────────────
15816
15817    /// The TOML template baked into Docker images (Dockerfile + Dockerfile.debian).
15818    /// Kept here so changes to the Dockerfiles can be validated by `cargo test`.
15819    const DOCKER_CONFIG_TEMPLATE: &str = r#"
15820workspace_dir = "/construct-data/workspace"
15821config_path = "/construct-data/.construct/config.toml"
15822api_key = ""
15823default_provider = "openrouter"
15824default_model = "anthropic/claude-sonnet-4-20250514"
15825default_temperature = 0.7
15826
15827[gateway]
15828port = 42617
15829host = "[::]"
15830allow_public_bind = true
15831
15832[autonomy]
15833level = "supervised"
15834auto_approve = ["file_read", "file_write", "file_edit", "memory_recall", "memory_store", "web_search_tool", "web_fetch", "calculator", "glob_search", "content_search", "image_info", "weather", "git_operations"]
15835"#;
15836
15837    #[test]
15838    async fn docker_config_template_is_parseable() {
15839        let cfg: Config = toml::from_str(DOCKER_CONFIG_TEMPLATE)
15840            .expect("Docker baked config.toml must be valid TOML that deserialises into Config");
15841
15842        // The [autonomy] section must be present and contain the expected tools.
15843        let auto = &cfg.autonomy.auto_approve;
15844        for tool in &[
15845            "file_read",
15846            "file_write",
15847            "file_edit",
15848            "memory_recall",
15849            "memory_store",
15850            "web_search_tool",
15851            "web_fetch",
15852            "calculator",
15853            "glob_search",
15854            "content_search",
15855            "image_info",
15856            "weather",
15857            "git_operations",
15858        ] {
15859            assert!(
15860                auto.iter().any(|t| t == tool),
15861                "Docker config auto_approve missing expected tool: {tool}"
15862            );
15863        }
15864    }
15865
15866    #[test]
15867    async fn cost_enforcement_config_defaults() {
15868        let config = CostEnforcementConfig::default();
15869        assert_eq!(config.mode, "warn");
15870        assert_eq!(config.route_down_model, None);
15871        assert_eq!(config.reserve_percent, 10);
15872    }
15873
15874    #[test]
15875    async fn cost_config_includes_enforcement() {
15876        let config = CostConfig::default();
15877        assert_eq!(config.enforcement.mode, "warn");
15878        assert_eq!(config.enforcement.reserve_percent, 10);
15879    }
15880}