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        dir.sync_all()
10819            .with_context(|| format!("Failed to fsync directory metadata: {}", path.display()))?;
10820        Ok(())
10821    }
10822
10823    #[cfg(not(any(unix, windows)))]
10824    {
10825        let _ = path;
10826        Ok(())
10827    }
10828}
10829
10830// ── SOP engine configuration ───────────────────────────────────
10831
10832/// Standard Operating Procedures engine configuration (`[sop]`).
10833///
10834/// The `default_execution_mode` field uses the `SopExecutionMode` type from
10835/// `sop::types` (re-exported via `sop::SopExecutionMode`). To avoid circular
10836/// module references, config stores it using the same enum definition.
10837#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
10838pub struct SopConfig {
10839    /// Directory containing SOP definitions (subdirs with SOP.toml + SOP.md).
10840    /// Falls back to `<workspace>/sops` when omitted.
10841    #[serde(default)]
10842    pub sops_dir: Option<String>,
10843
10844    /// Default execution mode for SOPs that omit `execution_mode`.
10845    /// Values: `auto`, `supervised` (default), `step_by_step`,
10846    /// `priority_based`, `deterministic`.
10847    #[serde(default = "default_sop_execution_mode")]
10848    pub default_execution_mode: String,
10849
10850    /// Maximum total concurrent SOP runs across all SOPs.
10851    #[serde(default = "default_sop_max_concurrent_total")]
10852    pub max_concurrent_total: usize,
10853
10854    /// Approval timeout in seconds. When a run waits for approval longer than
10855    /// this, Critical/High-priority SOPs auto-approve; others stay waiting.
10856    /// Set to 0 to disable timeout.
10857    #[serde(default = "default_sop_approval_timeout_secs")]
10858    pub approval_timeout_secs: u64,
10859
10860    /// Maximum number of finished runs kept in memory for status queries.
10861    /// Oldest runs are evicted when over capacity. 0 = unlimited.
10862    #[serde(default = "default_sop_max_finished_runs")]
10863    pub max_finished_runs: usize,
10864}
10865
10866fn default_sop_execution_mode() -> String {
10867    "supervised".to_string()
10868}
10869
10870fn default_sop_max_concurrent_total() -> usize {
10871    4
10872}
10873
10874fn default_sop_approval_timeout_secs() -> u64 {
10875    300
10876}
10877
10878fn default_sop_max_finished_runs() -> usize {
10879    100
10880}
10881
10882impl Default for SopConfig {
10883    fn default() -> Self {
10884        Self {
10885            sops_dir: None,
10886            default_execution_mode: default_sop_execution_mode(),
10887            max_concurrent_total: default_sop_max_concurrent_total(),
10888            approval_timeout_secs: default_sop_approval_timeout_secs(),
10889            max_finished_runs: default_sop_max_finished_runs(),
10890        }
10891    }
10892}
10893
10894#[cfg(test)]
10895mod tests {
10896    use super::*;
10897    use std::io;
10898    #[cfg(unix)]
10899    use std::os::unix::fs::PermissionsExt;
10900    use std::path::PathBuf;
10901    use std::sync::{Arc, Mutex as StdMutex};
10902    use tempfile::TempDir;
10903    use tokio::sync::{Mutex, MutexGuard};
10904    use tokio::test;
10905    use tokio_stream::StreamExt;
10906    use tokio_stream::wrappers::ReadDirStream;
10907
10908    // ── Tilde expansion ───────────────────────────────────────
10909
10910    #[test]
10911    async fn expand_tilde_path_handles_absolute_path() {
10912        let path = expand_tilde_path("/absolute/path");
10913        assert_eq!(path, PathBuf::from("/absolute/path"));
10914    }
10915
10916    #[test]
10917    async fn expand_tilde_path_handles_relative_path() {
10918        let path = expand_tilde_path("relative/path");
10919        assert_eq!(path, PathBuf::from("relative/path"));
10920    }
10921
10922    #[test]
10923    async fn expand_tilde_path_expands_tilde_when_home_set() {
10924        // This test verifies that tilde expansion works when HOME is set.
10925        // In normal environments, HOME is set, so ~ should expand.
10926        let path = expand_tilde_path("~/.construct");
10927        // The path should not literally start with '~' if HOME is set
10928        // (it should be expanded to the actual home directory)
10929        if std::env::var("HOME").is_ok() {
10930            assert!(
10931                !path.to_string_lossy().starts_with('~'),
10932                "Tilde should be expanded when HOME is set"
10933            );
10934        }
10935    }
10936
10937    // ── Defaults ─────────────────────────────────────────────
10938
10939    fn has_test_table(raw: &str, table: &str) -> bool {
10940        let exact = format!("[{table}]");
10941        let nested = format!("[{table}.");
10942        raw.lines()
10943            .map(str::trim)
10944            .any(|line| line == exact || line.starts_with(&nested))
10945    }
10946
10947    fn parse_test_config(raw: &str) -> Config {
10948        let mut merged = raw.trim().to_string();
10949        for table in [
10950            "data_retention",
10951            "cloud_ops",
10952            "conversational_ai",
10953            "security",
10954            "security_ops",
10955        ] {
10956            if has_test_table(&merged, table) {
10957                continue;
10958            }
10959            if !merged.is_empty() {
10960                merged.push_str("\n\n");
10961            }
10962            merged.push('[');
10963            merged.push_str(table);
10964            merged.push(']');
10965        }
10966        merged.push('\n');
10967        let mut config: Config = toml::from_str(&merged).unwrap();
10968        config.autonomy.ensure_default_auto_approve();
10969        config
10970    }
10971
10972    #[test]
10973    async fn http_request_config_default_has_correct_values() {
10974        let cfg = HttpRequestConfig::default();
10975        assert_eq!(cfg.timeout_secs, 30);
10976        assert_eq!(cfg.max_response_size, 1_000_000);
10977        assert!(cfg.enabled);
10978        assert_eq!(cfg.allowed_domains, vec!["*".to_string()]);
10979    }
10980
10981    #[test]
10982    async fn config_default_has_sane_values() {
10983        let c = Config::default();
10984        assert_eq!(c.default_provider.as_deref(), Some("openrouter"));
10985        assert!(c.default_model.as_deref().unwrap().contains("claude"));
10986        assert!((c.default_temperature - 0.7).abs() < f64::EPSILON);
10987        assert!(c.api_key.is_none());
10988        assert!(!c.skills.open_skills_enabled);
10989        assert!(!c.skills.allow_scripts);
10990        assert_eq!(
10991            c.skills.prompt_injection_mode,
10992            SkillsPromptInjectionMode::Full
10993        );
10994        assert_eq!(c.provider_timeout_secs, 120);
10995        assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
10996        assert!(c.config_path.to_string_lossy().contains("config.toml"));
10997    }
10998
10999    #[derive(Clone, Default)]
11000    struct SharedLogBuffer(Arc<StdMutex<Vec<u8>>>);
11001
11002    struct SharedLogWriter(Arc<StdMutex<Vec<u8>>>);
11003
11004    impl SharedLogBuffer {
11005        fn captured(&self) -> String {
11006            String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
11007        }
11008    }
11009
11010    impl<'a> tracing_subscriber::fmt::MakeWriter<'a> for SharedLogBuffer {
11011        type Writer = SharedLogWriter;
11012
11013        fn make_writer(&'a self) -> Self::Writer {
11014            SharedLogWriter(self.0.clone())
11015        }
11016    }
11017
11018    impl io::Write for SharedLogWriter {
11019        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
11020            self.0.lock().unwrap().extend_from_slice(buf);
11021            Ok(buf.len())
11022        }
11023
11024        fn flush(&mut self) -> io::Result<()> {
11025            Ok(())
11026        }
11027    }
11028
11029    #[test]
11030    async fn config_dir_creation_error_mentions_openrc_and_path() {
11031        let msg = config_dir_creation_error(Path::new("/etc/construct"));
11032        assert!(msg.contains("/etc/construct"));
11033        assert!(msg.contains("OpenRC"));
11034        assert!(msg.contains("construct"));
11035    }
11036
11037    #[test]
11038    async fn config_schema_export_contains_expected_contract_shape() {
11039        let schema = schemars::schema_for!(Config);
11040        let schema_json = serde_json::to_value(&schema).expect("schema should serialize to json");
11041
11042        assert_eq!(
11043            schema_json
11044                .get("$schema")
11045                .and_then(serde_json::Value::as_str),
11046            Some("https://json-schema.org/draft/2020-12/schema")
11047        );
11048
11049        let properties = schema_json
11050            .get("properties")
11051            .and_then(serde_json::Value::as_object)
11052            .expect("schema should expose top-level properties");
11053
11054        assert!(properties.contains_key("default_provider"));
11055        assert!(properties.contains_key("skills"));
11056        assert!(properties.contains_key("gateway"));
11057        assert!(properties.contains_key("channels_config"));
11058        assert!(!properties.contains_key("workspace_dir"));
11059        assert!(!properties.contains_key("config_path"));
11060
11061        assert!(
11062            schema_json
11063                .get("$defs")
11064                .and_then(serde_json::Value::as_object)
11065                .is_some(),
11066            "schema should include reusable type definitions"
11067        );
11068    }
11069
11070    #[cfg(unix)]
11071    #[test]
11072    async fn save_sets_config_permissions_on_new_file() {
11073        let temp = TempDir::new().expect("temp dir");
11074        let config_path = temp.path().join("config.toml");
11075        let workspace_dir = temp.path().join("workspace");
11076
11077        let mut config = Config::default();
11078        config.config_path = config_path.clone();
11079        config.workspace_dir = workspace_dir;
11080
11081        config.save().await.expect("save config");
11082
11083        let mode = std::fs::metadata(&config_path)
11084            .expect("config metadata")
11085            .permissions()
11086            .mode()
11087            & 0o777;
11088        assert_eq!(mode, 0o600);
11089    }
11090
11091    #[test]
11092    async fn observability_config_default() {
11093        let o = ObservabilityConfig::default();
11094        assert_eq!(o.backend, "none");
11095        assert_eq!(o.runtime_trace_mode, "none");
11096        assert_eq!(o.runtime_trace_path, "state/runtime-trace.jsonl");
11097        assert_eq!(o.runtime_trace_max_entries, 200);
11098    }
11099
11100    #[test]
11101    async fn autonomy_config_default() {
11102        let a = AutonomyConfig::default();
11103        assert_eq!(a.level, AutonomyLevel::Supervised);
11104        assert!(a.workspace_only);
11105        assert!(a.allowed_commands.contains(&"git".to_string()));
11106        assert!(a.allowed_commands.contains(&"cargo".to_string()));
11107        assert!(a.forbidden_paths.contains(&"/etc".to_string()));
11108        assert_eq!(a.max_actions_per_hour, 20);
11109        assert_eq!(a.max_cost_per_day_cents, 500);
11110        assert!(a.require_approval_for_medium_risk);
11111        assert!(a.block_high_risk_commands);
11112        assert!(a.shell_env_passthrough.is_empty());
11113    }
11114
11115    #[test]
11116    async fn runtime_config_default() {
11117        let r = RuntimeConfig::default();
11118        assert_eq!(r.kind, "native");
11119        assert_eq!(r.docker.image, "alpine:3.20");
11120        assert_eq!(r.docker.network, "none");
11121        assert_eq!(r.docker.memory_limit_mb, Some(512));
11122        assert_eq!(r.docker.cpu_limit, Some(1.0));
11123        assert!(r.docker.read_only_rootfs);
11124        assert!(r.docker.mount_workspace);
11125    }
11126
11127    #[test]
11128    async fn heartbeat_config_default() {
11129        let h = HeartbeatConfig::default();
11130        assert!(!h.enabled);
11131        assert_eq!(h.interval_minutes, 30);
11132        assert!(h.message.is_none());
11133        assert!(h.target.is_none());
11134        assert!(h.to.is_none());
11135    }
11136
11137    #[test]
11138    async fn heartbeat_config_parses_delivery_aliases() {
11139        let raw = r#"
11140enabled = true
11141interval_minutes = 10
11142message = "Ping"
11143channel = "telegram"
11144recipient = "42"
11145"#;
11146        let parsed: HeartbeatConfig = toml::from_str(raw).unwrap();
11147        assert!(parsed.enabled);
11148        assert_eq!(parsed.interval_minutes, 10);
11149        assert_eq!(parsed.message.as_deref(), Some("Ping"));
11150        assert_eq!(parsed.target.as_deref(), Some("telegram"));
11151        assert_eq!(parsed.to.as_deref(), Some("42"));
11152    }
11153
11154    #[test]
11155    async fn cron_config_default() {
11156        let c = CronConfig::default();
11157        assert!(c.enabled);
11158        assert_eq!(c.max_run_history, 50);
11159    }
11160
11161    #[test]
11162    async fn cron_config_serde_roundtrip() {
11163        let c = CronConfig {
11164            enabled: false,
11165            catch_up_on_startup: false,
11166            max_run_history: 100,
11167            jobs: Vec::new(),
11168        };
11169        let json = serde_json::to_string(&c).unwrap();
11170        let parsed: CronConfig = serde_json::from_str(&json).unwrap();
11171        assert!(!parsed.enabled);
11172        assert!(!parsed.catch_up_on_startup);
11173        assert_eq!(parsed.max_run_history, 100);
11174    }
11175
11176    #[test]
11177    async fn config_defaults_cron_when_section_missing() {
11178        let toml_str = r#"
11179workspace_dir = "/tmp/workspace"
11180config_path = "/tmp/config.toml"
11181default_temperature = 0.7
11182"#;
11183
11184        let parsed = parse_test_config(toml_str);
11185        assert!(parsed.cron.enabled);
11186        assert!(parsed.cron.catch_up_on_startup);
11187        assert_eq!(parsed.cron.max_run_history, 50);
11188    }
11189
11190    #[test]
11191    async fn memory_config_default_hygiene_settings() {
11192        let m = MemoryConfig::default();
11193        assert_eq!(m.backend, "none");
11194        assert!(m.auto_save);
11195        assert!(m.hygiene_enabled);
11196        assert_eq!(m.archive_after_days, 7);
11197        assert_eq!(m.purge_after_days, 30);
11198        assert_eq!(m.conversation_retention_days, 30);
11199    }
11200
11201    #[test]
11202    async fn storage_provider_config_defaults() {
11203        let storage = StorageConfig::default();
11204        assert!(storage.provider.config.provider.is_empty());
11205        assert!(storage.provider.config.db_url.is_none());
11206        assert_eq!(storage.provider.config.schema, "public");
11207        assert_eq!(storage.provider.config.table, "memories");
11208        assert!(storage.provider.config.connect_timeout_secs.is_none());
11209    }
11210
11211    #[test]
11212    async fn channels_config_default() {
11213        let c = ChannelsConfig::default();
11214        assert!(c.cli);
11215        assert!(c.telegram.is_none());
11216        assert!(c.discord.is_none());
11217        assert!(!c.show_tool_calls);
11218    }
11219
11220    // ── Serde round-trip ─────────────────────────────────────
11221
11222    #[test]
11223    async fn config_toml_roundtrip() {
11224        let config = Config {
11225            workspace_dir: PathBuf::from("/tmp/test/workspace"),
11226            config_path: PathBuf::from("/tmp/test/config.toml"),
11227            api_key: Some("sk-test-key".into()),
11228            api_url: None,
11229            api_path: None,
11230            default_provider: Some("openrouter".into()),
11231            default_model: Some("gpt-4o".into()),
11232            model_providers: HashMap::new(),
11233            default_temperature: 0.5,
11234            provider_timeout_secs: 120,
11235            provider_max_tokens: None,
11236            extra_headers: HashMap::new(),
11237            observability: ObservabilityConfig {
11238                backend: "log".into(),
11239                ..ObservabilityConfig::default()
11240            },
11241            autonomy: AutonomyConfig {
11242                level: AutonomyLevel::Full,
11243                workspace_only: false,
11244                allowed_commands: vec!["docker".into()],
11245                forbidden_paths: vec!["/secret".into()],
11246                max_actions_per_hour: 50,
11247                max_cost_per_day_cents: 1000,
11248                require_approval_for_medium_risk: false,
11249                block_high_risk_commands: true,
11250                shell_env_passthrough: vec!["DATABASE_URL".into()],
11251                auto_approve: vec!["file_read".into()],
11252                always_ask: vec![],
11253                allowed_roots: vec![],
11254                non_cli_excluded_tools: vec![],
11255            },
11256            trust: crate::trust::TrustConfig::default(),
11257            backup: BackupConfig::default(),
11258            data_retention: DataRetentionConfig::default(),
11259            cloud_ops: CloudOpsConfig::default(),
11260            conversational_ai: ConversationalAiConfig::default(),
11261            security: SecurityConfig::default(),
11262            security_ops: SecurityOpsConfig::default(),
11263            runtime: RuntimeConfig {
11264                kind: "docker".into(),
11265                ..RuntimeConfig::default()
11266            },
11267            reliability: ReliabilityConfig::default(),
11268            scheduler: SchedulerConfig::default(),
11269            skills: SkillsConfig::default(),
11270            pipeline: PipelineConfig::default(),
11271            model_routes: Vec::new(),
11272            embedding_routes: Vec::new(),
11273            query_classification: QueryClassificationConfig::default(),
11274            heartbeat: HeartbeatConfig {
11275                enabled: true,
11276                interval_minutes: 15,
11277                two_phase: true,
11278                message: Some("Check London time".into()),
11279                target: Some("telegram".into()),
11280                to: Some("123456".into()),
11281                ..HeartbeatConfig::default()
11282            },
11283            cron: CronConfig::default(),
11284            channels_config: ChannelsConfig {
11285                cli: true,
11286                telegram: Some(TelegramConfig {
11287                    bot_token: "123:ABC".into(),
11288                    allowed_users: vec!["user1".into()],
11289                    stream_mode: StreamMode::default(),
11290                    draft_update_interval_ms: default_draft_update_interval_ms(),
11291                    interrupt_on_new_message: false,
11292                    mention_only: false,
11293                    ack_reactions: None,
11294                    proxy_url: None,
11295                    notification_chat_id: None,
11296                }),
11297                discord: None,
11298                discord_history: None,
11299                slack: None,
11300                mattermost: None,
11301                webhook: None,
11302                imessage: None,
11303                matrix: None,
11304                signal: None,
11305                whatsapp: None,
11306                linq: None,
11307                wati: None,
11308                nextcloud_talk: None,
11309                email: None,
11310                gmail_push: None,
11311                irc: None,
11312                lark: None,
11313                feishu: None,
11314                dingtalk: None,
11315                wecom: None,
11316                qq: None,
11317                twitter: None,
11318                mochat: None,
11319                #[cfg(feature = "channel-nostr")]
11320                nostr: None,
11321                clawdtalk: None,
11322                reddit: None,
11323                bluesky: None,
11324                voice_call: None,
11325                #[cfg(feature = "voice-wake")]
11326                voice_wake: None,
11327                message_timeout_secs: 300,
11328                ack_reactions: true,
11329                show_tool_calls: true,
11330                session_persistence: true,
11331                session_backend: default_session_backend(),
11332                session_ttl_hours: 0,
11333                debounce_ms: 0,
11334            },
11335            memory: MemoryConfig::default(),
11336            storage: StorageConfig::default(),
11337            tunnel: TunnelConfig::default(),
11338            gateway: GatewayConfig::default(),
11339            composio: ComposioConfig::default(),
11340            microsoft365: Microsoft365Config::default(),
11341            secrets: SecretsConfig::default(),
11342            browser: BrowserConfig::default(),
11343            browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig::default(),
11344            http_request: HttpRequestConfig::default(),
11345            multimodal: MultimodalConfig::default(),
11346            media_pipeline: MediaPipelineConfig::default(),
11347            web_fetch: WebFetchConfig::default(),
11348            link_enricher: LinkEnricherConfig::default(),
11349            text_browser: TextBrowserConfig::default(),
11350            web_search: WebSearchConfig::default(),
11351            project_intel: ProjectIntelConfig::default(),
11352            google_workspace: GoogleWorkspaceConfig::default(),
11353            proxy: ProxyConfig::default(),
11354            agent: AgentConfig::default(),
11355            pacing: PacingConfig::default(),
11356            identity: IdentityConfig::default(),
11357            cost: CostConfig::default(),
11358            peripherals: PeripheralsConfig::default(),
11359            delegate: DelegateToolConfig::default(),
11360            agents: HashMap::new(),
11361            swarms: HashMap::new(),
11362            hooks: HooksConfig::default(),
11363            hardware: HardwareConfig::default(),
11364            transcription: TranscriptionConfig::default(),
11365            tts: TtsConfig::default(),
11366            mcp: McpConfig::default(),
11367            kumiho: KumihoConfig::default(),
11368            operator: OperatorConfig::default(),
11369            nodes: NodesConfig::default(),
11370            clawhub: ClawHubConfig::default(),
11371            workspace: WorkspaceConfig::default(),
11372            notion: NotionConfig::default(),
11373            jira: JiraConfig::default(),
11374            node_transport: NodeTransportConfig::default(),
11375            linkedin: LinkedInConfig::default(),
11376            image_gen: ImageGenConfig::default(),
11377            plugins: PluginsConfig::default(),
11378            locale: None,
11379            verifiable_intent: VerifiableIntentConfig::default(),
11380            claude_code: ClaudeCodeConfig::default(),
11381            claude_code_runner: ClaudeCodeRunnerConfig::default(),
11382            codex_cli: CodexCliConfig::default(),
11383            gemini_cli: GeminiCliConfig::default(),
11384            opencode_cli: OpenCodeCliConfig::default(),
11385            sop: SopConfig::default(),
11386            shell_tool: ShellToolConfig::default(),
11387        };
11388
11389        let toml_str = toml::to_string_pretty(&config).unwrap();
11390        let parsed = parse_test_config(&toml_str);
11391
11392        assert_eq!(parsed.api_key, config.api_key);
11393        assert_eq!(parsed.default_provider, config.default_provider);
11394        assert_eq!(parsed.default_model, config.default_model);
11395        assert!((parsed.default_temperature - config.default_temperature).abs() < f64::EPSILON);
11396        assert_eq!(parsed.observability.backend, "log");
11397        assert_eq!(parsed.observability.runtime_trace_mode, "none");
11398        assert_eq!(parsed.autonomy.level, AutonomyLevel::Full);
11399        assert!(!parsed.autonomy.workspace_only);
11400        assert_eq!(parsed.runtime.kind, "docker");
11401        assert!(parsed.heartbeat.enabled);
11402        assert_eq!(parsed.heartbeat.interval_minutes, 15);
11403        assert_eq!(
11404            parsed.heartbeat.message.as_deref(),
11405            Some("Check London time")
11406        );
11407        assert_eq!(parsed.heartbeat.target.as_deref(), Some("telegram"));
11408        assert_eq!(parsed.heartbeat.to.as_deref(), Some("123456"));
11409        assert!(parsed.channels_config.telegram.is_some());
11410        assert_eq!(
11411            parsed.channels_config.telegram.unwrap().bot_token,
11412            "123:ABC"
11413        );
11414    }
11415
11416    #[test]
11417    async fn config_minimal_toml_uses_defaults() {
11418        let minimal = r#"
11419workspace_dir = "/tmp/ws"
11420config_path = "/tmp/config.toml"
11421default_temperature = 0.7
11422"#;
11423        let parsed = parse_test_config(minimal);
11424        assert!(parsed.api_key.is_none());
11425        assert!(parsed.default_provider.is_none());
11426        assert_eq!(parsed.observability.backend, "none");
11427        assert_eq!(parsed.observability.runtime_trace_mode, "none");
11428        assert_eq!(parsed.autonomy.level, AutonomyLevel::Supervised);
11429        assert_eq!(parsed.runtime.kind, "native");
11430        assert!(!parsed.heartbeat.enabled);
11431        assert!(parsed.channels_config.cli);
11432        assert!(parsed.memory.hygiene_enabled);
11433        assert_eq!(parsed.memory.archive_after_days, 7);
11434        assert_eq!(parsed.memory.purge_after_days, 30);
11435        assert_eq!(parsed.memory.conversation_retention_days, 30);
11436        // provider_timeout_secs defaults to 120 when not specified
11437        assert_eq!(parsed.provider_timeout_secs, 120);
11438    }
11439
11440    /// Regression test for #4171: the `[autonomy]` section must not be
11441    /// silently dropped when parsing config TOML.
11442    #[test]
11443    async fn autonomy_section_is_not_silently_ignored() {
11444        let raw = r#"
11445default_temperature = 0.7
11446
11447[autonomy]
11448level = "full"
11449max_actions_per_hour = 99
11450auto_approve = ["file_read", "memory_recall", "http_request"]
11451"#;
11452        let parsed = parse_test_config(raw);
11453        assert_eq!(
11454            parsed.autonomy.level,
11455            AutonomyLevel::Full,
11456            "autonomy.level must be parsed from config (was silently defaulting to Supervised)"
11457        );
11458        assert_eq!(
11459            parsed.autonomy.max_actions_per_hour, 99,
11460            "autonomy.max_actions_per_hour must be parsed from config"
11461        );
11462        assert!(
11463            parsed
11464                .autonomy
11465                .auto_approve
11466                .contains(&"http_request".to_string()),
11467            "autonomy.auto_approve must include http_request from config"
11468        );
11469    }
11470
11471    /// Regression test for #4247: when a user provides a custom auto_approve
11472    /// list, the built-in defaults must still be present.
11473    #[test]
11474    async fn auto_approve_merges_user_entries_with_defaults() {
11475        let raw = r#"
11476default_temperature = 0.7
11477
11478[autonomy]
11479auto_approve = ["my_custom_tool", "another_tool"]
11480"#;
11481        let parsed = parse_test_config(raw);
11482        // User entries are preserved
11483        assert!(
11484            parsed
11485                .autonomy
11486                .auto_approve
11487                .contains(&"my_custom_tool".to_string()),
11488            "user-supplied tool must remain in auto_approve"
11489        );
11490        assert!(
11491            parsed
11492                .autonomy
11493                .auto_approve
11494                .contains(&"another_tool".to_string()),
11495            "user-supplied tool must remain in auto_approve"
11496        );
11497        // Defaults are merged in
11498        for default_tool in &[
11499            "file_read",
11500            "memory_recall",
11501            "weather",
11502            "calculator",
11503            "web_fetch",
11504        ] {
11505            assert!(
11506                parsed
11507                    .autonomy
11508                    .auto_approve
11509                    .contains(&String::from(*default_tool)),
11510                "default tool '{default_tool}' must be present in auto_approve even when user provides custom list"
11511            );
11512        }
11513    }
11514
11515    /// Regression test: empty auto_approve still gets defaults merged.
11516    #[test]
11517    async fn auto_approve_empty_list_gets_defaults() {
11518        let raw = r#"
11519default_temperature = 0.7
11520
11521[autonomy]
11522auto_approve = []
11523"#;
11524        let parsed = parse_test_config(raw);
11525        let defaults = default_auto_approve();
11526        for tool in &defaults {
11527            assert!(
11528                parsed.autonomy.auto_approve.contains(tool),
11529                "default tool '{tool}' must be present even when user sets auto_approve = []"
11530            );
11531        }
11532    }
11533
11534    /// When no autonomy section is provided, defaults are applied normally.
11535    #[test]
11536    async fn auto_approve_defaults_when_no_autonomy_section() {
11537        let raw = r#"
11538default_temperature = 0.7
11539"#;
11540        let parsed = parse_test_config(raw);
11541        let defaults = default_auto_approve();
11542        for tool in &defaults {
11543            assert!(
11544                parsed.autonomy.auto_approve.contains(tool),
11545                "default tool '{tool}' must be present when no [autonomy] section"
11546            );
11547        }
11548    }
11549
11550    /// Duplicates are not introduced when ensure_default_auto_approve runs
11551    /// on a list that already contains the defaults.
11552    #[test]
11553    async fn auto_approve_no_duplicates() {
11554        let raw = r#"
11555default_temperature = 0.7
11556
11557[autonomy]
11558auto_approve = ["weather", "file_read"]
11559"#;
11560        let parsed = parse_test_config(raw);
11561        let weather_count = parsed
11562            .autonomy
11563            .auto_approve
11564            .iter()
11565            .filter(|t| *t == "weather")
11566            .count();
11567        assert_eq!(weather_count, 1, "weather must not be duplicated");
11568        let file_read_count = parsed
11569            .autonomy
11570            .auto_approve
11571            .iter()
11572            .filter(|t| *t == "file_read")
11573            .count();
11574        assert_eq!(file_read_count, 1, "file_read must not be duplicated");
11575    }
11576
11577    #[test]
11578    async fn provider_timeout_secs_parses_from_toml() {
11579        let raw = r#"
11580default_temperature = 0.7
11581provider_timeout_secs = 300
11582"#;
11583        let parsed = parse_test_config(raw);
11584        assert_eq!(parsed.provider_timeout_secs, 300);
11585    }
11586
11587    #[test]
11588    async fn parse_extra_headers_env_basic() {
11589        let headers = parse_extra_headers_env("User-Agent:MyApp/1.0,X-Title:construct");
11590        assert_eq!(headers.len(), 2);
11591        assert_eq!(
11592            headers[0],
11593            ("User-Agent".to_string(), "MyApp/1.0".to_string())
11594        );
11595        assert_eq!(headers[1], ("X-Title".to_string(), "construct".to_string()));
11596    }
11597
11598    #[test]
11599    async fn parse_extra_headers_env_with_url_value() {
11600        let headers = parse_extra_headers_env("HTTP-Referer:https://github.com/KumihoIO/construct");
11601        assert_eq!(headers.len(), 1);
11602        // Only splits on first colon, preserving URL colons in value
11603        assert_eq!(headers[0].0, "HTTP-Referer");
11604        assert_eq!(headers[0].1, "https://github.com/KumihoIO/construct");
11605    }
11606
11607    #[test]
11608    async fn parse_extra_headers_env_empty_string() {
11609        let headers = parse_extra_headers_env("");
11610        assert!(headers.is_empty());
11611    }
11612
11613    #[test]
11614    async fn parse_extra_headers_env_whitespace_trimming() {
11615        let headers = parse_extra_headers_env("  X-Title : construct , User-Agent : cli/1.0 ");
11616        assert_eq!(headers.len(), 2);
11617        assert_eq!(headers[0], ("X-Title".to_string(), "construct".to_string()));
11618        assert_eq!(
11619            headers[1],
11620            ("User-Agent".to_string(), "cli/1.0".to_string())
11621        );
11622    }
11623
11624    #[test]
11625    async fn parse_extra_headers_env_skips_malformed() {
11626        let headers = parse_extra_headers_env("X-Valid:value,no-colon-here,Another:ok");
11627        assert_eq!(headers.len(), 2);
11628        assert_eq!(headers[0], ("X-Valid".to_string(), "value".to_string()));
11629        assert_eq!(headers[1], ("Another".to_string(), "ok".to_string()));
11630    }
11631
11632    #[test]
11633    async fn parse_extra_headers_env_skips_empty_key() {
11634        let headers = parse_extra_headers_env(":value,X-Valid:ok");
11635        assert_eq!(headers.len(), 1);
11636        assert_eq!(headers[0], ("X-Valid".to_string(), "ok".to_string()));
11637    }
11638
11639    #[test]
11640    async fn parse_extra_headers_env_allows_empty_value() {
11641        let headers = parse_extra_headers_env("X-Empty:");
11642        assert_eq!(headers.len(), 1);
11643        assert_eq!(headers[0], ("X-Empty".to_string(), String::new()));
11644    }
11645
11646    #[test]
11647    async fn parse_extra_headers_env_trailing_comma() {
11648        let headers = parse_extra_headers_env("X-Title:construct,");
11649        assert_eq!(headers.len(), 1);
11650        assert_eq!(headers[0], ("X-Title".to_string(), "construct".to_string()));
11651    }
11652
11653    #[test]
11654    async fn extra_headers_parses_from_toml() {
11655        let raw = r#"
11656default_temperature = 0.7
11657
11658[extra_headers]
11659User-Agent = "MyApp/1.0"
11660X-Title = "construct"
11661"#;
11662        let parsed = parse_test_config(raw);
11663        assert_eq!(parsed.extra_headers.len(), 2);
11664        assert_eq!(parsed.extra_headers.get("User-Agent").unwrap(), "MyApp/1.0");
11665        assert_eq!(parsed.extra_headers.get("X-Title").unwrap(), "construct");
11666    }
11667
11668    #[test]
11669    async fn extra_headers_defaults_to_empty() {
11670        let raw = r#"
11671default_temperature = 0.7
11672"#;
11673        let parsed = parse_test_config(raw);
11674        assert!(parsed.extra_headers.is_empty());
11675    }
11676
11677    #[test]
11678    async fn storage_provider_dburl_alias_deserializes() {
11679        let raw = r#"
11680default_temperature = 0.7
11681
11682[storage.provider.config]
11683provider = "qdrant"
11684dbURL = "http://localhost:6333"
11685schema = "public"
11686table = "memories"
11687connect_timeout_secs = 12
11688"#;
11689
11690        let parsed = parse_test_config(raw);
11691        assert_eq!(parsed.storage.provider.config.provider, "qdrant");
11692        assert_eq!(
11693            parsed.storage.provider.config.db_url.as_deref(),
11694            Some("http://localhost:6333")
11695        );
11696        assert_eq!(parsed.storage.provider.config.schema, "public");
11697        assert_eq!(parsed.storage.provider.config.table, "memories");
11698        assert_eq!(
11699            parsed.storage.provider.config.connect_timeout_secs,
11700            Some(12)
11701        );
11702    }
11703
11704    #[test]
11705    async fn runtime_reasoning_enabled_deserializes() {
11706        let raw = r#"
11707default_temperature = 0.7
11708
11709[runtime]
11710reasoning_enabled = false
11711"#;
11712
11713        let parsed = parse_test_config(raw);
11714        assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
11715    }
11716
11717    #[test]
11718    async fn runtime_reasoning_effort_deserializes() {
11719        let raw = r#"
11720default_temperature = 0.7
11721
11722[runtime]
11723reasoning_effort = "HIGH"
11724"#;
11725
11726        let parsed: Config = toml::from_str(raw).unwrap();
11727        assert_eq!(parsed.runtime.reasoning_effort.as_deref(), Some("high"));
11728    }
11729
11730    #[test]
11731    async fn runtime_reasoning_effort_rejects_invalid_values() {
11732        let raw = r#"
11733default_temperature = 0.7
11734
11735[runtime]
11736reasoning_effort = "turbo"
11737"#;
11738
11739        let error = toml::from_str::<Config>(raw).expect_err("invalid value should fail");
11740        assert!(error.to_string().contains("reasoning_effort"));
11741    }
11742
11743    #[test]
11744    async fn agent_config_defaults() {
11745        let cfg = AgentConfig::default();
11746        assert!(cfg.compact_context);
11747        assert_eq!(cfg.max_tool_iterations, 10);
11748        assert_eq!(cfg.max_history_messages, 50);
11749        assert!(!cfg.parallel_tools);
11750        assert_eq!(cfg.tool_dispatcher, "auto");
11751    }
11752
11753    #[test]
11754    async fn agent_config_deserializes() {
11755        let raw = r#"
11756default_temperature = 0.7
11757[agent]
11758compact_context = true
11759max_tool_iterations = 20
11760max_history_messages = 80
11761parallel_tools = true
11762tool_dispatcher = "xml"
11763"#;
11764        let parsed = parse_test_config(raw);
11765        assert!(parsed.agent.compact_context);
11766        assert_eq!(parsed.agent.max_tool_iterations, 20);
11767        assert_eq!(parsed.agent.max_history_messages, 80);
11768        assert!(parsed.agent.parallel_tools);
11769        assert_eq!(parsed.agent.tool_dispatcher, "xml");
11770    }
11771
11772    #[test]
11773    async fn pacing_config_defaults_are_all_none_or_empty() {
11774        let cfg = PacingConfig::default();
11775        assert!(cfg.step_timeout_secs.is_none());
11776        assert!(cfg.loop_detection_min_elapsed_secs.is_none());
11777        assert!(cfg.loop_ignore_tools.is_empty());
11778        assert!(cfg.message_timeout_scale_max.is_none());
11779    }
11780
11781    #[test]
11782    async fn pacing_config_deserializes_from_toml() {
11783        let raw = r#"
11784default_temperature = 0.7
11785[pacing]
11786step_timeout_secs = 120
11787loop_detection_min_elapsed_secs = 60
11788loop_ignore_tools = ["browser_screenshot", "browser_navigate"]
11789message_timeout_scale_max = 8
11790"#;
11791        let parsed: Config = toml::from_str(raw).unwrap();
11792        assert_eq!(parsed.pacing.step_timeout_secs, Some(120));
11793        assert_eq!(parsed.pacing.loop_detection_min_elapsed_secs, Some(60));
11794        assert_eq!(
11795            parsed.pacing.loop_ignore_tools,
11796            vec!["browser_screenshot", "browser_navigate"]
11797        );
11798        assert_eq!(parsed.pacing.message_timeout_scale_max, Some(8));
11799    }
11800
11801    #[test]
11802    async fn pacing_config_absent_preserves_defaults() {
11803        let raw = r#"
11804default_temperature = 0.7
11805"#;
11806        let parsed: Config = toml::from_str(raw).unwrap();
11807        assert!(parsed.pacing.step_timeout_secs.is_none());
11808        assert!(parsed.pacing.loop_detection_min_elapsed_secs.is_none());
11809        assert!(parsed.pacing.loop_ignore_tools.is_empty());
11810        assert!(parsed.pacing.message_timeout_scale_max.is_none());
11811    }
11812
11813    #[tokio::test]
11814    async fn sync_directory_handles_existing_directory() {
11815        let dir = std::env::temp_dir().join(format!(
11816            "construct_test_sync_directory_{}",
11817            uuid::Uuid::new_v4()
11818        ));
11819        fs::create_dir_all(&dir).await.unwrap();
11820
11821        sync_directory(&dir).await.unwrap();
11822
11823        let _ = fs::remove_dir_all(&dir).await;
11824    }
11825
11826    #[tokio::test]
11827    async fn config_save_and_load_tmpdir() {
11828        let dir = std::env::temp_dir().join("construct_test_config");
11829        let _ = fs::remove_dir_all(&dir).await;
11830        fs::create_dir_all(&dir).await.unwrap();
11831
11832        let config_path = dir.join("config.toml");
11833        let config = Config {
11834            workspace_dir: dir.join("workspace"),
11835            config_path: config_path.clone(),
11836            api_key: Some("sk-roundtrip".into()),
11837            api_url: None,
11838            api_path: None,
11839            default_provider: Some("openrouter".into()),
11840            default_model: Some("test-model".into()),
11841            model_providers: HashMap::new(),
11842            default_temperature: 0.9,
11843            provider_timeout_secs: 120,
11844            provider_max_tokens: None,
11845            extra_headers: HashMap::new(),
11846            observability: ObservabilityConfig::default(),
11847            autonomy: AutonomyConfig::default(),
11848            trust: crate::trust::TrustConfig::default(),
11849            backup: BackupConfig::default(),
11850            data_retention: DataRetentionConfig::default(),
11851            cloud_ops: CloudOpsConfig::default(),
11852            conversational_ai: ConversationalAiConfig::default(),
11853            security: SecurityConfig::default(),
11854            security_ops: SecurityOpsConfig::default(),
11855            runtime: RuntimeConfig::default(),
11856            reliability: ReliabilityConfig::default(),
11857            scheduler: SchedulerConfig::default(),
11858            skills: SkillsConfig::default(),
11859            pipeline: PipelineConfig::default(),
11860            model_routes: Vec::new(),
11861            embedding_routes: Vec::new(),
11862            query_classification: QueryClassificationConfig::default(),
11863            heartbeat: HeartbeatConfig::default(),
11864            cron: CronConfig::default(),
11865            channels_config: ChannelsConfig::default(),
11866            memory: MemoryConfig::default(),
11867            storage: StorageConfig::default(),
11868            tunnel: TunnelConfig::default(),
11869            gateway: GatewayConfig::default(),
11870            composio: ComposioConfig::default(),
11871            microsoft365: Microsoft365Config::default(),
11872            secrets: SecretsConfig::default(),
11873            browser: BrowserConfig::default(),
11874            browser_delegate: crate::tools::browser_delegate::BrowserDelegateConfig::default(),
11875            http_request: HttpRequestConfig::default(),
11876            multimodal: MultimodalConfig::default(),
11877            media_pipeline: MediaPipelineConfig::default(),
11878            web_fetch: WebFetchConfig::default(),
11879            link_enricher: LinkEnricherConfig::default(),
11880            text_browser: TextBrowserConfig::default(),
11881            web_search: WebSearchConfig::default(),
11882            project_intel: ProjectIntelConfig::default(),
11883            google_workspace: GoogleWorkspaceConfig::default(),
11884            proxy: ProxyConfig::default(),
11885            agent: AgentConfig::default(),
11886            pacing: PacingConfig::default(),
11887            identity: IdentityConfig::default(),
11888            cost: CostConfig::default(),
11889            peripherals: PeripheralsConfig::default(),
11890            delegate: DelegateToolConfig::default(),
11891            agents: HashMap::new(),
11892            swarms: HashMap::new(),
11893            hooks: HooksConfig::default(),
11894            hardware: HardwareConfig::default(),
11895            transcription: TranscriptionConfig::default(),
11896            tts: TtsConfig::default(),
11897            mcp: McpConfig::default(),
11898            kumiho: KumihoConfig::default(),
11899            operator: OperatorConfig::default(),
11900            nodes: NodesConfig::default(),
11901            clawhub: ClawHubConfig::default(),
11902            workspace: WorkspaceConfig::default(),
11903            notion: NotionConfig::default(),
11904            jira: JiraConfig::default(),
11905            node_transport: NodeTransportConfig::default(),
11906            linkedin: LinkedInConfig::default(),
11907            image_gen: ImageGenConfig::default(),
11908            plugins: PluginsConfig::default(),
11909            locale: None,
11910            verifiable_intent: VerifiableIntentConfig::default(),
11911            claude_code: ClaudeCodeConfig::default(),
11912            claude_code_runner: ClaudeCodeRunnerConfig::default(),
11913            codex_cli: CodexCliConfig::default(),
11914            gemini_cli: GeminiCliConfig::default(),
11915            opencode_cli: OpenCodeCliConfig::default(),
11916            sop: SopConfig::default(),
11917            shell_tool: ShellToolConfig::default(),
11918        };
11919
11920        config.save().await.unwrap();
11921        assert!(config_path.exists());
11922
11923        let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
11924        let loaded: Config = toml::from_str(&contents).unwrap();
11925        assert!(
11926            loaded
11927                .api_key
11928                .as_deref()
11929                .is_some_and(crate::security::SecretStore::is_encrypted)
11930        );
11931        let store = crate::security::SecretStore::new(&dir, true);
11932        let decrypted = store.decrypt(loaded.api_key.as_deref().unwrap()).unwrap();
11933        assert_eq!(decrypted, "sk-roundtrip");
11934        assert_eq!(loaded.default_model.as_deref(), Some("test-model"));
11935        assert!((loaded.default_temperature - 0.9).abs() < f64::EPSILON);
11936
11937        let _ = fs::remove_dir_all(&dir).await;
11938    }
11939
11940    #[tokio::test]
11941    async fn config_save_encrypts_nested_credentials() {
11942        let dir = std::env::temp_dir().join(format!(
11943            "construct_test_nested_credentials_{}",
11944            uuid::Uuid::new_v4()
11945        ));
11946        fs::create_dir_all(&dir).await.unwrap();
11947
11948        let mut config = Config::default();
11949        config.workspace_dir = dir.join("workspace");
11950        config.config_path = dir.join("config.toml");
11951        config.api_key = Some("root-credential".into());
11952        config.composio.api_key = Some("composio-credential".into());
11953        config.browser.computer_use.api_key = Some("browser-credential".into());
11954        config.web_search.brave_api_key = Some("brave-credential".into());
11955        config.storage.provider.config.db_url = Some("postgres://user:pw@host/db".into());
11956        config.channels_config.feishu = Some(FeishuConfig {
11957            app_id: "cli_feishu_123".into(),
11958            app_secret: "feishu-secret".into(),
11959            encrypt_key: Some("feishu-encrypt".into()),
11960            verification_token: Some("feishu-verify".into()),
11961            allowed_users: vec!["*".into()],
11962            receive_mode: LarkReceiveMode::Websocket,
11963            port: None,
11964            proxy_url: None,
11965        });
11966
11967        config.agents.insert(
11968            "worker".into(),
11969            DelegateAgentConfig {
11970                provider: "openrouter".into(),
11971                model: "model-test".into(),
11972                system_prompt: None,
11973                api_key: Some("agent-credential".into()),
11974                temperature: None,
11975                max_depth: 3,
11976                agentic: false,
11977                allowed_tools: Vec::new(),
11978                max_iterations: 10,
11979                timeout_secs: None,
11980                agentic_timeout_secs: None,
11981                skills_directory: None,
11982            },
11983        );
11984
11985        config.save().await.unwrap();
11986
11987        let contents = tokio::fs::read_to_string(config.config_path.clone())
11988            .await
11989            .unwrap();
11990        let stored: Config = toml::from_str(&contents).unwrap();
11991        let store = crate::security::SecretStore::new(&dir, true);
11992
11993        let root_encrypted = stored.api_key.as_deref().unwrap();
11994        assert!(crate::security::SecretStore::is_encrypted(root_encrypted));
11995        assert_eq!(store.decrypt(root_encrypted).unwrap(), "root-credential");
11996
11997        let composio_encrypted = stored.composio.api_key.as_deref().unwrap();
11998        assert!(crate::security::SecretStore::is_encrypted(
11999            composio_encrypted
12000        ));
12001        assert_eq!(
12002            store.decrypt(composio_encrypted).unwrap(),
12003            "composio-credential"
12004        );
12005
12006        let browser_encrypted = stored.browser.computer_use.api_key.as_deref().unwrap();
12007        assert!(crate::security::SecretStore::is_encrypted(
12008            browser_encrypted
12009        ));
12010        assert_eq!(
12011            store.decrypt(browser_encrypted).unwrap(),
12012            "browser-credential"
12013        );
12014
12015        let web_search_encrypted = stored.web_search.brave_api_key.as_deref().unwrap();
12016        assert!(crate::security::SecretStore::is_encrypted(
12017            web_search_encrypted
12018        ));
12019        assert_eq!(
12020            store.decrypt(web_search_encrypted).unwrap(),
12021            "brave-credential"
12022        );
12023
12024        let worker = stored.agents.get("worker").unwrap();
12025        let worker_encrypted = worker.api_key.as_deref().unwrap();
12026        assert!(crate::security::SecretStore::is_encrypted(worker_encrypted));
12027        assert_eq!(store.decrypt(worker_encrypted).unwrap(), "agent-credential");
12028
12029        let storage_db_url = stored.storage.provider.config.db_url.as_deref().unwrap();
12030        assert!(crate::security::SecretStore::is_encrypted(storage_db_url));
12031        assert_eq!(
12032            store.decrypt(storage_db_url).unwrap(),
12033            "postgres://user:pw@host/db"
12034        );
12035
12036        let feishu = stored.channels_config.feishu.as_ref().unwrap();
12037        assert!(crate::security::SecretStore::is_encrypted(
12038            &feishu.app_secret
12039        ));
12040        assert_eq!(store.decrypt(&feishu.app_secret).unwrap(), "feishu-secret");
12041        assert!(
12042            feishu
12043                .encrypt_key
12044                .as_deref()
12045                .is_some_and(crate::security::SecretStore::is_encrypted)
12046        );
12047        assert_eq!(
12048            store
12049                .decrypt(feishu.encrypt_key.as_deref().unwrap())
12050                .unwrap(),
12051            "feishu-encrypt"
12052        );
12053        assert!(
12054            feishu
12055                .verification_token
12056                .as_deref()
12057                .is_some_and(crate::security::SecretStore::is_encrypted)
12058        );
12059        assert_eq!(
12060            store
12061                .decrypt(feishu.verification_token.as_deref().unwrap())
12062                .unwrap(),
12063            "feishu-verify"
12064        );
12065
12066        let _ = fs::remove_dir_all(&dir).await;
12067    }
12068
12069    #[tokio::test]
12070    async fn config_save_atomic_cleanup() {
12071        let dir =
12072            std::env::temp_dir().join(format!("construct_test_config_{}", uuid::Uuid::new_v4()));
12073        fs::create_dir_all(&dir).await.unwrap();
12074
12075        let config_path = dir.join("config.toml");
12076        let mut config = Config::default();
12077        config.workspace_dir = dir.join("workspace");
12078        config.config_path = config_path.clone();
12079        config.default_model = Some("model-a".into());
12080        config.save().await.unwrap();
12081        assert!(config_path.exists());
12082
12083        config.default_model = Some("model-b".into());
12084        config.save().await.unwrap();
12085
12086        let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
12087        assert!(contents.contains("model-b"));
12088
12089        let names: Vec<String> = ReadDirStream::new(fs::read_dir(&dir).await.unwrap())
12090            .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
12091            .collect()
12092            .await;
12093        assert!(!names.iter().any(|name| name.contains(".tmp-")));
12094        assert!(!names.iter().any(|name| name.ends_with(".bak")));
12095
12096        let _ = fs::remove_dir_all(&dir).await;
12097    }
12098
12099    // ── Telegram / Discord config ────────────────────────────
12100
12101    #[test]
12102    async fn telegram_config_serde() {
12103        let tc = TelegramConfig {
12104            bot_token: "123:XYZ".into(),
12105            allowed_users: vec!["alice".into(), "bob".into()],
12106            stream_mode: StreamMode::Partial,
12107            draft_update_interval_ms: 500,
12108            interrupt_on_new_message: true,
12109            mention_only: false,
12110            ack_reactions: None,
12111            proxy_url: None,
12112            notification_chat_id: None,
12113        };
12114        let json = serde_json::to_string(&tc).unwrap();
12115        let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
12116        assert_eq!(parsed.bot_token, "123:XYZ");
12117        assert_eq!(parsed.allowed_users.len(), 2);
12118        assert_eq!(parsed.stream_mode, StreamMode::Partial);
12119        assert_eq!(parsed.draft_update_interval_ms, 500);
12120        assert!(parsed.interrupt_on_new_message);
12121    }
12122
12123    #[test]
12124    async fn telegram_config_defaults_stream_off() {
12125        let json = r#"{"bot_token":"tok","allowed_users":[]}"#;
12126        let parsed: TelegramConfig = serde_json::from_str(json).unwrap();
12127        assert_eq!(parsed.stream_mode, StreamMode::Off);
12128        assert_eq!(parsed.draft_update_interval_ms, 1000);
12129        assert!(!parsed.interrupt_on_new_message);
12130    }
12131
12132    #[test]
12133    async fn discord_config_serde() {
12134        let dc = DiscordConfig {
12135            bot_token: "discord-token".into(),
12136            guild_id: Some("12345".into()),
12137            allowed_users: vec![],
12138            listen_to_bots: false,
12139            interrupt_on_new_message: false,
12140            mention_only: false,
12141            proxy_url: None,
12142            stream_mode: StreamMode::default(),
12143            draft_update_interval_ms: 1000,
12144            multi_message_delay_ms: 800,
12145            notification_channel_id: None,
12146        };
12147        let json = serde_json::to_string(&dc).unwrap();
12148        let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
12149        assert_eq!(parsed.bot_token, "discord-token");
12150        assert_eq!(parsed.guild_id.as_deref(), Some("12345"));
12151    }
12152
12153    #[test]
12154    async fn discord_config_optional_guild() {
12155        let dc = DiscordConfig {
12156            bot_token: "tok".into(),
12157            guild_id: None,
12158            allowed_users: vec![],
12159            listen_to_bots: false,
12160            interrupt_on_new_message: false,
12161            mention_only: false,
12162            proxy_url: None,
12163            stream_mode: StreamMode::default(),
12164            draft_update_interval_ms: 1000,
12165            multi_message_delay_ms: 800,
12166            notification_channel_id: None,
12167        };
12168        let json = serde_json::to_string(&dc).unwrap();
12169        let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
12170        assert!(parsed.guild_id.is_none());
12171    }
12172
12173    // ── iMessage / Matrix config ────────────────────────────
12174
12175    #[test]
12176    async fn imessage_config_serde() {
12177        let ic = IMessageConfig {
12178            allowed_contacts: vec!["+1234567890".into(), "user@icloud.com".into()],
12179        };
12180        let json = serde_json::to_string(&ic).unwrap();
12181        let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
12182        assert_eq!(parsed.allowed_contacts.len(), 2);
12183        assert_eq!(parsed.allowed_contacts[0], "+1234567890");
12184    }
12185
12186    #[test]
12187    async fn imessage_config_empty_contacts() {
12188        let ic = IMessageConfig {
12189            allowed_contacts: vec![],
12190        };
12191        let json = serde_json::to_string(&ic).unwrap();
12192        let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
12193        assert!(parsed.allowed_contacts.is_empty());
12194    }
12195
12196    #[test]
12197    async fn imessage_config_wildcard() {
12198        let ic = IMessageConfig {
12199            allowed_contacts: vec!["*".into()],
12200        };
12201        let toml_str = toml::to_string(&ic).unwrap();
12202        let parsed: IMessageConfig = toml::from_str(&toml_str).unwrap();
12203        assert_eq!(parsed.allowed_contacts, vec!["*"]);
12204    }
12205
12206    #[test]
12207    async fn matrix_config_serde() {
12208        let mc = MatrixConfig {
12209            homeserver: "https://matrix.org".into(),
12210            access_token: "syt_token_abc".into(),
12211            user_id: Some("@bot:matrix.org".into()),
12212            device_id: Some("DEVICE123".into()),
12213            room_id: "!room123:matrix.org".into(),
12214            allowed_users: vec!["@user:matrix.org".into()],
12215            allowed_rooms: vec![],
12216            interrupt_on_new_message: false,
12217            stream_mode: StreamMode::default(),
12218            draft_update_interval_ms: 1500,
12219            multi_message_delay_ms: 800,
12220            recovery_key: None,
12221        };
12222        let json = serde_json::to_string(&mc).unwrap();
12223        let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
12224        assert_eq!(parsed.homeserver, "https://matrix.org");
12225        assert_eq!(parsed.access_token, "syt_token_abc");
12226        assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org"));
12227        assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123"));
12228        assert_eq!(parsed.room_id, "!room123:matrix.org");
12229        assert_eq!(parsed.allowed_users.len(), 1);
12230    }
12231
12232    #[test]
12233    async fn matrix_config_toml_roundtrip() {
12234        let mc = MatrixConfig {
12235            homeserver: "https://synapse.local:8448".into(),
12236            access_token: "tok".into(),
12237            user_id: None,
12238            device_id: None,
12239            room_id: "!abc:synapse.local".into(),
12240            allowed_users: vec!["@admin:synapse.local".into(), "*".into()],
12241            allowed_rooms: vec![],
12242            interrupt_on_new_message: false,
12243            stream_mode: StreamMode::default(),
12244            draft_update_interval_ms: 1500,
12245            multi_message_delay_ms: 800,
12246            recovery_key: None,
12247        };
12248        let toml_str = toml::to_string(&mc).unwrap();
12249        let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
12250        assert_eq!(parsed.homeserver, "https://synapse.local:8448");
12251        assert_eq!(parsed.allowed_users.len(), 2);
12252    }
12253
12254    #[test]
12255    async fn matrix_config_backward_compatible_without_session_hints() {
12256        let toml = r#"
12257homeserver = "https://matrix.org"
12258access_token = "tok"
12259room_id = "!ops:matrix.org"
12260allowed_users = ["@ops:matrix.org"]
12261"#;
12262
12263        let parsed: MatrixConfig = toml::from_str(toml).unwrap();
12264        assert_eq!(parsed.homeserver, "https://matrix.org");
12265        assert!(parsed.user_id.is_none());
12266        assert!(parsed.device_id.is_none());
12267    }
12268
12269    #[test]
12270    async fn signal_config_serde() {
12271        let sc = SignalConfig {
12272            http_url: "http://127.0.0.1:8686".into(),
12273            account: "+1234567890".into(),
12274            group_id: Some("group123".into()),
12275            allowed_from: vec!["+1111111111".into()],
12276            ignore_attachments: true,
12277            ignore_stories: false,
12278            proxy_url: None,
12279        };
12280        let json = serde_json::to_string(&sc).unwrap();
12281        let parsed: SignalConfig = serde_json::from_str(&json).unwrap();
12282        assert_eq!(parsed.http_url, "http://127.0.0.1:8686");
12283        assert_eq!(parsed.account, "+1234567890");
12284        assert_eq!(parsed.group_id.as_deref(), Some("group123"));
12285        assert_eq!(parsed.allowed_from.len(), 1);
12286        assert!(parsed.ignore_attachments);
12287        assert!(!parsed.ignore_stories);
12288    }
12289
12290    #[test]
12291    async fn signal_config_toml_roundtrip() {
12292        let sc = SignalConfig {
12293            http_url: "http://localhost:8080".into(),
12294            account: "+9876543210".into(),
12295            group_id: None,
12296            allowed_from: vec!["*".into()],
12297            ignore_attachments: false,
12298            ignore_stories: true,
12299            proxy_url: None,
12300        };
12301        let toml_str = toml::to_string(&sc).unwrap();
12302        let parsed: SignalConfig = toml::from_str(&toml_str).unwrap();
12303        assert_eq!(parsed.http_url, "http://localhost:8080");
12304        assert_eq!(parsed.account, "+9876543210");
12305        assert!(parsed.group_id.is_none());
12306        assert!(parsed.ignore_stories);
12307    }
12308
12309    #[test]
12310    async fn signal_config_defaults() {
12311        let json = r#"{"http_url":"http://127.0.0.1:8686","account":"+1234567890"}"#;
12312        let parsed: SignalConfig = serde_json::from_str(json).unwrap();
12313        assert!(parsed.group_id.is_none());
12314        assert!(parsed.allowed_from.is_empty());
12315        assert!(!parsed.ignore_attachments);
12316        assert!(!parsed.ignore_stories);
12317    }
12318
12319    #[test]
12320    async fn channels_config_with_imessage_and_matrix() {
12321        let c = ChannelsConfig {
12322            cli: true,
12323            telegram: None,
12324            discord: None,
12325            discord_history: None,
12326            slack: None,
12327            mattermost: None,
12328            webhook: None,
12329            imessage: Some(IMessageConfig {
12330                allowed_contacts: vec!["+1".into()],
12331            }),
12332            matrix: Some(MatrixConfig {
12333                homeserver: "https://m.org".into(),
12334                access_token: "tok".into(),
12335                user_id: None,
12336                device_id: None,
12337                room_id: "!r:m".into(),
12338                allowed_users: vec!["@u:m".into()],
12339                allowed_rooms: vec![],
12340                interrupt_on_new_message: false,
12341                stream_mode: StreamMode::default(),
12342                draft_update_interval_ms: 1500,
12343                multi_message_delay_ms: 800,
12344                recovery_key: None,
12345            }),
12346            signal: None,
12347            whatsapp: None,
12348            linq: None,
12349            wati: None,
12350            nextcloud_talk: None,
12351            email: None,
12352            gmail_push: None,
12353            irc: None,
12354            lark: None,
12355            feishu: None,
12356            dingtalk: None,
12357            wecom: None,
12358            qq: None,
12359            twitter: None,
12360            mochat: None,
12361            #[cfg(feature = "channel-nostr")]
12362            nostr: None,
12363            clawdtalk: None,
12364            reddit: None,
12365            bluesky: None,
12366            voice_call: None,
12367            #[cfg(feature = "voice-wake")]
12368            voice_wake: None,
12369            message_timeout_secs: 300,
12370            ack_reactions: true,
12371            show_tool_calls: true,
12372            session_persistence: true,
12373            session_backend: default_session_backend(),
12374            session_ttl_hours: 0,
12375            debounce_ms: 0,
12376        };
12377        let toml_str = toml::to_string_pretty(&c).unwrap();
12378        let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
12379        assert!(parsed.imessage.is_some());
12380        assert!(parsed.matrix.is_some());
12381        assert_eq!(parsed.imessage.unwrap().allowed_contacts, vec!["+1"]);
12382        assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org");
12383    }
12384
12385    #[test]
12386    async fn channels_config_default_has_no_imessage_matrix() {
12387        let c = ChannelsConfig::default();
12388        assert!(c.imessage.is_none());
12389        assert!(c.matrix.is_none());
12390    }
12391
12392    // ── Edge cases: serde(default) for allowed_users ─────────
12393
12394    #[test]
12395    async fn discord_config_deserializes_without_allowed_users() {
12396        // Old configs won't have allowed_users — serde(default) should fill vec![]
12397        let json = r#"{"bot_token":"tok","guild_id":"123"}"#;
12398        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12399        assert!(parsed.allowed_users.is_empty());
12400    }
12401
12402    #[test]
12403    async fn discord_config_deserializes_with_allowed_users() {
12404        let json = r#"{"bot_token":"tok","guild_id":"123","allowed_users":["111","222"]}"#;
12405        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12406        assert_eq!(parsed.allowed_users, vec!["111", "222"]);
12407    }
12408
12409    #[test]
12410    async fn slack_config_deserializes_without_allowed_users() {
12411        let json = r#"{"bot_token":"xoxb-tok"}"#;
12412        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12413        assert!(parsed.channel_ids.is_empty());
12414        assert!(parsed.allowed_users.is_empty());
12415        assert!(!parsed.interrupt_on_new_message);
12416        assert_eq!(parsed.thread_replies, None);
12417        assert!(!parsed.mention_only);
12418    }
12419
12420    #[test]
12421    async fn slack_config_deserializes_with_allowed_users() {
12422        let json = r#"{"bot_token":"xoxb-tok","allowed_users":["U111"]}"#;
12423        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12424        assert!(parsed.channel_ids.is_empty());
12425        assert_eq!(parsed.allowed_users, vec!["U111"]);
12426        assert!(!parsed.interrupt_on_new_message);
12427        assert_eq!(parsed.thread_replies, None);
12428        assert!(!parsed.mention_only);
12429    }
12430
12431    #[test]
12432    async fn slack_config_deserializes_with_channel_ids() {
12433        let json = r#"{"bot_token":"xoxb-tok","channel_ids":["C111","D222"]}"#;
12434        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12435        assert_eq!(parsed.channel_ids, vec!["C111", "D222"]);
12436        assert!(parsed.allowed_users.is_empty());
12437        assert!(!parsed.interrupt_on_new_message);
12438        assert_eq!(parsed.thread_replies, None);
12439        assert!(!parsed.mention_only);
12440    }
12441
12442    #[test]
12443    async fn slack_config_deserializes_with_mention_only() {
12444        let json = r#"{"bot_token":"xoxb-tok","mention_only":true}"#;
12445        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12446        assert!(parsed.mention_only);
12447        assert!(!parsed.interrupt_on_new_message);
12448        assert_eq!(parsed.thread_replies, None);
12449    }
12450
12451    #[test]
12452    async fn slack_config_deserializes_interrupt_on_new_message() {
12453        let json = r#"{"bot_token":"xoxb-tok","interrupt_on_new_message":true}"#;
12454        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12455        assert!(parsed.interrupt_on_new_message);
12456        assert_eq!(parsed.thread_replies, None);
12457        assert!(!parsed.mention_only);
12458    }
12459
12460    #[test]
12461    async fn slack_config_deserializes_thread_replies() {
12462        let json = r#"{"bot_token":"xoxb-tok","thread_replies":false}"#;
12463        let parsed: SlackConfig = serde_json::from_str(json).unwrap();
12464        assert_eq!(parsed.thread_replies, Some(false));
12465        assert!(!parsed.interrupt_on_new_message);
12466        assert!(!parsed.mention_only);
12467    }
12468
12469    #[test]
12470    async fn discord_config_default_interrupt_on_new_message_is_false() {
12471        let json = r#"{"bot_token":"tok"}"#;
12472        let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
12473        assert!(!parsed.interrupt_on_new_message);
12474    }
12475
12476    #[test]
12477    async fn discord_config_deserializes_interrupt_on_new_message_true() {
12478        let json = r#"{"bot_token":"tok","interrupt_on_new_message":true}"#;
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_toml_backward_compat() {
12485        let toml_str = r#"
12486bot_token = "tok"
12487guild_id = "123"
12488"#;
12489        let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
12490        assert!(parsed.allowed_users.is_empty());
12491        assert_eq!(parsed.bot_token, "tok");
12492    }
12493
12494    #[test]
12495    async fn slack_config_toml_backward_compat() {
12496        let toml_str = r#"
12497bot_token = "xoxb-tok"
12498channel_id = "C123"
12499"#;
12500        let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
12501        assert!(parsed.channel_ids.is_empty());
12502        assert!(parsed.allowed_users.is_empty());
12503        assert!(!parsed.interrupt_on_new_message);
12504        assert_eq!(parsed.thread_replies, None);
12505        assert!(!parsed.mention_only);
12506        assert_eq!(parsed.channel_id.as_deref(), Some("C123"));
12507    }
12508
12509    #[test]
12510    async fn slack_config_toml_accepts_channel_ids() {
12511        let toml_str = r#"
12512bot_token = "xoxb-tok"
12513channel_ids = ["C123", "D456"]
12514"#;
12515        let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
12516        assert_eq!(parsed.channel_ids, vec!["C123", "D456"]);
12517        assert!(parsed.allowed_users.is_empty());
12518        assert!(!parsed.interrupt_on_new_message);
12519        assert_eq!(parsed.thread_replies, None);
12520        assert!(!parsed.mention_only);
12521        assert!(parsed.channel_id.is_none());
12522    }
12523
12524    #[test]
12525    async fn mattermost_config_default_interrupt_on_new_message_is_false() {
12526        let json = r#"{"url":"https://mm.example.com","bot_token":"tok"}"#;
12527        let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
12528        assert!(!parsed.interrupt_on_new_message);
12529    }
12530
12531    #[test]
12532    async fn mattermost_config_deserializes_interrupt_on_new_message_true() {
12533        let json =
12534            r#"{"url":"https://mm.example.com","bot_token":"tok","interrupt_on_new_message":true}"#;
12535        let parsed: MattermostConfig = serde_json::from_str(json).unwrap();
12536        assert!(parsed.interrupt_on_new_message);
12537    }
12538
12539    #[test]
12540    async fn webhook_config_with_secret() {
12541        let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
12542        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
12543        assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
12544    }
12545
12546    #[test]
12547    async fn webhook_config_without_secret() {
12548        let json = r#"{"port":8080}"#;
12549        let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
12550        assert!(parsed.secret.is_none());
12551        assert_eq!(parsed.port, 8080);
12552    }
12553
12554    // ── WhatsApp config ──────────────────────────────────────
12555
12556    #[test]
12557    async fn whatsapp_config_serde() {
12558        let wc = WhatsAppConfig {
12559            access_token: Some("EAABx...".into()),
12560            phone_number_id: Some("123456789".into()),
12561            verify_token: Some("my-verify-token".into()),
12562            app_secret: None,
12563            session_path: None,
12564            pair_phone: None,
12565            pair_code: None,
12566            allowed_numbers: vec!["+1234567890".into(), "+9876543210".into()],
12567            mode: WhatsAppWebMode::default(),
12568            dm_policy: WhatsAppChatPolicy::default(),
12569            group_policy: WhatsAppChatPolicy::default(),
12570            self_chat_mode: false,
12571            dm_mention_patterns: vec![],
12572            group_mention_patterns: vec![],
12573            proxy_url: None,
12574        };
12575        let json = serde_json::to_string(&wc).unwrap();
12576        let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
12577        assert_eq!(parsed.access_token, Some("EAABx...".into()));
12578        assert_eq!(parsed.phone_number_id, Some("123456789".into()));
12579        assert_eq!(parsed.verify_token, Some("my-verify-token".into()));
12580        assert_eq!(parsed.allowed_numbers.len(), 2);
12581    }
12582
12583    #[test]
12584    async fn whatsapp_config_toml_roundtrip() {
12585        let wc = WhatsAppConfig {
12586            access_token: Some("tok".into()),
12587            phone_number_id: Some("12345".into()),
12588            verify_token: Some("verify".into()),
12589            app_secret: Some("secret123".into()),
12590            session_path: None,
12591            pair_phone: None,
12592            pair_code: None,
12593            allowed_numbers: vec!["+1".into()],
12594            mode: WhatsAppWebMode::default(),
12595            dm_policy: WhatsAppChatPolicy::default(),
12596            group_policy: WhatsAppChatPolicy::default(),
12597            self_chat_mode: false,
12598            dm_mention_patterns: vec![],
12599            group_mention_patterns: vec![],
12600            proxy_url: None,
12601        };
12602        let toml_str = toml::to_string(&wc).unwrap();
12603        let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
12604        assert_eq!(parsed.phone_number_id, Some("12345".into()));
12605        assert_eq!(parsed.allowed_numbers, vec!["+1"]);
12606    }
12607
12608    #[test]
12609    async fn whatsapp_config_deserializes_without_allowed_numbers() {
12610        let json = r#"{"access_token":"tok","phone_number_id":"123","verify_token":"ver"}"#;
12611        let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
12612        assert!(parsed.allowed_numbers.is_empty());
12613    }
12614
12615    #[test]
12616    async fn whatsapp_config_wildcard_allowed() {
12617        let wc = WhatsAppConfig {
12618            access_token: Some("tok".into()),
12619            phone_number_id: Some("123".into()),
12620            verify_token: Some("ver".into()),
12621            app_secret: None,
12622            session_path: None,
12623            pair_phone: None,
12624            pair_code: None,
12625            allowed_numbers: vec!["*".into()],
12626            mode: WhatsAppWebMode::default(),
12627            dm_policy: WhatsAppChatPolicy::default(),
12628            group_policy: WhatsAppChatPolicy::default(),
12629            self_chat_mode: false,
12630            dm_mention_patterns: vec![],
12631            group_mention_patterns: vec![],
12632            proxy_url: None,
12633        };
12634        let toml_str = toml::to_string(&wc).unwrap();
12635        let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
12636        assert_eq!(parsed.allowed_numbers, vec!["*"]);
12637    }
12638
12639    #[test]
12640    async fn whatsapp_config_backend_type_cloud_precedence_when_ambiguous() {
12641        let wc = WhatsAppConfig {
12642            access_token: Some("tok".into()),
12643            phone_number_id: Some("123".into()),
12644            verify_token: Some("ver".into()),
12645            app_secret: None,
12646            session_path: Some("~/.construct/state/whatsapp-web/session.db".into()),
12647            pair_phone: None,
12648            pair_code: None,
12649            allowed_numbers: vec!["+1".into()],
12650            mode: WhatsAppWebMode::default(),
12651            dm_policy: WhatsAppChatPolicy::default(),
12652            group_policy: WhatsAppChatPolicy::default(),
12653            self_chat_mode: false,
12654            dm_mention_patterns: vec![],
12655            group_mention_patterns: vec![],
12656            proxy_url: None,
12657        };
12658        assert!(wc.is_ambiguous_config());
12659        assert_eq!(wc.backend_type(), "cloud");
12660    }
12661
12662    #[test]
12663    async fn whatsapp_config_backend_type_web() {
12664        let wc = WhatsAppConfig {
12665            access_token: None,
12666            phone_number_id: None,
12667            verify_token: None,
12668            app_secret: None,
12669            session_path: Some("~/.construct/state/whatsapp-web/session.db".into()),
12670            pair_phone: None,
12671            pair_code: None,
12672            allowed_numbers: vec![],
12673            mode: WhatsAppWebMode::default(),
12674            dm_policy: WhatsAppChatPolicy::default(),
12675            group_policy: WhatsAppChatPolicy::default(),
12676            self_chat_mode: false,
12677            dm_mention_patterns: vec![],
12678            group_mention_patterns: vec![],
12679            proxy_url: None,
12680        };
12681        assert!(!wc.is_ambiguous_config());
12682        assert_eq!(wc.backend_type(), "web");
12683    }
12684
12685    #[test]
12686    async fn channels_config_with_whatsapp() {
12687        let c = ChannelsConfig {
12688            cli: true,
12689            telegram: None,
12690            discord: None,
12691            discord_history: None,
12692            slack: None,
12693            mattermost: None,
12694            webhook: None,
12695            imessage: None,
12696            matrix: None,
12697            signal: None,
12698            whatsapp: Some(WhatsAppConfig {
12699                access_token: Some("tok".into()),
12700                phone_number_id: Some("123".into()),
12701                verify_token: Some("ver".into()),
12702                app_secret: None,
12703                session_path: None,
12704                pair_phone: None,
12705                pair_code: None,
12706                allowed_numbers: vec!["+1".into()],
12707                mode: WhatsAppWebMode::default(),
12708                dm_policy: WhatsAppChatPolicy::default(),
12709                group_policy: WhatsAppChatPolicy::default(),
12710                self_chat_mode: false,
12711                dm_mention_patterns: vec![],
12712                group_mention_patterns: vec![],
12713                proxy_url: None,
12714            }),
12715            linq: None,
12716            wati: None,
12717            nextcloud_talk: None,
12718            email: None,
12719            gmail_push: None,
12720            irc: None,
12721            lark: None,
12722            feishu: None,
12723            dingtalk: None,
12724            wecom: None,
12725            qq: None,
12726            twitter: None,
12727            mochat: None,
12728            #[cfg(feature = "channel-nostr")]
12729            nostr: None,
12730            clawdtalk: None,
12731            reddit: None,
12732            bluesky: None,
12733            voice_call: None,
12734            #[cfg(feature = "voice-wake")]
12735            voice_wake: None,
12736            message_timeout_secs: 300,
12737            ack_reactions: true,
12738            show_tool_calls: true,
12739            session_persistence: true,
12740            session_backend: default_session_backend(),
12741            session_ttl_hours: 0,
12742            debounce_ms: 0,
12743        };
12744        let toml_str = toml::to_string_pretty(&c).unwrap();
12745        let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
12746        assert!(parsed.whatsapp.is_some());
12747        let wa = parsed.whatsapp.unwrap();
12748        assert_eq!(wa.phone_number_id, Some("123".into()));
12749        assert_eq!(wa.allowed_numbers, vec!["+1"]);
12750    }
12751
12752    #[test]
12753    async fn channels_config_default_has_no_whatsapp() {
12754        let c = ChannelsConfig::default();
12755        assert!(c.whatsapp.is_none());
12756    }
12757
12758    #[test]
12759    async fn channels_config_default_has_no_nextcloud_talk() {
12760        let c = ChannelsConfig::default();
12761        assert!(c.nextcloud_talk.is_none());
12762    }
12763
12764    // ══════════════════════════════════════════════════════════
12765    // SECURITY CHECKLIST TESTS — Gateway config
12766    // ══════════════════════════════════════════════════════════
12767
12768    #[test]
12769    async fn checklist_gateway_default_requires_pairing() {
12770        let g = GatewayConfig::default();
12771        assert!(g.require_pairing, "Pairing must be required by default");
12772    }
12773
12774    #[test]
12775    async fn checklist_gateway_default_blocks_public_bind() {
12776        let g = GatewayConfig::default();
12777        assert!(
12778            !g.allow_public_bind,
12779            "Public bind must be blocked by default"
12780        );
12781    }
12782
12783    #[test]
12784    async fn checklist_gateway_default_no_tokens() {
12785        let g = GatewayConfig::default();
12786        assert!(
12787            g.paired_tokens.is_empty(),
12788            "No pre-paired tokens by default"
12789        );
12790        assert_eq!(g.pair_rate_limit_per_minute, 10);
12791        assert_eq!(g.webhook_rate_limit_per_minute, 60);
12792        assert!(!g.trust_forwarded_headers);
12793        assert_eq!(g.rate_limit_max_keys, 10_000);
12794        assert_eq!(g.idempotency_ttl_secs, 300);
12795        assert_eq!(g.idempotency_max_keys, 10_000);
12796    }
12797
12798    #[test]
12799    async fn checklist_gateway_cli_default_host_is_localhost() {
12800        // The CLI default for --host is 127.0.0.1 (checked in main.rs)
12801        // Here we verify the config default matches
12802        let c = Config::default();
12803        assert!(
12804            c.gateway.require_pairing,
12805            "Config default must require pairing"
12806        );
12807        assert!(
12808            !c.gateway.allow_public_bind,
12809            "Config default must block public bind"
12810        );
12811    }
12812
12813    #[test]
12814    async fn checklist_gateway_serde_roundtrip() {
12815        let g = GatewayConfig {
12816            port: 42617,
12817            host: "127.0.0.1".into(),
12818            require_pairing: true,
12819            allow_public_bind: false,
12820            paired_tokens: vec!["zc_test_token".into()],
12821            pair_rate_limit_per_minute: 12,
12822            webhook_rate_limit_per_minute: 80,
12823            trust_forwarded_headers: true,
12824            path_prefix: Some("/construct".into()),
12825            rate_limit_max_keys: 2048,
12826            idempotency_ttl_secs: 600,
12827            idempotency_max_keys: 4096,
12828            session_persistence: true,
12829            session_ttl_hours: 0,
12830            pairing_dashboard: PairingDashboardConfig::default(),
12831            tls: None,
12832        };
12833        let toml_str = toml::to_string(&g).unwrap();
12834        let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
12835        assert!(parsed.require_pairing);
12836        assert!(parsed.session_persistence);
12837        assert_eq!(parsed.session_ttl_hours, 0);
12838        assert!(!parsed.allow_public_bind);
12839        assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
12840        assert_eq!(parsed.pair_rate_limit_per_minute, 12);
12841        assert_eq!(parsed.webhook_rate_limit_per_minute, 80);
12842        assert!(parsed.trust_forwarded_headers);
12843        assert_eq!(parsed.path_prefix.as_deref(), Some("/construct"));
12844        assert_eq!(parsed.rate_limit_max_keys, 2048);
12845        assert_eq!(parsed.idempotency_ttl_secs, 600);
12846        assert_eq!(parsed.idempotency_max_keys, 4096);
12847    }
12848
12849    #[test]
12850    async fn checklist_gateway_backward_compat_no_gateway_section() {
12851        // Old configs without [gateway] should get secure defaults
12852        let minimal = r#"
12853workspace_dir = "/tmp/ws"
12854config_path = "/tmp/config.toml"
12855default_temperature = 0.7
12856"#;
12857        let parsed = parse_test_config(minimal);
12858        assert!(
12859            parsed.gateway.require_pairing,
12860            "Missing [gateway] must default to require_pairing=true"
12861        );
12862        assert!(
12863            !parsed.gateway.allow_public_bind,
12864            "Missing [gateway] must default to allow_public_bind=false"
12865        );
12866    }
12867
12868    #[test]
12869    async fn checklist_autonomy_default_is_workspace_scoped() {
12870        let a = AutonomyConfig::default();
12871        assert!(a.workspace_only, "Default autonomy must be workspace_only");
12872        assert!(
12873            a.forbidden_paths.contains(&"/etc".to_string()),
12874            "Must block /etc"
12875        );
12876        assert!(
12877            a.forbidden_paths.contains(&"/proc".to_string()),
12878            "Must block /proc"
12879        );
12880        assert!(
12881            a.forbidden_paths.contains(&"~/.ssh".to_string()),
12882            "Must block ~/.ssh"
12883        );
12884    }
12885
12886    // ══════════════════════════════════════════════════════════
12887    // COMPOSIO CONFIG TESTS
12888    // ══════════════════════════════════════════════════════════
12889
12890    #[test]
12891    async fn composio_config_default_disabled() {
12892        let c = ComposioConfig::default();
12893        assert!(!c.enabled, "Composio must be disabled by default");
12894        assert!(c.api_key.is_none(), "No API key by default");
12895        assert_eq!(c.entity_id, "default");
12896    }
12897
12898    #[test]
12899    async fn composio_config_serde_roundtrip() {
12900        let c = ComposioConfig {
12901            enabled: true,
12902            api_key: Some("comp-key-123".into()),
12903            entity_id: "user42".into(),
12904        };
12905        let toml_str = toml::to_string(&c).unwrap();
12906        let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
12907        assert!(parsed.enabled);
12908        assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
12909        assert_eq!(parsed.entity_id, "user42");
12910    }
12911
12912    #[test]
12913    async fn composio_config_backward_compat_missing_section() {
12914        let minimal = r#"
12915workspace_dir = "/tmp/ws"
12916config_path = "/tmp/config.toml"
12917default_temperature = 0.7
12918"#;
12919        let parsed = parse_test_config(minimal);
12920        assert!(
12921            !parsed.composio.enabled,
12922            "Missing [composio] must default to disabled"
12923        );
12924        assert!(parsed.composio.api_key.is_none());
12925    }
12926
12927    #[test]
12928    async fn composio_config_partial_toml() {
12929        let toml_str = r"
12930enabled = true
12931";
12932        let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
12933        assert!(parsed.enabled);
12934        assert!(parsed.api_key.is_none());
12935        assert_eq!(parsed.entity_id, "default");
12936    }
12937
12938    #[test]
12939    async fn composio_config_enable_alias_supported() {
12940        let toml_str = r"
12941enable = true
12942";
12943        let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
12944        assert!(parsed.enabled);
12945        assert!(parsed.api_key.is_none());
12946        assert_eq!(parsed.entity_id, "default");
12947    }
12948
12949    // ══════════════════════════════════════════════════════════
12950    // SECRETS CONFIG TESTS
12951    // ══════════════════════════════════════════════════════════
12952
12953    #[test]
12954    async fn secrets_config_default_encrypts() {
12955        let s = SecretsConfig::default();
12956        assert!(s.encrypt, "Encryption must be enabled by default");
12957    }
12958
12959    #[test]
12960    async fn secrets_config_serde_roundtrip() {
12961        let s = SecretsConfig { encrypt: false };
12962        let toml_str = toml::to_string(&s).unwrap();
12963        let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
12964        assert!(!parsed.encrypt);
12965    }
12966
12967    #[test]
12968    async fn secrets_config_backward_compat_missing_section() {
12969        let minimal = r#"
12970workspace_dir = "/tmp/ws"
12971config_path = "/tmp/config.toml"
12972default_temperature = 0.7
12973"#;
12974        let parsed = parse_test_config(minimal);
12975        assert!(
12976            parsed.secrets.encrypt,
12977            "Missing [secrets] must default to encrypt=true"
12978        );
12979    }
12980
12981    #[test]
12982    async fn config_default_has_composio_and_secrets() {
12983        let c = Config::default();
12984        assert!(!c.composio.enabled);
12985        assert!(c.composio.api_key.is_none());
12986        assert!(c.secrets.encrypt);
12987        assert!(c.browser.enabled);
12988        assert_eq!(c.browser.allowed_domains, vec!["*".to_string()]);
12989    }
12990
12991    #[test]
12992    async fn browser_config_default_enabled() {
12993        let b = BrowserConfig::default();
12994        assert!(b.enabled);
12995        assert_eq!(b.allowed_domains, vec!["*".to_string()]);
12996        assert_eq!(b.backend, "agent_browser");
12997        assert!(b.native_headless);
12998        assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
12999        assert!(b.native_chrome_path.is_none());
13000        assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions");
13001        assert_eq!(b.computer_use.timeout_ms, 15_000);
13002        assert!(!b.computer_use.allow_remote_endpoint);
13003        assert!(b.computer_use.window_allowlist.is_empty());
13004        assert!(b.computer_use.max_coordinate_x.is_none());
13005        assert!(b.computer_use.max_coordinate_y.is_none());
13006    }
13007
13008    #[test]
13009    async fn browser_config_serde_roundtrip() {
13010        let b = BrowserConfig {
13011            enabled: true,
13012            allowed_domains: vec!["example.com".into(), "docs.example.com".into()],
13013            session_name: None,
13014            backend: "auto".into(),
13015            native_headless: false,
13016            native_webdriver_url: "http://localhost:4444".into(),
13017            native_chrome_path: Some("/usr/bin/chromium".into()),
13018            computer_use: BrowserComputerUseConfig {
13019                endpoint: "https://computer-use.example.com/v1/actions".into(),
13020                api_key: Some("test-token".into()),
13021                timeout_ms: 8_000,
13022                allow_remote_endpoint: true,
13023                window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()],
13024                max_coordinate_x: Some(3840),
13025                max_coordinate_y: Some(2160),
13026            },
13027        };
13028        let toml_str = toml::to_string(&b).unwrap();
13029        let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap();
13030        assert!(parsed.enabled);
13031        assert_eq!(parsed.allowed_domains.len(), 2);
13032        assert_eq!(parsed.allowed_domains[0], "example.com");
13033        assert_eq!(parsed.backend, "auto");
13034        assert!(!parsed.native_headless);
13035        assert_eq!(parsed.native_webdriver_url, "http://localhost:4444");
13036        assert_eq!(
13037            parsed.native_chrome_path.as_deref(),
13038            Some("/usr/bin/chromium")
13039        );
13040        assert_eq!(
13041            parsed.computer_use.endpoint,
13042            "https://computer-use.example.com/v1/actions"
13043        );
13044        assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token"));
13045        assert_eq!(parsed.computer_use.timeout_ms, 8_000);
13046        assert!(parsed.computer_use.allow_remote_endpoint);
13047        assert_eq!(parsed.computer_use.window_allowlist.len(), 2);
13048        assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840));
13049        assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160));
13050    }
13051
13052    #[test]
13053    async fn browser_config_backward_compat_missing_section() {
13054        let minimal = r#"
13055workspace_dir = "/tmp/ws"
13056config_path = "/tmp/config.toml"
13057default_temperature = 0.7
13058"#;
13059        let parsed = parse_test_config(minimal);
13060        assert!(parsed.browser.enabled);
13061        assert_eq!(parsed.browser.allowed_domains, vec!["*".to_string()]);
13062    }
13063
13064    // ── Environment variable overrides (Docker support) ─────────
13065
13066    async fn env_override_lock() -> MutexGuard<'static, ()> {
13067        static ENV_OVERRIDE_TEST_LOCK: Mutex<()> = Mutex::const_new(());
13068        ENV_OVERRIDE_TEST_LOCK.lock().await
13069    }
13070
13071    fn clear_proxy_env_test_vars() {
13072        for key in [
13073            "CONSTRUCT_PROXY_ENABLED",
13074            "CONSTRUCT_HTTP_PROXY",
13075            "CONSTRUCT_HTTPS_PROXY",
13076            "CONSTRUCT_ALL_PROXY",
13077            "CONSTRUCT_NO_PROXY",
13078            "CONSTRUCT_PROXY_SCOPE",
13079            "CONSTRUCT_PROXY_SERVICES",
13080            "HTTP_PROXY",
13081            "HTTPS_PROXY",
13082            "ALL_PROXY",
13083            "NO_PROXY",
13084            "http_proxy",
13085            "https_proxy",
13086            "all_proxy",
13087            "no_proxy",
13088        ] {
13089            // SAFETY: test-only, single-threaded test runner.
13090            unsafe { std::env::remove_var(key) };
13091        }
13092    }
13093
13094    #[test]
13095    async fn env_override_api_key() {
13096        let _env_guard = env_override_lock().await;
13097        let mut config = Config::default();
13098        assert!(config.api_key.is_none());
13099
13100        // SAFETY: test-only, single-threaded test runner.
13101        unsafe { std::env::set_var("CONSTRUCT_API_KEY", "sk-test-env-key") };
13102        config.apply_env_overrides();
13103        assert_eq!(config.api_key.as_deref(), Some("sk-test-env-key"));
13104
13105        // SAFETY: test-only, single-threaded test runner.
13106        unsafe { std::env::remove_var("CONSTRUCT_API_KEY") };
13107    }
13108
13109    #[test]
13110    async fn env_override_api_key_fallback() {
13111        let _env_guard = env_override_lock().await;
13112        let mut config = Config::default();
13113
13114        // SAFETY: test-only, single-threaded test runner.
13115        unsafe { std::env::remove_var("CONSTRUCT_API_KEY") };
13116        // SAFETY: test-only, single-threaded test runner.
13117        unsafe { std::env::set_var("API_KEY", "sk-fallback-key") };
13118        config.apply_env_overrides();
13119        assert_eq!(config.api_key.as_deref(), Some("sk-fallback-key"));
13120
13121        // SAFETY: test-only, single-threaded test runner.
13122        unsafe { std::env::remove_var("API_KEY") };
13123    }
13124
13125    #[test]
13126    async fn env_override_provider() {
13127        let _env_guard = env_override_lock().await;
13128        let mut config = Config::default();
13129
13130        // SAFETY: test-only, single-threaded test runner.
13131        unsafe { std::env::set_var("CONSTRUCT_PROVIDER", "anthropic") };
13132        config.apply_env_overrides();
13133        assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
13134
13135        // SAFETY: test-only, single-threaded test runner.
13136        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13137    }
13138
13139    #[test]
13140    async fn env_override_model_provider_alias() {
13141        let _env_guard = env_override_lock().await;
13142        let mut config = Config::default();
13143
13144        // SAFETY: test-only, single-threaded test runner.
13145        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13146        // SAFETY: test-only, single-threaded test runner.
13147        unsafe { std::env::set_var("CONSTRUCT_MODEL_PROVIDER", "openai-codex") };
13148        config.apply_env_overrides();
13149        assert_eq!(config.default_provider.as_deref(), Some("openai-codex"));
13150
13151        // SAFETY: test-only, single-threaded test runner.
13152        unsafe { std::env::remove_var("CONSTRUCT_MODEL_PROVIDER") };
13153    }
13154
13155    #[test]
13156    async fn toml_supports_model_provider_and_model_alias_fields() {
13157        let raw = r#"
13158default_temperature = 0.7
13159model_provider = "sub2api"
13160model = "gpt-5.3-codex"
13161
13162[model_providers.sub2api]
13163name = "sub2api"
13164base_url = "https://api.tonsof.blue/v1"
13165wire_api = "responses"
13166requires_openai_auth = true
13167"#;
13168
13169        let parsed = parse_test_config(raw);
13170        assert_eq!(parsed.default_provider.as_deref(), Some("sub2api"));
13171        assert_eq!(parsed.default_model.as_deref(), Some("gpt-5.3-codex"));
13172        let profile = parsed
13173            .model_providers
13174            .get("sub2api")
13175            .expect("profile should exist");
13176        assert_eq!(profile.wire_api.as_deref(), Some("responses"));
13177        assert!(profile.requires_openai_auth);
13178    }
13179
13180    #[test]
13181    async fn env_override_open_skills_enabled_and_dir() {
13182        let _env_guard = env_override_lock().await;
13183        let mut config = Config::default();
13184        assert!(!config.skills.open_skills_enabled);
13185        assert!(config.skills.open_skills_dir.is_none());
13186        assert_eq!(
13187            config.skills.prompt_injection_mode,
13188            SkillsPromptInjectionMode::Full
13189        );
13190
13191        // SAFETY: test-only, single-threaded test runner.
13192        unsafe { std::env::set_var("CONSTRUCT_OPEN_SKILLS_ENABLED", "true") };
13193        // SAFETY: test-only, single-threaded test runner.
13194        unsafe { std::env::set_var("CONSTRUCT_OPEN_SKILLS_DIR", "/tmp/open-skills") };
13195        // SAFETY: test-only, single-threaded test runner.
13196        unsafe { std::env::set_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS", "yes") };
13197        // SAFETY: test-only, single-threaded test runner.
13198        unsafe { std::env::set_var("CONSTRUCT_SKILLS_PROMPT_MODE", "compact") };
13199        config.apply_env_overrides();
13200
13201        assert!(config.skills.open_skills_enabled);
13202        assert!(config.skills.allow_scripts);
13203        assert_eq!(
13204            config.skills.open_skills_dir.as_deref(),
13205            Some("/tmp/open-skills")
13206        );
13207        assert_eq!(
13208            config.skills.prompt_injection_mode,
13209            SkillsPromptInjectionMode::Compact
13210        );
13211
13212        // SAFETY: test-only, single-threaded test runner.
13213        unsafe { std::env::remove_var("CONSTRUCT_OPEN_SKILLS_ENABLED") };
13214        // SAFETY: test-only, single-threaded test runner.
13215        unsafe { std::env::remove_var("CONSTRUCT_OPEN_SKILLS_DIR") };
13216        // SAFETY: test-only, single-threaded test runner.
13217        unsafe { std::env::remove_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS") };
13218        // SAFETY: test-only, single-threaded test runner.
13219        unsafe { std::env::remove_var("CONSTRUCT_SKILLS_PROMPT_MODE") };
13220    }
13221
13222    #[test]
13223    async fn env_override_open_skills_enabled_invalid_value_keeps_existing_value() {
13224        let _env_guard = env_override_lock().await;
13225        let mut config = Config::default();
13226        config.skills.open_skills_enabled = true;
13227        config.skills.allow_scripts = true;
13228        config.skills.prompt_injection_mode = SkillsPromptInjectionMode::Compact;
13229
13230        // SAFETY: test-only, single-threaded test runner.
13231        unsafe { std::env::set_var("CONSTRUCT_OPEN_SKILLS_ENABLED", "maybe") };
13232        // SAFETY: test-only, single-threaded test runner.
13233        unsafe { std::env::set_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS", "maybe") };
13234        // SAFETY: test-only, single-threaded test runner.
13235        unsafe { std::env::set_var("CONSTRUCT_SKILLS_PROMPT_MODE", "invalid") };
13236        config.apply_env_overrides();
13237
13238        assert!(config.skills.open_skills_enabled);
13239        assert!(config.skills.allow_scripts);
13240        assert_eq!(
13241            config.skills.prompt_injection_mode,
13242            SkillsPromptInjectionMode::Compact
13243        );
13244        // SAFETY: test-only, single-threaded test runner.
13245        unsafe { std::env::remove_var("CONSTRUCT_OPEN_SKILLS_ENABLED") };
13246        // SAFETY: test-only, single-threaded test runner.
13247        unsafe { std::env::remove_var("CONSTRUCT_SKILLS_ALLOW_SCRIPTS") };
13248        // SAFETY: test-only, single-threaded test runner.
13249        unsafe { std::env::remove_var("CONSTRUCT_SKILLS_PROMPT_MODE") };
13250    }
13251
13252    #[test]
13253    async fn env_override_provider_fallback() {
13254        let _env_guard = env_override_lock().await;
13255        let mut config = Config::default();
13256
13257        // SAFETY: test-only, single-threaded test runner.
13258        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13259        // SAFETY: test-only, single-threaded test runner.
13260        unsafe { std::env::set_var("PROVIDER", "openai") };
13261        config.apply_env_overrides();
13262        assert_eq!(config.default_provider.as_deref(), Some("openai"));
13263
13264        // SAFETY: test-only, single-threaded test runner.
13265        unsafe { std::env::remove_var("PROVIDER") };
13266    }
13267
13268    #[test]
13269    async fn env_override_provider_fallback_does_not_replace_non_default_provider() {
13270        let _env_guard = env_override_lock().await;
13271        let mut config = Config {
13272            default_provider: Some("custom:https://proxy.example.com/v1".to_string()),
13273            ..Config::default()
13274        };
13275
13276        // SAFETY: test-only, single-threaded test runner.
13277        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13278        // SAFETY: test-only, single-threaded test runner.
13279        unsafe { std::env::set_var("PROVIDER", "openrouter") };
13280        config.apply_env_overrides();
13281        assert_eq!(
13282            config.default_provider.as_deref(),
13283            Some("custom:https://proxy.example.com/v1")
13284        );
13285
13286        // SAFETY: test-only, single-threaded test runner.
13287        unsafe { std::env::remove_var("PROVIDER") };
13288    }
13289
13290    #[test]
13291    async fn env_override_zero_claw_provider_overrides_non_default_provider() {
13292        let _env_guard = env_override_lock().await;
13293        let mut config = Config {
13294            default_provider: Some("custom:https://proxy.example.com/v1".to_string()),
13295            ..Config::default()
13296        };
13297
13298        // SAFETY: test-only, single-threaded test runner.
13299        unsafe { std::env::set_var("CONSTRUCT_PROVIDER", "openrouter") };
13300        // SAFETY: test-only, single-threaded test runner.
13301        unsafe { std::env::set_var("PROVIDER", "anthropic") };
13302        config.apply_env_overrides();
13303        assert_eq!(config.default_provider.as_deref(), Some("openrouter"));
13304
13305        // SAFETY: test-only, single-threaded test runner.
13306        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
13307        // SAFETY: test-only, single-threaded test runner.
13308        unsafe { std::env::remove_var("PROVIDER") };
13309    }
13310
13311    #[test]
13312    async fn env_override_glm_api_key_for_regional_aliases() {
13313        let _env_guard = env_override_lock().await;
13314        let mut config = Config {
13315            default_provider: Some("glm-cn".to_string()),
13316            ..Config::default()
13317        };
13318
13319        // SAFETY: test-only, single-threaded test runner.
13320        unsafe { std::env::set_var("GLM_API_KEY", "glm-regional-key") };
13321        config.apply_env_overrides();
13322        assert_eq!(config.api_key.as_deref(), Some("glm-regional-key"));
13323
13324        // SAFETY: test-only, single-threaded test runner.
13325        unsafe { std::env::remove_var("GLM_API_KEY") };
13326    }
13327
13328    #[test]
13329    async fn env_override_zai_api_key_for_regional_aliases() {
13330        let _env_guard = env_override_lock().await;
13331        let mut config = Config {
13332            default_provider: Some("zai-cn".to_string()),
13333            ..Config::default()
13334        };
13335
13336        // SAFETY: test-only, single-threaded test runner.
13337        unsafe { std::env::set_var("ZAI_API_KEY", "zai-regional-key") };
13338        config.apply_env_overrides();
13339        assert_eq!(config.api_key.as_deref(), Some("zai-regional-key"));
13340
13341        // SAFETY: test-only, single-threaded test runner.
13342        unsafe { std::env::remove_var("ZAI_API_KEY") };
13343    }
13344
13345    #[test]
13346    async fn env_override_model() {
13347        let _env_guard = env_override_lock().await;
13348        let mut config = Config::default();
13349
13350        // SAFETY: test-only, single-threaded test runner.
13351        unsafe { std::env::set_var("CONSTRUCT_MODEL", "gpt-4o") };
13352        config.apply_env_overrides();
13353        assert_eq!(config.default_model.as_deref(), Some("gpt-4o"));
13354
13355        // SAFETY: test-only, single-threaded test runner.
13356        unsafe { std::env::remove_var("CONSTRUCT_MODEL") };
13357    }
13358
13359    #[test]
13360    async fn model_provider_profile_maps_to_custom_endpoint() {
13361        let _env_guard = env_override_lock().await;
13362        let mut config = Config {
13363            default_provider: Some("sub2api".to_string()),
13364            model_providers: HashMap::from([(
13365                "sub2api".to_string(),
13366                ModelProviderConfig {
13367                    name: Some("sub2api".to_string()),
13368                    base_url: Some("https://api.tonsof.blue/v1".to_string()),
13369                    wire_api: None,
13370                    requires_openai_auth: false,
13371                    azure_openai_resource: None,
13372                    azure_openai_deployment: None,
13373                    azure_openai_api_version: None,
13374                    api_path: None,
13375                    max_tokens: None,
13376                },
13377            )]),
13378            ..Config::default()
13379        };
13380
13381        config.apply_env_overrides();
13382        assert_eq!(
13383            config.default_provider.as_deref(),
13384            Some("custom:https://api.tonsof.blue/v1")
13385        );
13386        assert_eq!(
13387            config.api_url.as_deref(),
13388            Some("https://api.tonsof.blue/v1")
13389        );
13390    }
13391
13392    #[test]
13393    async fn model_provider_profile_responses_uses_openai_codex_and_openai_key() {
13394        let _env_guard = env_override_lock().await;
13395        let mut config = Config {
13396            default_provider: Some("sub2api".to_string()),
13397            model_providers: HashMap::from([(
13398                "sub2api".to_string(),
13399                ModelProviderConfig {
13400                    name: Some("sub2api".to_string()),
13401                    base_url: Some("https://api.tonsof.blue".to_string()),
13402                    wire_api: Some("responses".to_string()),
13403                    requires_openai_auth: true,
13404                    azure_openai_resource: None,
13405                    azure_openai_deployment: None,
13406                    azure_openai_api_version: None,
13407                    api_path: None,
13408                    max_tokens: None,
13409                },
13410            )]),
13411            api_key: None,
13412            ..Config::default()
13413        };
13414
13415        // SAFETY: test-only, single-threaded test runner.
13416        unsafe { std::env::set_var("OPENAI_API_KEY", "sk-test-codex-key") };
13417        config.apply_env_overrides();
13418        // SAFETY: test-only, single-threaded test runner.
13419        unsafe { std::env::remove_var("OPENAI_API_KEY") };
13420
13421        assert_eq!(config.default_provider.as_deref(), Some("openai-codex"));
13422        assert_eq!(config.api_url.as_deref(), Some("https://api.tonsof.blue"));
13423        assert_eq!(config.api_key.as_deref(), Some("sk-test-codex-key"));
13424    }
13425
13426    #[test]
13427    async fn save_repairs_bare_config_filename_using_runtime_resolution() {
13428        let _env_guard = env_override_lock().await;
13429        let temp_home =
13430            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13431        let workspace_dir = temp_home.join("workspace");
13432        let resolved_config_path = temp_home.join(".construct").join("config.toml");
13433
13434        let original_home = std::env::var("HOME").ok();
13435        // SAFETY: test-only, single-threaded test runner.
13436        unsafe { std::env::set_var("HOME", &temp_home) };
13437        // SAFETY: test-only, single-threaded test runner.
13438        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13439
13440        let mut config = Config::default();
13441        config.workspace_dir = workspace_dir;
13442        config.config_path = PathBuf::from("config.toml");
13443        config.default_temperature = 0.5;
13444        config.save().await.unwrap();
13445
13446        assert!(resolved_config_path.exists());
13447        let saved = tokio::fs::read_to_string(&resolved_config_path)
13448            .await
13449            .unwrap();
13450        let parsed = parse_test_config(&saved);
13451        assert_eq!(parsed.default_temperature, 0.5);
13452
13453        // SAFETY: test-only, single-threaded test runner.
13454        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13455        if let Some(home) = original_home {
13456            // SAFETY: test-only, single-threaded test runner.
13457            unsafe { std::env::set_var("HOME", home) };
13458        } else {
13459            // SAFETY: test-only, single-threaded test runner.
13460            unsafe { std::env::remove_var("HOME") };
13461        }
13462        let _ = tokio::fs::remove_dir_all(temp_home).await;
13463    }
13464
13465    #[test]
13466    async fn validate_ollama_cloud_model_requires_remote_api_url() {
13467        let _env_guard = env_override_lock().await;
13468        let config = Config {
13469            default_provider: Some("ollama".to_string()),
13470            default_model: Some("glm-5:cloud".to_string()),
13471            api_url: None,
13472            api_key: Some("ollama-key".to_string()),
13473            ..Config::default()
13474        };
13475
13476        let error = config.validate().expect_err("expected validation to fail");
13477        assert!(error.to_string().contains(
13478            "default_model uses ':cloud' with provider 'ollama', but api_url is local or unset"
13479        ));
13480    }
13481
13482    #[test]
13483    async fn validate_ollama_cloud_model_accepts_remote_endpoint_and_env_key() {
13484        let _env_guard = env_override_lock().await;
13485        let config = Config {
13486            default_provider: Some("ollama".to_string()),
13487            default_model: Some("glm-5:cloud".to_string()),
13488            api_url: Some("https://ollama.com/api".to_string()),
13489            api_key: None,
13490            ..Config::default()
13491        };
13492
13493        // SAFETY: test-only, single-threaded test runner.
13494        unsafe { std::env::set_var("OLLAMA_API_KEY", "ollama-env-key") };
13495        let result = config.validate();
13496        // SAFETY: test-only, single-threaded test runner.
13497        unsafe { std::env::remove_var("OLLAMA_API_KEY") };
13498
13499        assert!(result.is_ok(), "expected validation to pass: {result:?}");
13500    }
13501
13502    #[test]
13503    async fn validate_rejects_unknown_model_provider_wire_api() {
13504        let _env_guard = env_override_lock().await;
13505        let config = Config {
13506            default_provider: Some("sub2api".to_string()),
13507            model_providers: HashMap::from([(
13508                "sub2api".to_string(),
13509                ModelProviderConfig {
13510                    name: Some("sub2api".to_string()),
13511                    base_url: Some("https://api.tonsof.blue/v1".to_string()),
13512                    wire_api: Some("ws".to_string()),
13513                    requires_openai_auth: false,
13514                    azure_openai_resource: None,
13515                    azure_openai_deployment: None,
13516                    azure_openai_api_version: None,
13517                    api_path: None,
13518                    max_tokens: None,
13519                },
13520            )]),
13521            ..Config::default()
13522        };
13523
13524        let error = config.validate().expect_err("expected validation failure");
13525        assert!(
13526            error
13527                .to_string()
13528                .contains("wire_api must be one of: responses, chat_completions")
13529        );
13530    }
13531
13532    #[test]
13533    async fn env_override_model_fallback() {
13534        let _env_guard = env_override_lock().await;
13535        let mut config = Config::default();
13536
13537        // SAFETY: test-only, single-threaded test runner.
13538        unsafe { std::env::remove_var("CONSTRUCT_MODEL") };
13539        // SAFETY: test-only, single-threaded test runner.
13540        unsafe { std::env::set_var("MODEL", "anthropic/claude-3.5-sonnet") };
13541        config.apply_env_overrides();
13542        assert_eq!(
13543            config.default_model.as_deref(),
13544            Some("anthropic/claude-3.5-sonnet")
13545        );
13546
13547        // SAFETY: test-only, single-threaded test runner.
13548        unsafe { std::env::remove_var("MODEL") };
13549    }
13550
13551    #[test]
13552    async fn env_override_workspace() {
13553        let _env_guard = env_override_lock().await;
13554        let mut config = Config::default();
13555
13556        // SAFETY: test-only, single-threaded test runner.
13557        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", "/custom/workspace") };
13558        config.apply_env_overrides();
13559        assert_eq!(config.workspace_dir, PathBuf::from("/custom/workspace"));
13560
13561        // SAFETY: test-only, single-threaded test runner.
13562        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13563    }
13564
13565    #[test]
13566    async fn resolve_runtime_config_dirs_uses_env_workspace_first() {
13567        let _env_guard = env_override_lock().await;
13568        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13569        let default_workspace_dir = default_config_dir.join("workspace");
13570        let workspace_dir = default_config_dir.join("profile-a");
13571
13572        // SAFETY: test-only, single-threaded test runner.
13573        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13574        let (config_dir, resolved_workspace_dir, source) =
13575            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13576                .await
13577                .unwrap();
13578
13579        assert_eq!(source, ConfigResolutionSource::EnvWorkspace);
13580        assert_eq!(config_dir, workspace_dir);
13581        assert_eq!(resolved_workspace_dir, workspace_dir.join("workspace"));
13582
13583        // SAFETY: test-only, single-threaded test runner.
13584        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13585        let _ = fs::remove_dir_all(default_config_dir).await;
13586    }
13587
13588    #[test]
13589    async fn resolve_runtime_config_dirs_uses_env_config_dir_first() {
13590        let _env_guard = env_override_lock().await;
13591        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13592        let default_workspace_dir = default_config_dir.join("workspace");
13593        let explicit_config_dir = default_config_dir.join("explicit-config");
13594        let marker_config_dir = default_config_dir.join("profiles").join("alpha");
13595        let state_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
13596
13597        fs::create_dir_all(&default_config_dir).await.unwrap();
13598        let state = ActiveWorkspaceState {
13599            config_dir: marker_config_dir.to_string_lossy().into_owned(),
13600        };
13601        fs::write(&state_path, toml::to_string(&state).unwrap())
13602            .await
13603            .unwrap();
13604
13605        // SAFETY: test-only, single-threaded test runner.
13606        unsafe { std::env::set_var("CONSTRUCT_CONFIG_DIR", &explicit_config_dir) };
13607        // SAFETY: test-only, single-threaded test runner.
13608        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13609
13610        let (config_dir, resolved_workspace_dir, source) =
13611            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13612                .await
13613                .unwrap();
13614
13615        assert_eq!(source, ConfigResolutionSource::EnvConfigDir);
13616        assert_eq!(config_dir, explicit_config_dir);
13617        assert_eq!(
13618            resolved_workspace_dir,
13619            explicit_config_dir.join("workspace")
13620        );
13621
13622        // SAFETY: test-only, single-threaded test runner.
13623        unsafe { std::env::remove_var("CONSTRUCT_CONFIG_DIR") };
13624        let _ = fs::remove_dir_all(default_config_dir).await;
13625    }
13626
13627    #[test]
13628    async fn resolve_runtime_config_dirs_uses_active_workspace_marker() {
13629        let _env_guard = env_override_lock().await;
13630        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13631        let default_workspace_dir = default_config_dir.join("workspace");
13632        let marker_config_dir = default_config_dir.join("profiles").join("alpha");
13633        let state_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
13634
13635        // SAFETY: test-only, single-threaded test runner.
13636        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13637        fs::create_dir_all(&default_config_dir).await.unwrap();
13638        let state = ActiveWorkspaceState {
13639            config_dir: marker_config_dir.to_string_lossy().into_owned(),
13640        };
13641        fs::write(&state_path, toml::to_string(&state).unwrap())
13642            .await
13643            .unwrap();
13644
13645        let (config_dir, resolved_workspace_dir, source) =
13646            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13647                .await
13648                .unwrap();
13649
13650        assert_eq!(source, ConfigResolutionSource::ActiveWorkspaceMarker);
13651        assert_eq!(config_dir, marker_config_dir);
13652        assert_eq!(resolved_workspace_dir, marker_config_dir.join("workspace"));
13653
13654        let _ = fs::remove_dir_all(default_config_dir).await;
13655    }
13656
13657    #[test]
13658    async fn resolve_runtime_config_dirs_falls_back_to_default_layout() {
13659        let _env_guard = env_override_lock().await;
13660        let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
13661        let default_workspace_dir = default_config_dir.join("workspace");
13662
13663        // SAFETY: test-only, single-threaded test runner.
13664        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13665        let (config_dir, resolved_workspace_dir, source) =
13666            resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
13667                .await
13668                .unwrap();
13669
13670        assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
13671        assert_eq!(config_dir, default_config_dir);
13672        assert_eq!(resolved_workspace_dir, default_workspace_dir);
13673
13674        let _ = fs::remove_dir_all(default_config_dir).await;
13675    }
13676
13677    #[test]
13678    async fn load_or_init_workspace_override_uses_workspace_root_for_config() {
13679        let _env_guard = env_override_lock().await;
13680        let temp_home =
13681            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13682        let workspace_dir = temp_home.join("profile-a");
13683
13684        let original_home = std::env::var("HOME").ok();
13685        // SAFETY: test-only, single-threaded test runner.
13686        unsafe { std::env::set_var("HOME", &temp_home) };
13687        // SAFETY: test-only, single-threaded test runner.
13688        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13689
13690        let config = Box::pin(Config::load_or_init()).await.unwrap();
13691
13692        assert_eq!(config.workspace_dir, workspace_dir.join("workspace"));
13693        assert_eq!(config.config_path, workspace_dir.join("config.toml"));
13694        assert!(workspace_dir.join("config.toml").exists());
13695
13696        // SAFETY: test-only, single-threaded test runner.
13697        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13698        if let Some(home) = original_home {
13699            // SAFETY: test-only, single-threaded test runner.
13700            unsafe { std::env::set_var("HOME", home) };
13701        } else {
13702            // SAFETY: test-only, single-threaded test runner.
13703            unsafe { std::env::remove_var("HOME") };
13704        }
13705        let _ = fs::remove_dir_all(temp_home).await;
13706    }
13707
13708    #[test]
13709    async fn load_or_init_workspace_suffix_uses_legacy_config_layout() {
13710        let _env_guard = env_override_lock().await;
13711        let temp_home =
13712            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13713        let workspace_dir = temp_home.join("workspace");
13714        let legacy_config_path = temp_home.join(".construct").join("config.toml");
13715
13716        let original_home = std::env::var("HOME").ok();
13717        // SAFETY: test-only, single-threaded test runner.
13718        unsafe { std::env::set_var("HOME", &temp_home) };
13719        // SAFETY: test-only, single-threaded test runner.
13720        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13721
13722        let config = Box::pin(Config::load_or_init()).await.unwrap();
13723
13724        assert_eq!(config.workspace_dir, workspace_dir);
13725        assert_eq!(config.config_path, legacy_config_path);
13726        assert!(config.config_path.exists());
13727
13728        // SAFETY: test-only, single-threaded test runner.
13729        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13730        if let Some(home) = original_home {
13731            // SAFETY: test-only, single-threaded test runner.
13732            unsafe { std::env::set_var("HOME", home) };
13733        } else {
13734            // SAFETY: test-only, single-threaded test runner.
13735            unsafe { std::env::remove_var("HOME") };
13736        }
13737        let _ = fs::remove_dir_all(temp_home).await;
13738    }
13739
13740    #[test]
13741    async fn load_or_init_workspace_override_keeps_existing_legacy_config() {
13742        let _env_guard = env_override_lock().await;
13743        let temp_home =
13744            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13745        let workspace_dir = temp_home.join("custom-workspace");
13746        let legacy_config_dir = temp_home.join(".construct");
13747        let legacy_config_path = legacy_config_dir.join("config.toml");
13748
13749        fs::create_dir_all(&legacy_config_dir).await.unwrap();
13750        fs::write(
13751            &legacy_config_path,
13752            r#"default_temperature = 0.7
13753default_model = "legacy-model"
13754"#,
13755        )
13756        .await
13757        .unwrap();
13758
13759        let original_home = std::env::var("HOME").ok();
13760        // SAFETY: test-only, single-threaded test runner.
13761        unsafe { std::env::set_var("HOME", &temp_home) };
13762        // SAFETY: test-only, single-threaded test runner.
13763        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13764
13765        let config = Box::pin(Config::load_or_init()).await.unwrap();
13766
13767        assert_eq!(config.workspace_dir, workspace_dir);
13768        assert_eq!(config.config_path, legacy_config_path);
13769        assert_eq!(config.default_model.as_deref(), Some("legacy-model"));
13770
13771        // SAFETY: test-only, single-threaded test runner.
13772        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13773        if let Some(home) = original_home {
13774            // SAFETY: test-only, single-threaded test runner.
13775            unsafe { std::env::set_var("HOME", home) };
13776        } else {
13777            // SAFETY: test-only, single-threaded test runner.
13778            unsafe { std::env::remove_var("HOME") };
13779        }
13780        let _ = fs::remove_dir_all(temp_home).await;
13781    }
13782
13783    #[test]
13784    async fn load_or_init_decrypts_feishu_channel_secrets() {
13785        let _env_guard = env_override_lock().await;
13786        let temp_home =
13787            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13788        let config_dir = temp_home.join(".construct");
13789        let config_path = config_dir.join("config.toml");
13790
13791        fs::create_dir_all(&config_dir).await.unwrap();
13792
13793        let original_home = std::env::var("HOME").ok();
13794        // SAFETY: test-only, single-threaded test runner.
13795        unsafe { std::env::set_var("HOME", &temp_home) };
13796        // SAFETY: test-only, single-threaded test runner.
13797        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13798
13799        let mut config = Config::default();
13800        config.config_path = config_path.clone();
13801        config.workspace_dir = config_dir.join("workspace");
13802        config.secrets.encrypt = true;
13803        config.channels_config.feishu = Some(FeishuConfig {
13804            app_id: "cli_feishu_123".into(),
13805            app_secret: "feishu-secret".into(),
13806            encrypt_key: Some("feishu-encrypt".into()),
13807            verification_token: Some("feishu-verify".into()),
13808            allowed_users: vec!["*".into()],
13809            receive_mode: LarkReceiveMode::Websocket,
13810            port: None,
13811            proxy_url: None,
13812        });
13813        config.save().await.unwrap();
13814
13815        let loaded = Box::pin(Config::load_or_init()).await.unwrap();
13816        let feishu = loaded.channels_config.feishu.as_ref().unwrap();
13817        assert_eq!(feishu.app_secret, "feishu-secret");
13818        assert_eq!(feishu.encrypt_key.as_deref(), Some("feishu-encrypt"));
13819        assert_eq!(feishu.verification_token.as_deref(), Some("feishu-verify"));
13820
13821        if let Some(home) = original_home {
13822            // SAFETY: test-only, single-threaded test runner.
13823            unsafe { std::env::set_var("HOME", home) };
13824        } else {
13825            // SAFETY: test-only, single-threaded test runner.
13826            unsafe { std::env::remove_var("HOME") };
13827        }
13828        let _ = fs::remove_dir_all(temp_home).await;
13829    }
13830
13831    #[test]
13832    async fn load_or_init_uses_persisted_active_workspace_marker() {
13833        let _env_guard = env_override_lock().await;
13834        let temp_home =
13835            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13836        let temp_default_dir = temp_home.join(".construct");
13837        let custom_config_dir = temp_home.join("profiles").join("agent-alpha");
13838
13839        fs::create_dir_all(&custom_config_dir).await.unwrap();
13840        // Pre-create the default dir so is_temp_directory() can canonicalize
13841        // the path on macOS (where /var → /private/var symlink requires
13842        // the directory to exist for canonicalize to resolve correctly).
13843        fs::create_dir_all(&temp_default_dir).await.unwrap();
13844        fs::write(
13845            custom_config_dir.join("config.toml"),
13846            "default_temperature = 0.7\ndefault_model = \"persisted-profile\"\n",
13847        )
13848        .await
13849        .unwrap();
13850
13851        // Write the marker using the explicit default dir (no HOME manipulation
13852        // needed for the persist call itself).
13853        persist_active_workspace_config_dir_in(&custom_config_dir, &temp_default_dir)
13854            .await
13855            .unwrap();
13856
13857        // Config::load_or_init still reads HOME to find the marker, so we
13858        // must override HOME here. The persist above already wrote to the
13859        // correct temp location, so no stale marker can leak.
13860        let original_home = std::env::var("HOME").ok();
13861        // SAFETY: test-only, single-threaded test runner.
13862        unsafe { std::env::set_var("HOME", &temp_home) };
13863        // SAFETY: test-only, single-threaded test runner.
13864        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13865
13866        let config = Box::pin(Config::load_or_init()).await.unwrap();
13867
13868        assert_eq!(config.config_path, custom_config_dir.join("config.toml"));
13869        assert_eq!(config.workspace_dir, custom_config_dir.join("workspace"));
13870        assert_eq!(config.default_model.as_deref(), Some("persisted-profile"));
13871
13872        if let Some(home) = original_home {
13873            // SAFETY: test-only, single-threaded test runner.
13874            unsafe { std::env::set_var("HOME", home) };
13875        } else {
13876            // SAFETY: test-only, single-threaded test runner.
13877            unsafe { std::env::remove_var("HOME") };
13878        }
13879        let _ = fs::remove_dir_all(temp_home).await;
13880    }
13881
13882    #[test]
13883    async fn load_or_init_env_workspace_override_takes_priority_over_marker() {
13884        let _env_guard = env_override_lock().await;
13885        let temp_home =
13886            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13887        let temp_default_dir = temp_home.join(".construct");
13888        let marker_config_dir = temp_home.join("profiles").join("persisted-profile");
13889        let env_workspace_dir = temp_home.join("env-workspace");
13890
13891        fs::create_dir_all(&marker_config_dir).await.unwrap();
13892        fs::write(
13893            marker_config_dir.join("config.toml"),
13894            "default_temperature = 0.7\ndefault_model = \"marker-model\"\n",
13895        )
13896        .await
13897        .unwrap();
13898
13899        // Write marker via explicit default dir, then set HOME for load_or_init.
13900        persist_active_workspace_config_dir_in(&marker_config_dir, &temp_default_dir)
13901            .await
13902            .unwrap();
13903
13904        let original_home = std::env::var("HOME").ok();
13905        // SAFETY: test-only, single-threaded test runner.
13906        unsafe { std::env::set_var("HOME", &temp_home) };
13907        // SAFETY: test-only, single-threaded test runner.
13908        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &env_workspace_dir) };
13909
13910        let config = Box::pin(Config::load_or_init()).await.unwrap();
13911
13912        assert_eq!(config.workspace_dir, env_workspace_dir.join("workspace"));
13913        assert_eq!(config.config_path, env_workspace_dir.join("config.toml"));
13914
13915        // SAFETY: test-only, single-threaded test runner.
13916        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13917        if let Some(home) = original_home {
13918            // SAFETY: test-only, single-threaded test runner.
13919            unsafe { std::env::set_var("HOME", home) };
13920        } else {
13921            // SAFETY: test-only, single-threaded test runner.
13922            unsafe { std::env::remove_var("HOME") };
13923        }
13924        let _ = fs::remove_dir_all(temp_home).await;
13925    }
13926
13927    #[test]
13928    async fn persist_active_workspace_marker_is_cleared_for_default_config_dir() {
13929        let temp_home =
13930            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13931        let default_config_dir = temp_home.join(".construct");
13932        let custom_config_dir = temp_home.join("profiles").join("custom-profile");
13933        let marker_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
13934
13935        // Use the _in variant directly -- no HOME manipulation needed since
13936        // this test only exercises persist/clear logic, not Config::load_or_init.
13937        persist_active_workspace_config_dir_in(&custom_config_dir, &default_config_dir)
13938            .await
13939            .unwrap();
13940        assert!(marker_path.exists());
13941
13942        persist_active_workspace_config_dir_in(&default_config_dir, &default_config_dir)
13943            .await
13944            .unwrap();
13945        assert!(!marker_path.exists());
13946
13947        let _ = fs::remove_dir_all(temp_home).await;
13948    }
13949
13950    #[test]
13951    #[allow(clippy::large_futures)]
13952    async fn load_or_init_logs_existing_config_as_initialized() {
13953        let _env_guard = env_override_lock().await;
13954        let temp_home =
13955            std::env::temp_dir().join(format!("construct_test_home_{}", uuid::Uuid::new_v4()));
13956        let workspace_dir = temp_home.join("profile-a");
13957        let config_path = workspace_dir.join("config.toml");
13958
13959        fs::create_dir_all(&workspace_dir).await.unwrap();
13960        fs::write(
13961            &config_path,
13962            r#"default_temperature = 0.7
13963default_model = "persisted-profile"
13964"#,
13965        )
13966        .await
13967        .unwrap();
13968
13969        let original_home = std::env::var("HOME").ok();
13970        // SAFETY: test-only, single-threaded test runner.
13971        unsafe { std::env::set_var("HOME", &temp_home) };
13972        // SAFETY: test-only, single-threaded test runner.
13973        unsafe { std::env::set_var("CONSTRUCT_WORKSPACE", &workspace_dir) };
13974
13975        let capture = SharedLogBuffer::default();
13976        let subscriber = tracing_subscriber::fmt()
13977            .with_ansi(false)
13978            .without_time()
13979            .with_target(false)
13980            .with_writer(capture.clone())
13981            .finish();
13982        let dispatch = tracing::Dispatch::new(subscriber);
13983        let guard = tracing::dispatcher::set_default(&dispatch);
13984
13985        let config = Box::pin(Config::load_or_init()).await.unwrap();
13986
13987        drop(guard);
13988        let logs = capture.captured();
13989
13990        assert_eq!(config.workspace_dir, workspace_dir.join("workspace"));
13991        assert_eq!(config.config_path, config_path);
13992        assert_eq!(config.default_model.as_deref(), Some("persisted-profile"));
13993        assert!(logs.contains("Config loaded"), "{logs}");
13994        assert!(logs.contains("initialized=true"), "{logs}");
13995        assert!(!logs.contains("initialized=false"), "{logs}");
13996
13997        // SAFETY: test-only, single-threaded test runner.
13998        unsafe { std::env::remove_var("CONSTRUCT_WORKSPACE") };
13999        if let Some(home) = original_home {
14000            // SAFETY: test-only, single-threaded test runner.
14001            unsafe { std::env::set_var("HOME", home) };
14002        } else {
14003            // SAFETY: test-only, single-threaded test runner.
14004            unsafe { std::env::remove_var("HOME") };
14005        }
14006        let _ = fs::remove_dir_all(temp_home).await;
14007    }
14008
14009    #[test]
14010    async fn env_override_empty_values_ignored() {
14011        let _env_guard = env_override_lock().await;
14012        let mut config = Config::default();
14013        let original_provider = config.default_provider.clone();
14014
14015        // SAFETY: test-only, single-threaded test runner.
14016        unsafe { std::env::set_var("CONSTRUCT_PROVIDER", "") };
14017        config.apply_env_overrides();
14018        assert_eq!(config.default_provider, original_provider);
14019
14020        // SAFETY: test-only, single-threaded test runner.
14021        unsafe { std::env::remove_var("CONSTRUCT_PROVIDER") };
14022    }
14023
14024    #[test]
14025    async fn env_override_gateway_port() {
14026        let _env_guard = env_override_lock().await;
14027        let mut config = Config::default();
14028        assert_eq!(config.gateway.port, 42617);
14029
14030        // SAFETY: test-only, single-threaded test runner.
14031        unsafe { std::env::set_var("CONSTRUCT_GATEWAY_PORT", "8080") };
14032        config.apply_env_overrides();
14033        assert_eq!(config.gateway.port, 8080);
14034
14035        // SAFETY: test-only, single-threaded test runner.
14036        unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_PORT") };
14037    }
14038
14039    #[test]
14040    async fn env_override_port_fallback() {
14041        let _env_guard = env_override_lock().await;
14042        let mut config = Config::default();
14043
14044        // SAFETY: test-only, single-threaded test runner.
14045        unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_PORT") };
14046        // SAFETY: test-only, single-threaded test runner.
14047        unsafe { std::env::set_var("PORT", "9000") };
14048        config.apply_env_overrides();
14049        assert_eq!(config.gateway.port, 9000);
14050
14051        // SAFETY: test-only, single-threaded test runner.
14052        unsafe { std::env::remove_var("PORT") };
14053    }
14054
14055    #[test]
14056    async fn env_override_gateway_host() {
14057        let _env_guard = env_override_lock().await;
14058        let mut config = Config::default();
14059        assert_eq!(config.gateway.host, "127.0.0.1");
14060
14061        // SAFETY: test-only, single-threaded test runner.
14062        unsafe { std::env::set_var("CONSTRUCT_GATEWAY_HOST", "0.0.0.0") };
14063        config.apply_env_overrides();
14064        assert_eq!(config.gateway.host, "0.0.0.0");
14065
14066        // SAFETY: test-only, single-threaded test runner.
14067        unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_HOST") };
14068    }
14069
14070    #[test]
14071    async fn env_override_host_fallback() {
14072        let _env_guard = env_override_lock().await;
14073        let mut config = Config::default();
14074
14075        // SAFETY: test-only, single-threaded test runner.
14076        unsafe { std::env::remove_var("CONSTRUCT_GATEWAY_HOST") };
14077        // SAFETY: test-only, single-threaded test runner.
14078        unsafe { std::env::set_var("HOST", "0.0.0.0") };
14079        config.apply_env_overrides();
14080        assert_eq!(config.gateway.host, "0.0.0.0");
14081
14082        // SAFETY: test-only, single-threaded test runner.
14083        unsafe { std::env::remove_var("HOST") };
14084    }
14085
14086    #[test]
14087    async fn env_override_require_pairing() {
14088        let _env_guard = env_override_lock().await;
14089        let mut config = Config::default();
14090        assert!(config.gateway.require_pairing);
14091
14092        // SAFETY: test-only, single-threaded test runner.
14093        unsafe { std::env::set_var("CONSTRUCT_REQUIRE_PAIRING", "false") };
14094        config.apply_env_overrides();
14095        assert!(!config.gateway.require_pairing);
14096
14097        // SAFETY: test-only, single-threaded test runner.
14098        unsafe { std::env::set_var("CONSTRUCT_REQUIRE_PAIRING", "true") };
14099        config.apply_env_overrides();
14100        assert!(config.gateway.require_pairing);
14101
14102        // SAFETY: test-only, single-threaded test runner.
14103        unsafe { std::env::remove_var("CONSTRUCT_REQUIRE_PAIRING") };
14104    }
14105
14106    #[test]
14107    async fn env_override_temperature() {
14108        let _env_guard = env_override_lock().await;
14109        let mut config = Config::default();
14110
14111        // SAFETY: test-only, single-threaded test runner.
14112        unsafe { std::env::set_var("CONSTRUCT_TEMPERATURE", "0.5") };
14113        config.apply_env_overrides();
14114        assert!((config.default_temperature - 0.5).abs() < f64::EPSILON);
14115
14116        // SAFETY: test-only, single-threaded test runner.
14117        unsafe { std::env::remove_var("CONSTRUCT_TEMPERATURE") };
14118    }
14119
14120    #[test]
14121    async fn env_override_temperature_out_of_range_ignored() {
14122        let _env_guard = env_override_lock().await;
14123        // Clean up any leftover env vars from other tests
14124        // SAFETY: test-only, single-threaded test runner.
14125        unsafe { std::env::remove_var("CONSTRUCT_TEMPERATURE") };
14126
14127        let mut config = Config::default();
14128        let original_temp = config.default_temperature;
14129
14130        // Temperature > 2.0 should be ignored
14131        // SAFETY: test-only, single-threaded test runner.
14132        unsafe { std::env::set_var("CONSTRUCT_TEMPERATURE", "3.0") };
14133        config.apply_env_overrides();
14134        assert!(
14135            (config.default_temperature - original_temp).abs() < f64::EPSILON,
14136            "Temperature 3.0 should be ignored (out of range)"
14137        );
14138
14139        // SAFETY: test-only, single-threaded test runner.
14140        unsafe { std::env::remove_var("CONSTRUCT_TEMPERATURE") };
14141    }
14142
14143    #[test]
14144    async fn env_override_reasoning_enabled() {
14145        let _env_guard = env_override_lock().await;
14146        let mut config = Config::default();
14147        assert_eq!(config.runtime.reasoning_enabled, None);
14148
14149        // SAFETY: test-only, single-threaded test runner.
14150        unsafe { std::env::set_var("CONSTRUCT_REASONING_ENABLED", "false") };
14151        config.apply_env_overrides();
14152        assert_eq!(config.runtime.reasoning_enabled, Some(false));
14153
14154        // SAFETY: test-only, single-threaded test runner.
14155        unsafe { std::env::set_var("CONSTRUCT_REASONING_ENABLED", "true") };
14156        config.apply_env_overrides();
14157        assert_eq!(config.runtime.reasoning_enabled, Some(true));
14158
14159        // SAFETY: test-only, single-threaded test runner.
14160        unsafe { std::env::remove_var("CONSTRUCT_REASONING_ENABLED") };
14161    }
14162
14163    #[test]
14164    async fn env_override_reasoning_invalid_value_ignored() {
14165        let _env_guard = env_override_lock().await;
14166        let mut config = Config::default();
14167        config.runtime.reasoning_enabled = Some(false);
14168
14169        // SAFETY: test-only, single-threaded test runner.
14170        unsafe { std::env::set_var("CONSTRUCT_REASONING_ENABLED", "maybe") };
14171        config.apply_env_overrides();
14172        assert_eq!(config.runtime.reasoning_enabled, Some(false));
14173
14174        // SAFETY: test-only, single-threaded test runner.
14175        unsafe { std::env::remove_var("CONSTRUCT_REASONING_ENABLED") };
14176    }
14177
14178    #[test]
14179    async fn env_override_reasoning_effort() {
14180        let _env_guard = env_override_lock().await;
14181        let mut config = Config::default();
14182        assert_eq!(config.runtime.reasoning_effort, None);
14183
14184        // SAFETY: test-only, single-threaded test runner.
14185        unsafe { std::env::set_var("CONSTRUCT_REASONING_EFFORT", "HIGH") };
14186        config.apply_env_overrides();
14187        assert_eq!(config.runtime.reasoning_effort.as_deref(), Some("high"));
14188
14189        // SAFETY: test-only, single-threaded test runner.
14190        unsafe { std::env::remove_var("CONSTRUCT_REASONING_EFFORT") };
14191    }
14192
14193    #[test]
14194    async fn env_override_reasoning_effort_legacy_codex_env() {
14195        let _env_guard = env_override_lock().await;
14196        let mut config = Config::default();
14197
14198        // SAFETY: test-only, single-threaded test runner.
14199        unsafe { std::env::set_var("CONSTRUCT_CODEX_REASONING_EFFORT", "minimal") };
14200        config.apply_env_overrides();
14201        assert_eq!(config.runtime.reasoning_effort.as_deref(), Some("minimal"));
14202
14203        // SAFETY: test-only, single-threaded test runner.
14204        unsafe { std::env::remove_var("CONSTRUCT_CODEX_REASONING_EFFORT") };
14205    }
14206
14207    #[test]
14208    async fn env_override_invalid_port_ignored() {
14209        let _env_guard = env_override_lock().await;
14210        let mut config = Config::default();
14211        let original_port = config.gateway.port;
14212
14213        // SAFETY: test-only, single-threaded test runner.
14214        unsafe { std::env::set_var("PORT", "not_a_number") };
14215        config.apply_env_overrides();
14216        assert_eq!(config.gateway.port, original_port);
14217
14218        // SAFETY: test-only, single-threaded test runner.
14219        unsafe { std::env::remove_var("PORT") };
14220    }
14221
14222    #[test]
14223    async fn env_override_web_search_config() {
14224        let _env_guard = env_override_lock().await;
14225        let mut config = Config::default();
14226
14227        // SAFETY: test-only, single-threaded test runner.
14228        unsafe { std::env::set_var("WEB_SEARCH_ENABLED", "false") };
14229        // SAFETY: test-only, single-threaded test runner.
14230        unsafe { std::env::set_var("WEB_SEARCH_PROVIDER", "brave") };
14231        // SAFETY: test-only, single-threaded test runner.
14232        unsafe { std::env::set_var("WEB_SEARCH_MAX_RESULTS", "7") };
14233        // SAFETY: test-only, single-threaded test runner.
14234        unsafe { std::env::set_var("WEB_SEARCH_TIMEOUT_SECS", "20") };
14235        // SAFETY: test-only, single-threaded test runner.
14236        unsafe { std::env::set_var("BRAVE_API_KEY", "brave-test-key") };
14237
14238        config.apply_env_overrides();
14239
14240        assert!(!config.web_search.enabled);
14241        assert_eq!(config.web_search.provider, "brave");
14242        assert_eq!(config.web_search.max_results, 7);
14243        assert_eq!(config.web_search.timeout_secs, 20);
14244        assert_eq!(
14245            config.web_search.brave_api_key.as_deref(),
14246            Some("brave-test-key")
14247        );
14248
14249        // SAFETY: test-only, single-threaded test runner.
14250        unsafe { std::env::remove_var("WEB_SEARCH_ENABLED") };
14251        // SAFETY: test-only, single-threaded test runner.
14252        unsafe { std::env::remove_var("WEB_SEARCH_PROVIDER") };
14253        // SAFETY: test-only, single-threaded test runner.
14254        unsafe { std::env::remove_var("WEB_SEARCH_MAX_RESULTS") };
14255        // SAFETY: test-only, single-threaded test runner.
14256        unsafe { std::env::remove_var("WEB_SEARCH_TIMEOUT_SECS") };
14257        // SAFETY: test-only, single-threaded test runner.
14258        unsafe { std::env::remove_var("BRAVE_API_KEY") };
14259    }
14260
14261    #[test]
14262    async fn env_override_web_search_invalid_values_ignored() {
14263        let _env_guard = env_override_lock().await;
14264        let mut config = Config::default();
14265        let original_max_results = config.web_search.max_results;
14266        let original_timeout = config.web_search.timeout_secs;
14267
14268        // SAFETY: test-only, single-threaded test runner.
14269        unsafe { std::env::set_var("WEB_SEARCH_MAX_RESULTS", "99") };
14270        // SAFETY: test-only, single-threaded test runner.
14271        unsafe { std::env::set_var("WEB_SEARCH_TIMEOUT_SECS", "0") };
14272
14273        config.apply_env_overrides();
14274
14275        assert_eq!(config.web_search.max_results, original_max_results);
14276        assert_eq!(config.web_search.timeout_secs, original_timeout);
14277
14278        // SAFETY: test-only, single-threaded test runner.
14279        unsafe { std::env::remove_var("WEB_SEARCH_MAX_RESULTS") };
14280        // SAFETY: test-only, single-threaded test runner.
14281        unsafe { std::env::remove_var("WEB_SEARCH_TIMEOUT_SECS") };
14282    }
14283
14284    #[test]
14285    async fn env_override_storage_provider_config() {
14286        let _env_guard = env_override_lock().await;
14287        let mut config = Config::default();
14288
14289        // SAFETY: test-only, single-threaded test runner.
14290        unsafe { std::env::set_var("CONSTRUCT_STORAGE_PROVIDER", "qdrant") };
14291        // SAFETY: test-only, single-threaded test runner.
14292        unsafe { std::env::set_var("CONSTRUCT_STORAGE_DB_URL", "http://localhost:6333") };
14293        // SAFETY: test-only, single-threaded test runner.
14294        unsafe { std::env::set_var("CONSTRUCT_STORAGE_CONNECT_TIMEOUT_SECS", "15") };
14295
14296        config.apply_env_overrides();
14297
14298        assert_eq!(config.storage.provider.config.provider, "qdrant");
14299        assert_eq!(
14300            config.storage.provider.config.db_url.as_deref(),
14301            Some("http://localhost:6333")
14302        );
14303        assert_eq!(
14304            config.storage.provider.config.connect_timeout_secs,
14305            Some(15)
14306        );
14307
14308        // SAFETY: test-only, single-threaded test runner.
14309        unsafe { std::env::remove_var("CONSTRUCT_STORAGE_PROVIDER") };
14310        // SAFETY: test-only, single-threaded test runner.
14311        unsafe { std::env::remove_var("CONSTRUCT_STORAGE_DB_URL") };
14312        // SAFETY: test-only, single-threaded test runner.
14313        unsafe { std::env::remove_var("CONSTRUCT_STORAGE_CONNECT_TIMEOUT_SECS") };
14314    }
14315
14316    #[test]
14317    async fn proxy_config_scope_services_requires_entries_when_enabled() {
14318        let proxy = ProxyConfig {
14319            enabled: true,
14320            http_proxy: Some("http://127.0.0.1:7890".into()),
14321            https_proxy: None,
14322            all_proxy: None,
14323            no_proxy: Vec::new(),
14324            scope: ProxyScope::Services,
14325            services: Vec::new(),
14326        };
14327
14328        let error = proxy.validate().unwrap_err().to_string();
14329        assert!(error.contains("proxy.scope='services'"));
14330    }
14331
14332    #[test]
14333    async fn env_override_proxy_scope_services() {
14334        let _env_guard = env_override_lock().await;
14335        clear_proxy_env_test_vars();
14336
14337        let mut config = Config::default();
14338        // SAFETY: test-only, single-threaded test runner.
14339        unsafe { std::env::set_var("CONSTRUCT_PROXY_ENABLED", "true") };
14340        // SAFETY: test-only, single-threaded test runner.
14341        unsafe { std::env::set_var("CONSTRUCT_HTTP_PROXY", "http://127.0.0.1:7890") };
14342        // SAFETY: test-only, single-threaded test runner.
14343        unsafe {
14344            std::env::set_var(
14345                "CONSTRUCT_PROXY_SERVICES",
14346                "provider.openai, tool.http_request",
14347            );
14348        }
14349        // SAFETY: test-only, single-threaded test runner.
14350        unsafe { std::env::set_var("CONSTRUCT_PROXY_SCOPE", "services") };
14351
14352        config.apply_env_overrides();
14353
14354        assert!(config.proxy.enabled);
14355        assert_eq!(config.proxy.scope, ProxyScope::Services);
14356        assert_eq!(
14357            config.proxy.http_proxy.as_deref(),
14358            Some("http://127.0.0.1:7890")
14359        );
14360        assert!(config.proxy.should_apply_to_service("provider.openai"));
14361        assert!(config.proxy.should_apply_to_service("tool.http_request"));
14362        assert!(!config.proxy.should_apply_to_service("provider.anthropic"));
14363
14364        clear_proxy_env_test_vars();
14365    }
14366
14367    #[test]
14368    async fn env_override_proxy_scope_environment_applies_process_env() {
14369        let _env_guard = env_override_lock().await;
14370        clear_proxy_env_test_vars();
14371
14372        let mut config = Config::default();
14373        // SAFETY: test-only, single-threaded test runner.
14374        unsafe { std::env::set_var("CONSTRUCT_PROXY_ENABLED", "true") };
14375        // SAFETY: test-only, single-threaded test runner.
14376        unsafe { std::env::set_var("CONSTRUCT_PROXY_SCOPE", "environment") };
14377        // SAFETY: test-only, single-threaded test runner.
14378        unsafe { std::env::set_var("CONSTRUCT_HTTP_PROXY", "http://127.0.0.1:7890") };
14379        // SAFETY: test-only, single-threaded test runner.
14380        unsafe { std::env::set_var("CONSTRUCT_HTTPS_PROXY", "http://127.0.0.1:7891") };
14381        // SAFETY: test-only, single-threaded test runner.
14382        unsafe { std::env::set_var("CONSTRUCT_NO_PROXY", "localhost,127.0.0.1") };
14383
14384        config.apply_env_overrides();
14385
14386        assert_eq!(config.proxy.scope, ProxyScope::Environment);
14387        assert_eq!(
14388            std::env::var("HTTP_PROXY").ok().as_deref(),
14389            Some("http://127.0.0.1:7890")
14390        );
14391        assert_eq!(
14392            std::env::var("HTTPS_PROXY").ok().as_deref(),
14393            Some("http://127.0.0.1:7891")
14394        );
14395        assert!(
14396            std::env::var("NO_PROXY")
14397                .ok()
14398                .is_some_and(|value| value.contains("localhost"))
14399        );
14400
14401        clear_proxy_env_test_vars();
14402    }
14403
14404    #[test]
14405    async fn google_workspace_allowed_operations_require_methods() {
14406        let mut config = Config::default();
14407        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
14408            service: "gmail".into(),
14409            resource: "users".into(),
14410            sub_resource: Some("drafts".into()),
14411            methods: Vec::new(),
14412        }];
14413
14414        let err = config.validate().unwrap_err().to_string();
14415        assert!(err.contains("google_workspace.allowed_operations[0].methods"));
14416    }
14417
14418    #[test]
14419    async fn google_workspace_allowed_operations_reject_duplicate_service_resource_sub_resource_entries()
14420     {
14421        let mut config = Config::default();
14422        config.google_workspace.allowed_operations = vec![
14423            GoogleWorkspaceAllowedOperation {
14424                service: "gmail".into(),
14425                resource: "users".into(),
14426                sub_resource: Some("drafts".into()),
14427                methods: vec!["create".into()],
14428            },
14429            GoogleWorkspaceAllowedOperation {
14430                service: "gmail".into(),
14431                resource: "users".into(),
14432                sub_resource: Some("drafts".into()),
14433                methods: vec!["update".into()],
14434            },
14435        ];
14436
14437        let err = config.validate().unwrap_err().to_string();
14438        assert!(err.contains("duplicate service/resource/sub_resource entry"));
14439    }
14440
14441    #[test]
14442    async fn google_workspace_allowed_operations_allow_same_resource_different_sub_resource() {
14443        let mut config = Config::default();
14444        config.google_workspace.allowed_operations = vec![
14445            GoogleWorkspaceAllowedOperation {
14446                service: "gmail".into(),
14447                resource: "users".into(),
14448                sub_resource: Some("messages".into()),
14449                methods: vec!["list".into(), "get".into()],
14450            },
14451            GoogleWorkspaceAllowedOperation {
14452                service: "gmail".into(),
14453                resource: "users".into(),
14454                sub_resource: Some("drafts".into()),
14455                methods: vec!["create".into(), "update".into()],
14456            },
14457        ];
14458
14459        assert!(config.validate().is_ok());
14460    }
14461
14462    #[test]
14463    async fn google_workspace_allowed_operations_reject_duplicate_methods_within_entry() {
14464        let mut config = Config::default();
14465        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
14466            service: "gmail".into(),
14467            resource: "users".into(),
14468            sub_resource: Some("drafts".into()),
14469            methods: vec!["create".into(), "create".into()],
14470        }];
14471
14472        let err = config.validate().unwrap_err().to_string();
14473        assert!(
14474            err.contains("duplicate entry"),
14475            "expected duplicate entry error, got: {err}"
14476        );
14477    }
14478
14479    #[test]
14480    async fn google_workspace_allowed_operations_accept_valid_entries() {
14481        let mut config = Config::default();
14482        config.google_workspace.allowed_operations = vec![
14483            GoogleWorkspaceAllowedOperation {
14484                service: "gmail".into(),
14485                resource: "users".into(),
14486                sub_resource: Some("messages".into()),
14487                methods: vec!["list".into(), "get".into()],
14488            },
14489            GoogleWorkspaceAllowedOperation {
14490                service: "drive".into(),
14491                resource: "files".into(),
14492                sub_resource: None,
14493                methods: vec!["list".into(), "get".into()],
14494            },
14495        ];
14496
14497        assert!(config.validate().is_ok());
14498    }
14499
14500    #[test]
14501    async fn google_workspace_allowed_operations_reject_invalid_sub_resource_characters() {
14502        let mut config = Config::default();
14503        config.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
14504            service: "gmail".into(),
14505            resource: "users".into(),
14506            sub_resource: Some("bad resource!".into()),
14507            methods: vec!["list".into()],
14508        }];
14509
14510        let err = config.validate().unwrap_err().to_string();
14511        assert!(err.contains("sub_resource contains invalid characters"));
14512    }
14513
14514    fn runtime_proxy_cache_contains(cache_key: &str) -> bool {
14515        match runtime_proxy_client_cache().read() {
14516            Ok(guard) => guard.contains_key(cache_key),
14517            Err(poisoned) => poisoned.into_inner().contains_key(cache_key),
14518        }
14519    }
14520
14521    #[test]
14522    async fn runtime_proxy_client_cache_reuses_default_profile_key() {
14523        let service_key = format!(
14524            "provider.cache_test.{}",
14525            std::time::SystemTime::now()
14526                .duration_since(std::time::UNIX_EPOCH)
14527                .expect("system clock should be after unix epoch")
14528                .as_nanos()
14529        );
14530        let cache_key = runtime_proxy_cache_key(&service_key, None, None);
14531
14532        clear_runtime_proxy_client_cache();
14533        assert!(!runtime_proxy_cache_contains(&cache_key));
14534
14535        let _ = build_runtime_proxy_client(&service_key);
14536        assert!(runtime_proxy_cache_contains(&cache_key));
14537
14538        let _ = build_runtime_proxy_client(&service_key);
14539        assert!(runtime_proxy_cache_contains(&cache_key));
14540    }
14541
14542    #[test]
14543    async fn set_runtime_proxy_config_clears_runtime_proxy_client_cache() {
14544        let service_key = format!(
14545            "provider.cache_timeout_test.{}",
14546            std::time::SystemTime::now()
14547                .duration_since(std::time::UNIX_EPOCH)
14548                .expect("system clock should be after unix epoch")
14549                .as_nanos()
14550        );
14551        let cache_key = runtime_proxy_cache_key(&service_key, Some(30), Some(5));
14552
14553        clear_runtime_proxy_client_cache();
14554        let _ = build_runtime_proxy_client_with_timeouts(&service_key, 30, 5);
14555        assert!(runtime_proxy_cache_contains(&cache_key));
14556
14557        set_runtime_proxy_config(ProxyConfig::default());
14558        assert!(!runtime_proxy_cache_contains(&cache_key));
14559    }
14560
14561    #[test]
14562    async fn gateway_config_default_values() {
14563        let g = GatewayConfig::default();
14564        assert_eq!(g.port, 42617);
14565        assert_eq!(g.host, "127.0.0.1");
14566        assert!(g.require_pairing);
14567        assert!(!g.allow_public_bind);
14568        assert!(g.paired_tokens.is_empty());
14569        assert!(!g.trust_forwarded_headers);
14570        assert_eq!(g.rate_limit_max_keys, 10_000);
14571        assert_eq!(g.idempotency_max_keys, 10_000);
14572    }
14573
14574    // ── Peripherals config ───────────────────────────────────────
14575
14576    #[test]
14577    async fn peripherals_config_default_disabled() {
14578        let p = PeripheralsConfig::default();
14579        assert!(!p.enabled);
14580        assert!(p.boards.is_empty());
14581    }
14582
14583    #[test]
14584    async fn peripheral_board_config_defaults() {
14585        let b = PeripheralBoardConfig::default();
14586        assert!(b.board.is_empty());
14587        assert_eq!(b.transport, "serial");
14588        assert!(b.path.is_none());
14589        assert_eq!(b.baud, 115_200);
14590    }
14591
14592    #[test]
14593    async fn peripherals_config_toml_roundtrip() {
14594        let p = PeripheralsConfig {
14595            enabled: true,
14596            boards: vec![PeripheralBoardConfig {
14597                board: "nucleo-f401re".into(),
14598                transport: "serial".into(),
14599                path: Some("/dev/ttyACM0".into()),
14600                baud: 115_200,
14601            }],
14602            datasheet_dir: None,
14603        };
14604        let toml_str = toml::to_string(&p).unwrap();
14605        let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
14606        assert!(parsed.enabled);
14607        assert_eq!(parsed.boards.len(), 1);
14608        assert_eq!(parsed.boards[0].board, "nucleo-f401re");
14609        assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
14610    }
14611
14612    #[test]
14613    async fn lark_config_serde() {
14614        let lc = LarkConfig {
14615            app_id: "cli_123456".into(),
14616            app_secret: "secret_abc".into(),
14617            encrypt_key: Some("encrypt_key".into()),
14618            verification_token: Some("verify_token".into()),
14619            allowed_users: vec!["user_123".into(), "user_456".into()],
14620            mention_only: false,
14621            use_feishu: true,
14622            receive_mode: LarkReceiveMode::Websocket,
14623            port: None,
14624            proxy_url: None,
14625        };
14626        let json = serde_json::to_string(&lc).unwrap();
14627        let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
14628        assert_eq!(parsed.app_id, "cli_123456");
14629        assert_eq!(parsed.app_secret, "secret_abc");
14630        assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
14631        assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
14632        assert_eq!(parsed.allowed_users.len(), 2);
14633        assert!(parsed.use_feishu);
14634    }
14635
14636    #[test]
14637    async fn lark_config_toml_roundtrip() {
14638        let lc = LarkConfig {
14639            app_id: "cli_123456".into(),
14640            app_secret: "secret_abc".into(),
14641            encrypt_key: Some("encrypt_key".into()),
14642            verification_token: Some("verify_token".into()),
14643            allowed_users: vec!["*".into()],
14644            mention_only: false,
14645            use_feishu: false,
14646            receive_mode: LarkReceiveMode::Webhook,
14647            port: Some(9898),
14648            proxy_url: None,
14649        };
14650        let toml_str = toml::to_string(&lc).unwrap();
14651        let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
14652        assert_eq!(parsed.app_id, "cli_123456");
14653        assert_eq!(parsed.app_secret, "secret_abc");
14654        assert!(!parsed.use_feishu);
14655    }
14656
14657    #[test]
14658    async fn lark_config_deserializes_without_optional_fields() {
14659        let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
14660        let parsed: LarkConfig = serde_json::from_str(json).unwrap();
14661        assert!(parsed.encrypt_key.is_none());
14662        assert!(parsed.verification_token.is_none());
14663        assert!(parsed.allowed_users.is_empty());
14664        assert!(!parsed.mention_only);
14665        assert!(!parsed.use_feishu);
14666    }
14667
14668    #[test]
14669    async fn lark_config_defaults_to_lark_endpoint() {
14670        let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
14671        let parsed: LarkConfig = serde_json::from_str(json).unwrap();
14672        assert!(
14673            !parsed.use_feishu,
14674            "use_feishu should default to false (Lark)"
14675        );
14676    }
14677
14678    #[test]
14679    async fn lark_config_with_wildcard_allowed_users() {
14680        let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#;
14681        let parsed: LarkConfig = serde_json::from_str(json).unwrap();
14682        assert_eq!(parsed.allowed_users, vec!["*"]);
14683    }
14684
14685    #[test]
14686    async fn feishu_config_serde() {
14687        let fc = FeishuConfig {
14688            app_id: "cli_feishu_123".into(),
14689            app_secret: "secret_abc".into(),
14690            encrypt_key: Some("encrypt_key".into()),
14691            verification_token: Some("verify_token".into()),
14692            allowed_users: vec!["user_123".into(), "user_456".into()],
14693            receive_mode: LarkReceiveMode::Websocket,
14694            port: None,
14695            proxy_url: None,
14696        };
14697        let json = serde_json::to_string(&fc).unwrap();
14698        let parsed: FeishuConfig = serde_json::from_str(&json).unwrap();
14699        assert_eq!(parsed.app_id, "cli_feishu_123");
14700        assert_eq!(parsed.app_secret, "secret_abc");
14701        assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
14702        assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
14703        assert_eq!(parsed.allowed_users.len(), 2);
14704    }
14705
14706    #[test]
14707    async fn feishu_config_toml_roundtrip() {
14708        let fc = FeishuConfig {
14709            app_id: "cli_feishu_123".into(),
14710            app_secret: "secret_abc".into(),
14711            encrypt_key: Some("encrypt_key".into()),
14712            verification_token: Some("verify_token".into()),
14713            allowed_users: vec!["*".into()],
14714            receive_mode: LarkReceiveMode::Webhook,
14715            port: Some(9898),
14716            proxy_url: None,
14717        };
14718        let toml_str = toml::to_string(&fc).unwrap();
14719        let parsed: FeishuConfig = toml::from_str(&toml_str).unwrap();
14720        assert_eq!(parsed.app_id, "cli_feishu_123");
14721        assert_eq!(parsed.app_secret, "secret_abc");
14722        assert_eq!(parsed.receive_mode, LarkReceiveMode::Webhook);
14723        assert_eq!(parsed.port, Some(9898));
14724    }
14725
14726    #[test]
14727    async fn feishu_config_deserializes_without_optional_fields() {
14728        let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
14729        let parsed: FeishuConfig = serde_json::from_str(json).unwrap();
14730        assert!(parsed.encrypt_key.is_none());
14731        assert!(parsed.verification_token.is_none());
14732        assert!(parsed.allowed_users.is_empty());
14733        assert_eq!(parsed.receive_mode, LarkReceiveMode::Websocket);
14734        assert!(parsed.port.is_none());
14735    }
14736
14737    #[test]
14738    async fn nextcloud_talk_config_serde() {
14739        let nc = NextcloudTalkConfig {
14740            base_url: "https://cloud.example.com".into(),
14741            app_token: "app-token".into(),
14742            webhook_secret: Some("webhook-secret".into()),
14743            allowed_users: vec!["user_a".into(), "*".into()],
14744            proxy_url: None,
14745            bot_name: None,
14746        };
14747
14748        let json = serde_json::to_string(&nc).unwrap();
14749        let parsed: NextcloudTalkConfig = serde_json::from_str(&json).unwrap();
14750        assert_eq!(parsed.base_url, "https://cloud.example.com");
14751        assert_eq!(parsed.app_token, "app-token");
14752        assert_eq!(parsed.webhook_secret.as_deref(), Some("webhook-secret"));
14753        assert_eq!(parsed.allowed_users, vec!["user_a", "*"]);
14754    }
14755
14756    #[test]
14757    async fn nextcloud_talk_config_defaults_optional_fields() {
14758        let json = r#"{"base_url":"https://cloud.example.com","app_token":"app-token"}"#;
14759        let parsed: NextcloudTalkConfig = serde_json::from_str(json).unwrap();
14760        assert!(parsed.webhook_secret.is_none());
14761        assert!(parsed.allowed_users.is_empty());
14762    }
14763
14764    // ── Config file permission hardening (Unix only) ───────────────
14765
14766    #[cfg(unix)]
14767    #[test]
14768    async fn new_config_file_has_restricted_permissions() {
14769        let tmp = tempfile::TempDir::new().unwrap();
14770        let config_path = tmp.path().join("config.toml");
14771
14772        // Create a config and save it
14773        let mut config = Config::default();
14774        config.config_path = config_path.clone();
14775        config.save().await.unwrap();
14776
14777        let meta = fs::metadata(&config_path).await.unwrap();
14778        let mode = meta.permissions().mode() & 0o777;
14779        assert_eq!(
14780            mode, 0o600,
14781            "New config file should be owner-only (0600), got {mode:o}"
14782        );
14783    }
14784
14785    #[cfg(unix)]
14786    #[test]
14787    async fn save_restricts_existing_world_readable_config_to_owner_only() {
14788        let tmp = tempfile::TempDir::new().unwrap();
14789        let config_path = tmp.path().join("config.toml");
14790
14791        let mut config = Config::default();
14792        config.config_path = config_path.clone();
14793        config.save().await.unwrap();
14794
14795        // Simulate the regression state observed in issue #1345.
14796        std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
14797        let loose_mode = std::fs::metadata(&config_path)
14798            .unwrap()
14799            .permissions()
14800            .mode()
14801            & 0o777;
14802        assert_eq!(
14803            loose_mode, 0o644,
14804            "test setup requires world-readable config"
14805        );
14806
14807        config.default_temperature = 0.6;
14808        config.save().await.unwrap();
14809
14810        let hardened_mode = std::fs::metadata(&config_path)
14811            .unwrap()
14812            .permissions()
14813            .mode()
14814            & 0o777;
14815        assert_eq!(
14816            hardened_mode, 0o600,
14817            "Saving config should restore owner-only permissions (0600)"
14818        );
14819    }
14820
14821    #[cfg(unix)]
14822    #[test]
14823    async fn world_readable_config_is_detectable() {
14824        use std::os::unix::fs::PermissionsExt;
14825
14826        let tmp = tempfile::TempDir::new().unwrap();
14827        let config_path = tmp.path().join("config.toml");
14828
14829        // Create a config file with intentionally loose permissions
14830        std::fs::write(&config_path, "# test config").unwrap();
14831        std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
14832
14833        let meta = std::fs::metadata(&config_path).unwrap();
14834        let mode = meta.permissions().mode();
14835        assert!(
14836            mode & 0o004 != 0,
14837            "Test setup: file should be world-readable (mode {mode:o})"
14838        );
14839    }
14840
14841    #[test]
14842    async fn transcription_config_defaults() {
14843        let tc = TranscriptionConfig::default();
14844        assert!(!tc.enabled);
14845        assert!(tc.api_url.contains("groq.com"));
14846        assert_eq!(tc.model, "whisper-large-v3-turbo");
14847        assert!(tc.language.is_none());
14848        assert_eq!(tc.max_duration_secs, 120);
14849        assert!(!tc.transcribe_non_ptt_audio);
14850    }
14851
14852    #[test]
14853    async fn config_roundtrip_with_transcription() {
14854        let mut config = Config::default();
14855        config.transcription.enabled = true;
14856        config.transcription.language = Some("en".into());
14857
14858        let toml_str = toml::to_string_pretty(&config).unwrap();
14859        let parsed = parse_test_config(&toml_str);
14860
14861        assert!(parsed.transcription.enabled);
14862        assert_eq!(parsed.transcription.language.as_deref(), Some("en"));
14863        assert_eq!(parsed.transcription.model, "whisper-large-v3-turbo");
14864    }
14865
14866    #[test]
14867    async fn config_without_transcription_uses_defaults() {
14868        let toml_str = r#"
14869            default_provider = "openrouter"
14870            default_model = "test-model"
14871            default_temperature = 0.7
14872        "#;
14873        let parsed = parse_test_config(toml_str);
14874        assert!(!parsed.transcription.enabled);
14875        assert_eq!(parsed.transcription.max_duration_secs, 120);
14876    }
14877
14878    #[test]
14879    async fn security_defaults_are_backward_compatible() {
14880        let parsed = parse_test_config(
14881            r#"
14882default_provider = "openrouter"
14883default_model = "anthropic/claude-sonnet-4.6"
14884default_temperature = 0.7
14885"#,
14886        );
14887
14888        assert!(!parsed.security.otp.enabled);
14889        assert_eq!(parsed.security.otp.method, OtpMethod::Totp);
14890        assert!(!parsed.security.estop.enabled);
14891        assert!(parsed.security.estop.require_otp_to_resume);
14892    }
14893
14894    #[test]
14895    async fn security_toml_parses_otp_and_estop_sections() {
14896        let parsed = parse_test_config(
14897            r#"
14898default_provider = "openrouter"
14899default_model = "anthropic/claude-sonnet-4.6"
14900default_temperature = 0.7
14901
14902[security.otp]
14903enabled = true
14904method = "totp"
14905token_ttl_secs = 30
14906cache_valid_secs = 120
14907gated_actions = ["shell", "browser_open"]
14908gated_domains = ["*.chase.com", "accounts.google.com"]
14909gated_domain_categories = ["banking"]
14910
14911[security.estop]
14912enabled = true
14913state_file = "~/.construct/estop-state.json"
14914require_otp_to_resume = true
14915"#,
14916        );
14917
14918        assert!(parsed.security.otp.enabled);
14919        assert!(parsed.security.estop.enabled);
14920        assert_eq!(parsed.security.otp.gated_actions.len(), 2);
14921        assert_eq!(parsed.security.otp.gated_domains.len(), 2);
14922        parsed.validate().unwrap();
14923    }
14924
14925    #[test]
14926    async fn security_validation_rejects_invalid_domain_glob() {
14927        let mut config = Config::default();
14928        config.security.otp.gated_domains = vec!["bad domain.com".into()];
14929
14930        let err = config.validate().expect_err("expected invalid domain glob");
14931        assert!(err.to_string().contains("gated_domains"));
14932    }
14933
14934    #[test]
14935    async fn validate_accepts_local_whisper_as_transcription_default_provider() {
14936        let mut config = Config::default();
14937        config.transcription.default_provider = "local_whisper".to_string();
14938
14939        config.validate().expect(
14940            "local_whisper must be accepted by the transcription.default_provider allowlist",
14941        );
14942    }
14943
14944    #[test]
14945    async fn validate_rejects_unknown_transcription_default_provider() {
14946        let mut config = Config::default();
14947        config.transcription.default_provider = "unknown_stt".to_string();
14948
14949        let err = config
14950            .validate()
14951            .expect_err("expected validation to reject unknown transcription provider");
14952        assert!(
14953            err.to_string().contains("transcription.default_provider"),
14954            "got: {err}"
14955        );
14956    }
14957
14958    #[tokio::test]
14959    async fn channel_secret_telegram_bot_token_roundtrip() {
14960        let dir = std::env::temp_dir().join(format!(
14961            "construct_test_tg_bot_token_{}",
14962            uuid::Uuid::new_v4()
14963        ));
14964        fs::create_dir_all(&dir).await.unwrap();
14965
14966        let plaintext_token = "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11";
14967
14968        let mut config = Config::default();
14969        config.workspace_dir = dir.join("workspace");
14970        config.config_path = dir.join("config.toml");
14971        config.channels_config.telegram = Some(TelegramConfig {
14972            bot_token: plaintext_token.into(),
14973            allowed_users: vec!["user1".into()],
14974            stream_mode: StreamMode::default(),
14975            draft_update_interval_ms: default_draft_update_interval_ms(),
14976            interrupt_on_new_message: false,
14977            mention_only: false,
14978            ack_reactions: None,
14979            proxy_url: None,
14980            notification_chat_id: None,
14981        });
14982
14983        // Save (triggers encryption)
14984        config.save().await.unwrap();
14985
14986        // Read raw TOML and verify plaintext token is NOT present
14987        let raw_toml = tokio::fs::read_to_string(&config.config_path)
14988            .await
14989            .unwrap();
14990        assert!(
14991            !raw_toml.contains(plaintext_token),
14992            "Saved TOML must not contain the plaintext bot_token"
14993        );
14994
14995        // Parse stored TOML and verify the value is encrypted
14996        let stored: Config = toml::from_str(&raw_toml).unwrap();
14997        let stored_token = &stored.channels_config.telegram.as_ref().unwrap().bot_token;
14998        assert!(
14999            crate::security::SecretStore::is_encrypted(stored_token),
15000            "Stored bot_token must be marked as encrypted"
15001        );
15002
15003        // Decrypt and verify it matches the original plaintext
15004        let store = crate::security::SecretStore::new(&dir, true);
15005        assert_eq!(store.decrypt(stored_token).unwrap(), plaintext_token);
15006
15007        // Simulate a full load: deserialize then decrypt (mirrors load_or_init logic)
15008        let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
15009        loaded.config_path = dir.join("config.toml");
15010        let load_store = crate::security::SecretStore::new(&dir, loaded.secrets.encrypt);
15011        if let Some(ref mut tg) = loaded.channels_config.telegram {
15012            decrypt_secret(
15013                &load_store,
15014                &mut tg.bot_token,
15015                "config.channels_config.telegram.bot_token",
15016            )
15017            .unwrap();
15018        }
15019        assert_eq!(
15020            loaded.channels_config.telegram.as_ref().unwrap().bot_token,
15021            plaintext_token,
15022            "Loaded bot_token must match the original plaintext after decryption"
15023        );
15024
15025        let _ = fs::remove_dir_all(&dir).await;
15026    }
15027
15028    #[test]
15029    async fn security_validation_rejects_unknown_domain_category() {
15030        let mut config = Config::default();
15031        config.security.otp.gated_domain_categories = vec!["not_real".into()];
15032
15033        let err = config
15034            .validate()
15035            .expect_err("expected unknown domain category");
15036        assert!(err.to_string().contains("gated_domain_categories"));
15037    }
15038
15039    #[test]
15040    async fn security_validation_rejects_zero_token_ttl() {
15041        let mut config = Config::default();
15042        config.security.otp.token_ttl_secs = 0;
15043
15044        let err = config
15045            .validate()
15046            .expect_err("expected ttl validation failure");
15047        assert!(err.to_string().contains("token_ttl_secs"));
15048    }
15049
15050    // ── MCP config validation ─────────────────────────────────────────────
15051
15052    fn stdio_server(name: &str, command: &str) -> McpServerConfig {
15053        McpServerConfig {
15054            name: name.to_string(),
15055            transport: McpTransport::Stdio,
15056            command: command.to_string(),
15057            ..Default::default()
15058        }
15059    }
15060
15061    fn http_server(name: &str, url: &str) -> McpServerConfig {
15062        McpServerConfig {
15063            name: name.to_string(),
15064            transport: McpTransport::Http,
15065            url: Some(url.to_string()),
15066            ..Default::default()
15067        }
15068    }
15069
15070    fn sse_server(name: &str, url: &str) -> McpServerConfig {
15071        McpServerConfig {
15072            name: name.to_string(),
15073            transport: McpTransport::Sse,
15074            url: Some(url.to_string()),
15075            ..Default::default()
15076        }
15077    }
15078
15079    #[test]
15080    async fn validate_mcp_config_empty_servers_ok() {
15081        let cfg = McpConfig::default();
15082        assert!(validate_mcp_config(&cfg).is_ok());
15083    }
15084
15085    #[test]
15086    async fn validate_mcp_config_valid_stdio_ok() {
15087        let cfg = McpConfig {
15088            enabled: true,
15089            servers: vec![stdio_server("fs", "/usr/bin/mcp-fs")],
15090            ..Default::default()
15091        };
15092        assert!(validate_mcp_config(&cfg).is_ok());
15093    }
15094
15095    #[test]
15096    async fn validate_mcp_config_valid_http_ok() {
15097        let cfg = McpConfig {
15098            enabled: true,
15099            servers: vec![http_server("svc", "http://localhost:8080/mcp")],
15100            ..Default::default()
15101        };
15102        assert!(validate_mcp_config(&cfg).is_ok());
15103    }
15104
15105    #[test]
15106    async fn validate_mcp_config_valid_sse_ok() {
15107        let cfg = McpConfig {
15108            enabled: true,
15109            servers: vec![sse_server("svc", "https://example.com/events")],
15110            ..Default::default()
15111        };
15112        assert!(validate_mcp_config(&cfg).is_ok());
15113    }
15114
15115    #[test]
15116    async fn validate_mcp_config_rejects_empty_name() {
15117        let cfg = McpConfig {
15118            enabled: true,
15119            servers: vec![stdio_server("", "/usr/bin/tool")],
15120            ..Default::default()
15121        };
15122        let err = validate_mcp_config(&cfg).expect_err("empty name should fail");
15123        assert!(
15124            err.to_string().contains("name must not be empty"),
15125            "got: {err}"
15126        );
15127    }
15128
15129    #[test]
15130    async fn validate_mcp_config_rejects_whitespace_name() {
15131        let cfg = McpConfig {
15132            enabled: true,
15133            servers: vec![stdio_server("   ", "/usr/bin/tool")],
15134            ..Default::default()
15135        };
15136        let err = validate_mcp_config(&cfg).expect_err("whitespace name should fail");
15137        assert!(
15138            err.to_string().contains("name must not be empty"),
15139            "got: {err}"
15140        );
15141    }
15142
15143    #[test]
15144    async fn validate_mcp_config_rejects_duplicate_names() {
15145        let cfg = McpConfig {
15146            enabled: true,
15147            servers: vec![
15148                stdio_server("fs", "/usr/bin/mcp-a"),
15149                stdio_server("fs", "/usr/bin/mcp-b"),
15150            ],
15151            ..Default::default()
15152        };
15153        let err = validate_mcp_config(&cfg).expect_err("duplicate name should fail");
15154        assert!(err.to_string().contains("duplicate name"), "got: {err}");
15155    }
15156
15157    #[test]
15158    async fn validate_mcp_config_rejects_zero_timeout() {
15159        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
15160        server.tool_timeout_secs = Some(0);
15161        let cfg = McpConfig {
15162            enabled: true,
15163            servers: vec![server],
15164            ..Default::default()
15165        };
15166        let err = validate_mcp_config(&cfg).expect_err("zero timeout should fail");
15167        assert!(err.to_string().contains("greater than 0"), "got: {err}");
15168    }
15169
15170    #[test]
15171    async fn validate_mcp_config_rejects_timeout_exceeding_max() {
15172        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
15173        server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS + 1);
15174        let cfg = McpConfig {
15175            enabled: true,
15176            servers: vec![server],
15177            ..Default::default()
15178        };
15179        let err = validate_mcp_config(&cfg).expect_err("oversized timeout should fail");
15180        assert!(err.to_string().contains("exceeds max"), "got: {err}");
15181    }
15182
15183    #[test]
15184    async fn validate_mcp_config_allows_max_timeout_exactly() {
15185        let mut server = stdio_server("fs", "/usr/bin/mcp-fs");
15186        server.tool_timeout_secs = Some(MCP_MAX_TOOL_TIMEOUT_SECS);
15187        let cfg = McpConfig {
15188            enabled: true,
15189            servers: vec![server],
15190            ..Default::default()
15191        };
15192        assert!(validate_mcp_config(&cfg).is_ok());
15193    }
15194
15195    #[test]
15196    async fn validate_mcp_config_rejects_stdio_with_empty_command() {
15197        let cfg = McpConfig {
15198            enabled: true,
15199            servers: vec![stdio_server("fs", "")],
15200            ..Default::default()
15201        };
15202        let err = validate_mcp_config(&cfg).expect_err("empty command should fail");
15203        assert!(
15204            err.to_string().contains("requires non-empty command"),
15205            "got: {err}"
15206        );
15207    }
15208
15209    #[test]
15210    async fn validate_mcp_config_rejects_http_without_url() {
15211        let cfg = McpConfig {
15212            enabled: true,
15213            servers: vec![McpServerConfig {
15214                name: "svc".to_string(),
15215                transport: McpTransport::Http,
15216                url: None,
15217                ..Default::default()
15218            }],
15219            ..Default::default()
15220        };
15221        let err = validate_mcp_config(&cfg).expect_err("http without url should fail");
15222        assert!(err.to_string().contains("requires url"), "got: {err}");
15223    }
15224
15225    #[test]
15226    async fn validate_mcp_config_rejects_sse_without_url() {
15227        let cfg = McpConfig {
15228            enabled: true,
15229            servers: vec![McpServerConfig {
15230                name: "svc".to_string(),
15231                transport: McpTransport::Sse,
15232                url: None,
15233                ..Default::default()
15234            }],
15235            ..Default::default()
15236        };
15237        let err = validate_mcp_config(&cfg).expect_err("sse without url should fail");
15238        assert!(err.to_string().contains("requires url"), "got: {err}");
15239    }
15240
15241    #[test]
15242    async fn validate_mcp_config_rejects_non_http_scheme() {
15243        let cfg = McpConfig {
15244            enabled: true,
15245            servers: vec![http_server("svc", "ftp://example.com/mcp")],
15246            ..Default::default()
15247        };
15248        let err = validate_mcp_config(&cfg).expect_err("non-http scheme should fail");
15249        assert!(err.to_string().contains("http/https"), "got: {err}");
15250    }
15251
15252    #[test]
15253    async fn validate_mcp_config_rejects_invalid_url() {
15254        let cfg = McpConfig {
15255            enabled: true,
15256            servers: vec![http_server("svc", "not a url at all !!!")],
15257            ..Default::default()
15258        };
15259        let err = validate_mcp_config(&cfg).expect_err("invalid url should fail");
15260        assert!(err.to_string().contains("valid URL"), "got: {err}");
15261    }
15262
15263    #[test]
15264    async fn mcp_config_default_disabled_with_empty_servers() {
15265        let cfg = McpConfig::default();
15266        assert!(!cfg.enabled);
15267        assert!(cfg.servers.is_empty());
15268    }
15269
15270    #[test]
15271    async fn mcp_transport_serde_roundtrip_lowercase() {
15272        let cases = [
15273            (McpTransport::Stdio, "\"stdio\""),
15274            (McpTransport::Http, "\"http\""),
15275            (McpTransport::Sse, "\"sse\""),
15276        ];
15277        for (variant, expected_json) in &cases {
15278            let serialized = serde_json::to_string(variant).expect("serialize");
15279            assert_eq!(&serialized, expected_json, "variant: {variant:?}");
15280            let deserialized: McpTransport =
15281                serde_json::from_str(expected_json).expect("deserialize");
15282            assert_eq!(&deserialized, variant);
15283        }
15284    }
15285
15286    #[test]
15287    async fn swarm_strategy_roundtrip() {
15288        let cases = vec![
15289            (SwarmStrategy::Sequential, "\"sequential\""),
15290            (SwarmStrategy::Parallel, "\"parallel\""),
15291            (SwarmStrategy::Router, "\"router\""),
15292        ];
15293        for (variant, expected_json) in &cases {
15294            let serialized = serde_json::to_string(variant).expect("serialize");
15295            assert_eq!(&serialized, expected_json, "variant: {variant:?}");
15296            let deserialized: SwarmStrategy =
15297                serde_json::from_str(expected_json).expect("deserialize");
15298            assert_eq!(&deserialized, variant);
15299        }
15300    }
15301
15302    #[test]
15303    async fn swarm_config_deserializes_with_defaults() {
15304        let toml_str = r#"
15305            agents = ["researcher", "writer"]
15306            strategy = "sequential"
15307        "#;
15308        let config: SwarmConfig = toml::from_str(toml_str).expect("deserialize");
15309        assert_eq!(config.agents, vec!["researcher", "writer"]);
15310        assert_eq!(config.strategy, SwarmStrategy::Sequential);
15311        assert!(config.router_prompt.is_none());
15312        assert!(config.description.is_none());
15313        assert_eq!(config.timeout_secs, 300);
15314    }
15315
15316    #[test]
15317    async fn swarm_config_deserializes_full() {
15318        let toml_str = r#"
15319            agents = ["a", "b", "c"]
15320            strategy = "router"
15321            router_prompt = "Pick the best."
15322            description = "Multi-agent router"
15323            timeout_secs = 120
15324        "#;
15325        let config: SwarmConfig = toml::from_str(toml_str).expect("deserialize");
15326        assert_eq!(config.agents.len(), 3);
15327        assert_eq!(config.strategy, SwarmStrategy::Router);
15328        assert_eq!(config.router_prompt.as_deref(), Some("Pick the best."));
15329        assert_eq!(config.description.as_deref(), Some("Multi-agent router"));
15330        assert_eq!(config.timeout_secs, 120);
15331    }
15332
15333    #[test]
15334    async fn config_with_swarms_section_deserializes() {
15335        let toml_str = r#"
15336            [agents.researcher]
15337            provider = "ollama"
15338            model = "llama3"
15339
15340            [agents.writer]
15341            provider = "openrouter"
15342            model = "claude-sonnet"
15343
15344            [swarms.pipeline]
15345            agents = ["researcher", "writer"]
15346            strategy = "sequential"
15347        "#;
15348        let config = parse_test_config(toml_str);
15349        assert_eq!(config.agents.len(), 2);
15350        assert_eq!(config.swarms.len(), 1);
15351        assert!(config.swarms.contains_key("pipeline"));
15352    }
15353
15354    #[tokio::test]
15355    async fn nevis_client_secret_encrypt_decrypt_roundtrip() {
15356        let dir = std::env::temp_dir().join(format!(
15357            "construct_test_nevis_secret_{}",
15358            uuid::Uuid::new_v4()
15359        ));
15360        fs::create_dir_all(&dir).await.unwrap();
15361
15362        let plaintext_secret = "nevis-test-client-secret-value";
15363
15364        let mut config = Config::default();
15365        config.workspace_dir = dir.join("workspace");
15366        config.config_path = dir.join("config.toml");
15367        config.security.nevis.client_secret = Some(plaintext_secret.into());
15368
15369        // Save (triggers encryption)
15370        config.save().await.unwrap();
15371
15372        // Read raw TOML and verify plaintext secret is NOT present
15373        let raw_toml = tokio::fs::read_to_string(&config.config_path)
15374            .await
15375            .unwrap();
15376        assert!(
15377            !raw_toml.contains(plaintext_secret),
15378            "Saved TOML must not contain the plaintext client_secret"
15379        );
15380
15381        // Parse stored TOML and verify the value is encrypted
15382        let stored: Config = toml::from_str(&raw_toml).unwrap();
15383        let stored_secret = stored.security.nevis.client_secret.as_ref().unwrap();
15384        assert!(
15385            crate::security::SecretStore::is_encrypted(stored_secret),
15386            "Stored client_secret must be marked as encrypted"
15387        );
15388
15389        // Decrypt and verify it matches the original plaintext
15390        let store = crate::security::SecretStore::new(&dir, true);
15391        assert_eq!(store.decrypt(stored_secret).unwrap(), plaintext_secret);
15392
15393        // Simulate a full load: deserialize then decrypt (mirrors load_or_init logic)
15394        let mut loaded: Config = toml::from_str(&raw_toml).unwrap();
15395        loaded.config_path = dir.join("config.toml");
15396        let load_store = crate::security::SecretStore::new(&dir, loaded.secrets.encrypt);
15397        decrypt_optional_secret(
15398            &load_store,
15399            &mut loaded.security.nevis.client_secret,
15400            "config.security.nevis.client_secret",
15401        )
15402        .unwrap();
15403        assert_eq!(
15404            loaded.security.nevis.client_secret.as_deref().unwrap(),
15405            plaintext_secret,
15406            "Loaded client_secret must match the original plaintext after decryption"
15407        );
15408
15409        let _ = fs::remove_dir_all(&dir).await;
15410    }
15411
15412    // ══════════════════════════════════════════════════════════
15413    // Nevis config validation tests
15414    // ══════════════════════════════════════════════════════════
15415
15416    #[test]
15417    async fn nevis_config_validate_disabled_accepts_empty_fields() {
15418        let cfg = NevisConfig::default();
15419        assert!(!cfg.enabled);
15420        assert!(cfg.validate().is_ok());
15421    }
15422
15423    #[test]
15424    async fn nevis_config_validate_rejects_empty_instance_url() {
15425        let cfg = NevisConfig {
15426            enabled: true,
15427            instance_url: String::new(),
15428            client_id: "test-client".into(),
15429            ..NevisConfig::default()
15430        };
15431        let err = cfg.validate().unwrap_err();
15432        assert!(err.contains("instance_url"));
15433    }
15434
15435    #[test]
15436    async fn nevis_config_validate_rejects_empty_client_id() {
15437        let cfg = NevisConfig {
15438            enabled: true,
15439            instance_url: "https://nevis.example.com".into(),
15440            client_id: String::new(),
15441            ..NevisConfig::default()
15442        };
15443        let err = cfg.validate().unwrap_err();
15444        assert!(err.contains("client_id"));
15445    }
15446
15447    #[test]
15448    async fn nevis_config_validate_rejects_empty_realm() {
15449        let cfg = NevisConfig {
15450            enabled: true,
15451            instance_url: "https://nevis.example.com".into(),
15452            client_id: "test-client".into(),
15453            realm: String::new(),
15454            ..NevisConfig::default()
15455        };
15456        let err = cfg.validate().unwrap_err();
15457        assert!(err.contains("realm"));
15458    }
15459
15460    #[test]
15461    async fn nevis_config_validate_rejects_local_without_jwks() {
15462        let cfg = NevisConfig {
15463            enabled: true,
15464            instance_url: "https://nevis.example.com".into(),
15465            client_id: "test-client".into(),
15466            token_validation: "local".into(),
15467            jwks_url: None,
15468            ..NevisConfig::default()
15469        };
15470        let err = cfg.validate().unwrap_err();
15471        assert!(err.contains("jwks_url"));
15472    }
15473
15474    #[test]
15475    async fn nevis_config_validate_rejects_zero_session_timeout() {
15476        let cfg = NevisConfig {
15477            enabled: true,
15478            instance_url: "https://nevis.example.com".into(),
15479            client_id: "test-client".into(),
15480            token_validation: "remote".into(),
15481            session_timeout_secs: 0,
15482            ..NevisConfig::default()
15483        };
15484        let err = cfg.validate().unwrap_err();
15485        assert!(err.contains("session_timeout_secs"));
15486    }
15487
15488    #[test]
15489    async fn nevis_config_validate_accepts_valid_enabled_config() {
15490        let cfg = NevisConfig {
15491            enabled: true,
15492            instance_url: "https://nevis.example.com".into(),
15493            realm: "master".into(),
15494            client_id: "test-client".into(),
15495            token_validation: "remote".into(),
15496            session_timeout_secs: 3600,
15497            ..NevisConfig::default()
15498        };
15499        assert!(cfg.validate().is_ok());
15500    }
15501
15502    #[test]
15503    async fn nevis_config_validate_rejects_invalid_token_validation() {
15504        let cfg = NevisConfig {
15505            enabled: true,
15506            instance_url: "https://nevis.example.com".into(),
15507            realm: "master".into(),
15508            client_id: "test-client".into(),
15509            token_validation: "invalid_mode".into(),
15510            session_timeout_secs: 3600,
15511            ..NevisConfig::default()
15512        };
15513        let err = cfg.validate().unwrap_err();
15514        assert!(
15515            err.contains("invalid value 'invalid_mode'"),
15516            "Expected invalid token_validation error, got: {err}"
15517        );
15518    }
15519
15520    #[test]
15521    async fn nevis_config_debug_redacts_client_secret() {
15522        let cfg = NevisConfig {
15523            client_secret: Some("super-secret".into()),
15524            ..NevisConfig::default()
15525        };
15526        let debug_output = format!("{:?}", cfg);
15527        assert!(
15528            !debug_output.contains("super-secret"),
15529            "Debug output must not contain the raw client_secret"
15530        );
15531        assert!(
15532            debug_output.contains("[REDACTED]"),
15533            "Debug output must show [REDACTED] for client_secret"
15534        );
15535    }
15536
15537    #[test]
15538    async fn telegram_config_ack_reactions_false_deserializes() {
15539        let toml_str = r#"
15540            bot_token = "123:ABC"
15541            allowed_users = ["alice"]
15542            ack_reactions = false
15543        "#;
15544        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
15545        assert_eq!(cfg.ack_reactions, Some(false));
15546    }
15547
15548    #[test]
15549    async fn telegram_config_ack_reactions_true_deserializes() {
15550        let toml_str = r#"
15551            bot_token = "123:ABC"
15552            allowed_users = ["alice"]
15553            ack_reactions = true
15554        "#;
15555        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
15556        assert_eq!(cfg.ack_reactions, Some(true));
15557    }
15558
15559    #[test]
15560    async fn telegram_config_ack_reactions_missing_defaults_to_none() {
15561        let toml_str = r#"
15562            bot_token = "123:ABC"
15563            allowed_users = ["alice"]
15564        "#;
15565        let cfg: TelegramConfig = toml::from_str(toml_str).unwrap();
15566        assert_eq!(cfg.ack_reactions, None);
15567    }
15568
15569    #[test]
15570    async fn telegram_config_ack_reactions_channel_overrides_top_level() {
15571        let tg_toml = r#"
15572            bot_token = "123:ABC"
15573            allowed_users = ["alice"]
15574            ack_reactions = false
15575        "#;
15576        let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
15577        let top_level_ack = true;
15578        let effective = tg.ack_reactions.unwrap_or(top_level_ack);
15579        assert!(
15580            !effective,
15581            "channel-level false must override top-level true"
15582        );
15583    }
15584
15585    #[test]
15586    async fn telegram_config_ack_reactions_falls_back_to_top_level() {
15587        let tg_toml = r#"
15588            bot_token = "123:ABC"
15589            allowed_users = ["alice"]
15590        "#;
15591        let tg: TelegramConfig = toml::from_str(tg_toml).unwrap();
15592        let top_level_ack = false;
15593        let effective = tg.ack_reactions.unwrap_or(top_level_ack);
15594        assert!(
15595            !effective,
15596            "must fall back to top-level false when channel omits field"
15597        );
15598    }
15599
15600    #[test]
15601    async fn google_workspace_allowed_operations_deserialize_from_toml() {
15602        let toml_str = r#"
15603            enabled = true
15604
15605            [[allowed_operations]]
15606            service = "gmail"
15607            resource = "users"
15608            sub_resource = "drafts"
15609            methods = ["create", "update"]
15610        "#;
15611
15612        let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
15613        assert_eq!(cfg.allowed_operations.len(), 1);
15614        assert_eq!(cfg.allowed_operations[0].service, "gmail");
15615        assert_eq!(cfg.allowed_operations[0].resource, "users");
15616        assert_eq!(
15617            cfg.allowed_operations[0].sub_resource.as_deref(),
15618            Some("drafts")
15619        );
15620        assert_eq!(
15621            cfg.allowed_operations[0].methods,
15622            vec!["create".to_string(), "update".to_string()]
15623        );
15624    }
15625
15626    #[test]
15627    async fn google_workspace_allowed_operations_deserialize_without_sub_resource() {
15628        let toml_str = r#"
15629            enabled = true
15630
15631            [[allowed_operations]]
15632            service = "drive"
15633            resource = "files"
15634            methods = ["list", "get"]
15635        "#;
15636
15637        let cfg: GoogleWorkspaceConfig = toml::from_str(toml_str).unwrap();
15638        assert_eq!(cfg.allowed_operations[0].sub_resource, None);
15639    }
15640
15641    #[test]
15642    async fn config_validate_accepts_google_workspace_allowed_operations() {
15643        let mut cfg = Config::default();
15644        cfg.google_workspace.enabled = true;
15645        cfg.google_workspace.allowed_services = vec!["gmail".into()];
15646        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15647            service: "gmail".into(),
15648            resource: "users".into(),
15649            sub_resource: Some("drafts".into()),
15650            methods: vec!["create".into(), "update".into()],
15651        }];
15652
15653        cfg.validate().unwrap();
15654    }
15655
15656    #[test]
15657    async fn config_validate_rejects_duplicate_google_workspace_allowed_operations() {
15658        let mut cfg = Config::default();
15659        cfg.google_workspace.enabled = true;
15660        cfg.google_workspace.allowed_services = vec!["gmail".into()];
15661        cfg.google_workspace.allowed_operations = vec![
15662            GoogleWorkspaceAllowedOperation {
15663                service: "gmail".into(),
15664                resource: "users".into(),
15665                sub_resource: Some("drafts".into()),
15666                methods: vec!["create".into()],
15667            },
15668            GoogleWorkspaceAllowedOperation {
15669                service: "gmail".into(),
15670                resource: "users".into(),
15671                sub_resource: Some("drafts".into()),
15672                methods: vec!["update".into()],
15673            },
15674        ];
15675
15676        let err = cfg.validate().unwrap_err().to_string();
15677        assert!(err.contains("duplicate service/resource/sub_resource entry"));
15678    }
15679
15680    #[test]
15681    async fn config_validate_rejects_operation_service_not_in_allowed_services() {
15682        let mut cfg = Config::default();
15683        cfg.google_workspace.enabled = true;
15684        cfg.google_workspace.allowed_services = vec!["gmail".into()];
15685        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15686            service: "drive".into(), // drive is not in allowed_services
15687            resource: "files".into(),
15688            sub_resource: None,
15689            methods: vec!["list".into()],
15690        }];
15691
15692        let err = cfg.validate().unwrap_err().to_string();
15693        assert!(
15694            err.contains("not in the effective allowed_services"),
15695            "expected not-in-allowed_services error, got: {err}"
15696        );
15697    }
15698
15699    #[test]
15700    async fn config_validate_accepts_default_service_when_allowed_services_empty() {
15701        // When allowed_services is empty the validator uses DEFAULT_GWS_SERVICES.
15702        // A known default service must pass.
15703        let mut cfg = Config::default();
15704        cfg.google_workspace.enabled = true;
15705        // allowed_services deliberately left empty (falls back to defaults)
15706        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15707            service: "drive".into(),
15708            resource: "files".into(),
15709            sub_resource: None,
15710            methods: vec!["list".into()],
15711        }];
15712
15713        assert!(cfg.validate().is_ok());
15714    }
15715
15716    #[test]
15717    async fn config_validate_rejects_unknown_service_when_allowed_services_empty() {
15718        // Even with allowed_services empty (using defaults), an operation whose
15719        // service is not in DEFAULT_GWS_SERVICES must fail validation — not silently
15720        // pass through to be rejected at runtime.
15721        let mut cfg = Config::default();
15722        cfg.google_workspace.enabled = true;
15723        // allowed_services deliberately left empty
15724        cfg.google_workspace.allowed_operations = vec![GoogleWorkspaceAllowedOperation {
15725            service: "not_a_real_service".into(),
15726            resource: "files".into(),
15727            sub_resource: None,
15728            methods: vec!["list".into()],
15729        }];
15730
15731        let err = cfg.validate().unwrap_err().to_string();
15732        assert!(
15733            err.contains("not in the effective allowed_services"),
15734            "expected effective-allowed_services error, got: {err}"
15735        );
15736    }
15737
15738    // ── Bootstrap files ─────────────────────────────────────
15739
15740    #[tokio::test]
15741    async fn ensure_bootstrap_files_creates_missing_files() {
15742        let tmp = tempfile::TempDir::new().unwrap();
15743        let ws = tmp.path().join("workspace");
15744        let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
15745
15746        ensure_bootstrap_files(&ws).await.unwrap();
15747
15748        let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
15749        let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
15750            .await
15751            .unwrap();
15752        assert!(soul.contains("SOUL.md"));
15753        assert!(identity.contains("IDENTITY.md"));
15754    }
15755
15756    #[tokio::test]
15757    async fn ensure_bootstrap_files_does_not_overwrite_existing() {
15758        let tmp = tempfile::TempDir::new().unwrap();
15759        let ws = tmp.path().join("workspace");
15760        let _: () = tokio::fs::create_dir_all(&ws).await.unwrap();
15761
15762        let custom = "# My custom SOUL";
15763        let _: () = tokio::fs::write(ws.join("SOUL.md"), custom).await.unwrap();
15764
15765        ensure_bootstrap_files(&ws).await.unwrap();
15766
15767        let soul: String = tokio::fs::read_to_string(ws.join("SOUL.md")).await.unwrap();
15768        assert_eq!(
15769            soul, custom,
15770            "ensure_bootstrap_files must not overwrite existing files"
15771        );
15772
15773        // IDENTITY.md should still be created since it was missing
15774        let identity: String = tokio::fs::read_to_string(ws.join("IDENTITY.md"))
15775            .await
15776            .unwrap();
15777        assert!(identity.contains("IDENTITY.md"));
15778    }
15779
15780    // ── PacingConfig serde defaults ─────────────────────────────
15781
15782    #[test]
15783    async fn pacing_config_serde_defaults_match_manual_default() {
15784        // Deserialise an empty TOML table and verify the loop-detection
15785        // fields receive the same defaults as `PacingConfig::default()`.
15786        let from_toml: PacingConfig = toml::from_str("").unwrap();
15787        let manual = PacingConfig::default();
15788
15789        assert_eq!(
15790            from_toml.loop_detection_enabled,
15791            manual.loop_detection_enabled
15792        );
15793        assert_eq!(
15794            from_toml.loop_detection_window_size,
15795            manual.loop_detection_window_size
15796        );
15797        assert_eq!(
15798            from_toml.loop_detection_max_repeats,
15799            manual.loop_detection_max_repeats
15800        );
15801
15802        // Verify concrete values so a silent change to the defaults is caught.
15803        assert!(from_toml.loop_detection_enabled, "default should be true");
15804        assert_eq!(from_toml.loop_detection_window_size, 20);
15805        assert_eq!(from_toml.loop_detection_max_repeats, 3);
15806    }
15807
15808    // ── Docker baked config template ────────────────────────────
15809
15810    /// The TOML template baked into Docker images (Dockerfile + Dockerfile.debian).
15811    /// Kept here so changes to the Dockerfiles can be validated by `cargo test`.
15812    const DOCKER_CONFIG_TEMPLATE: &str = r#"
15813workspace_dir = "/construct-data/workspace"
15814config_path = "/construct-data/.construct/config.toml"
15815api_key = ""
15816default_provider = "openrouter"
15817default_model = "anthropic/claude-sonnet-4-20250514"
15818default_temperature = 0.7
15819
15820[gateway]
15821port = 42617
15822host = "[::]"
15823allow_public_bind = true
15824
15825[autonomy]
15826level = "supervised"
15827auto_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"]
15828"#;
15829
15830    #[test]
15831    async fn docker_config_template_is_parseable() {
15832        let cfg: Config = toml::from_str(DOCKER_CONFIG_TEMPLATE)
15833            .expect("Docker baked config.toml must be valid TOML that deserialises into Config");
15834
15835        // The [autonomy] section must be present and contain the expected tools.
15836        let auto = &cfg.autonomy.auto_approve;
15837        for tool in &[
15838            "file_read",
15839            "file_write",
15840            "file_edit",
15841            "memory_recall",
15842            "memory_store",
15843            "web_search_tool",
15844            "web_fetch",
15845            "calculator",
15846            "glob_search",
15847            "content_search",
15848            "image_info",
15849            "weather",
15850            "git_operations",
15851        ] {
15852            assert!(
15853                auto.iter().any(|t| t == tool),
15854                "Docker config auto_approve missing expected tool: {tool}"
15855            );
15856        }
15857    }
15858
15859    #[test]
15860    async fn cost_enforcement_config_defaults() {
15861        let config = CostEnforcementConfig::default();
15862        assert_eq!(config.mode, "warn");
15863        assert_eq!(config.route_down_model, None);
15864        assert_eq!(config.reserve_percent, 10);
15865    }
15866
15867    #[test]
15868    async fn cost_config_includes_enforcement() {
15869        let config = CostConfig::default();
15870        assert_eq!(config.enforcement.mode, "warn");
15871        assert_eq!(config.enforcement.reserve_percent, 10);
15872    }
15873}