use super::DEFAULT_PROTOCOL_MODEL_ID;
use crate::providers::{is_glm_alias, is_zai_alias};
use crate::security::AutonomyLevel;
use anyhow::{Context, Result};
use directories::UserDirs;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::{OnceLock, RwLock};
#[cfg(unix)]
use tokio::fs::File;
use tokio::fs::{self, OpenOptions};
use tokio::io::AsyncWriteExt;
const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
"provider.anthropic",
"provider.compatible",
"provider.copilot",
"provider.gemini",
"provider.glm",
"provider.ollama",
"provider.openai",
"provider.openrouter",
"channel.dingtalk",
"channel.discord",
"channel.lark",
"channel.matrix",
"channel.mattermost",
"channel.nextcloud_talk",
"channel.qq",
"channel.signal",
"channel.slack",
"channel.telegram",
"channel.whatsapp",
"tool.browser",
"tool.composio",
"tool.http_request",
"tool.pushover",
"memory.embeddings",
"tunnel.custom",
];
const SUPPORTED_PROXY_SERVICE_SELECTORS: &[&str] =
&["provider.*", "channel.*", "tool.*", "memory.*", "tunnel.*"];
static RUNTIME_PROXY_CONFIG: OnceLock<RwLock<ProxyConfig>> = OnceLock::new();
static RUNTIME_PROXY_CLIENT_CACHE: OnceLock<RwLock<HashMap<String, reqwest::Client>>> =
OnceLock::new();
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct Config {
#[serde(skip)]
pub workspace_dir: PathBuf,
#[serde(skip)]
pub config_path: PathBuf,
pub api_key: Option<String>,
pub api_url: Option<String>,
pub default_provider: Option<String>,
pub default_model: Option<String>,
pub default_temperature: f64,
#[serde(default)]
pub observability: ObservabilityConfig,
#[serde(default)]
pub autonomy: AutonomyConfig,
#[serde(default)]
pub runtime: RuntimeConfig,
#[serde(default)]
pub reliability: ReliabilityConfig,
#[serde(default)]
pub scheduler: SchedulerConfig,
#[serde(default)]
pub agent: AgentConfig,
#[serde(default)]
pub skills: SkillsConfig,
#[serde(default)]
pub routing: ExecutionRoutingConfig,
#[serde(default)]
pub telemetry: TelemetryConfig,
#[serde(default)]
pub model_routes: Vec<ModelRouteConfig>,
#[serde(default)]
pub embedding_routes: Vec<EmbeddingRouteConfig>,
#[serde(default)]
pub query_classification: QueryClassificationConfig,
#[serde(default)]
pub heartbeat: HeartbeatConfig,
#[serde(default)]
pub cron: CronConfig,
#[serde(default)]
pub channels_config: ChannelsConfig,
#[serde(default)]
pub memory: MemoryConfig,
#[serde(default)]
pub storage: StorageConfig,
#[serde(default)]
pub tunnel: TunnelConfig,
#[serde(default)]
pub gateway: GatewayConfig,
#[serde(default)]
pub composio: ComposioConfig,
#[serde(default)]
pub secrets: SecretsConfig,
#[serde(default)]
pub browser: BrowserConfig,
#[serde(default)]
pub http_request: HttpRequestConfig,
#[serde(default)]
pub multimodal: MultimodalConfig,
#[serde(default)]
pub web_search: WebSearchConfig,
#[serde(default)]
pub proxy: ProxyConfig,
#[serde(default)]
pub identity: IdentityConfig,
#[serde(default)]
pub cost: CostConfig,
#[serde(default)]
pub peripherals: PeripheralsConfig,
#[serde(default)]
pub deploy: DeployConfig,
#[serde(default)]
pub agents: HashMap<String, DelegateAgentConfig>,
#[serde(default)]
pub hardware: HardwareConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DelegateAgentConfig {
pub provider: String,
pub model: String,
#[serde(default)]
pub system_prompt: Option<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub temperature: Option<f64>,
#[serde(default = "default_max_depth")]
pub max_depth: u32,
#[serde(default)]
pub agentic: bool,
#[serde(default)]
pub allowed_tools: Vec<String>,
#[serde(default = "default_max_tool_iterations")]
pub max_iterations: usize,
}
fn default_max_depth() -> u32 {
3
}
fn default_max_tool_iterations() -> usize {
10
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
pub enum HardwareTransport {
#[default]
None,
Native,
Serial,
Probe,
}
impl std::fmt::Display for HardwareTransport {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::None => write!(f, "none"),
Self::Native => write!(f, "native"),
Self::Serial => write!(f, "serial"),
Self::Probe => write!(f, "probe"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HardwareConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub transport: HardwareTransport,
#[serde(default)]
pub serial_port: Option<String>,
#[serde(default = "default_baud_rate")]
pub baud_rate: u32,
#[serde(default)]
pub probe_target: Option<String>,
#[serde(default)]
pub workspace_datasheets: bool,
}
fn default_baud_rate() -> u32 {
115_200
}
impl HardwareConfig {
pub fn transport_mode(&self) -> HardwareTransport {
self.transport.clone()
}
}
impl Default for HardwareConfig {
fn default() -> Self {
Self {
enabled: false,
transport: HardwareTransport::None,
serial_port: None,
baud_rate: default_baud_rate(),
probe_target: None,
workspace_datasheets: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AgentConfig {
#[serde(default)]
pub compact_context: bool,
#[serde(default = "default_agent_max_tool_iterations")]
pub max_tool_iterations: usize,
#[serde(default = "default_agent_max_history_messages")]
pub max_history_messages: usize,
#[serde(default)]
pub parallel_tools: bool,
#[serde(default = "default_agent_tool_dispatcher")]
pub tool_dispatcher: String,
}
fn default_agent_max_tool_iterations() -> usize {
10
}
fn default_agent_max_history_messages() -> usize {
50
}
fn default_agent_tool_dispatcher() -> String {
"auto".into()
}
impl Default for AgentConfig {
fn default() -> Self {
Self {
compact_context: false,
max_tool_iterations: default_agent_max_tool_iterations(),
max_history_messages: default_agent_max_history_messages(),
parallel_tools: false,
tool_dispatcher: default_agent_tool_dispatcher(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "snake_case")]
pub enum SkillsPromptInjectionMode {
#[default]
Full,
Compact,
}
fn parse_skills_prompt_injection_mode(raw: &str) -> Option<SkillsPromptInjectionMode> {
match raw.trim().to_ascii_lowercase().as_str() {
"full" => Some(SkillsPromptInjectionMode::Full),
"compact" => Some(SkillsPromptInjectionMode::Compact),
_ => None,
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct SkillsConfig {
#[serde(default)]
pub open_skills_enabled: bool,
#[serde(default)]
pub open_skills_dir: Option<String>,
#[serde(default)]
pub prompt_injection_mode: SkillsPromptInjectionMode,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MultimodalConfig {
#[serde(default = "default_multimodal_max_images")]
pub max_images: usize,
#[serde(default = "default_multimodal_max_image_size_mb")]
pub max_image_size_mb: usize,
#[serde(default)]
pub allow_remote_fetch: bool,
}
fn default_multimodal_max_images() -> usize {
4
}
fn default_multimodal_max_image_size_mb() -> usize {
5
}
impl MultimodalConfig {
pub fn effective_limits(&self) -> (usize, usize) {
let max_images = self.max_images.clamp(1, 16);
let max_image_size_mb = self.max_image_size_mb.clamp(1, 20);
(max_images, max_image_size_mb)
}
}
impl Default for MultimodalConfig {
fn default() -> Self {
Self {
max_images: default_multimodal_max_images(),
max_image_size_mb: default_multimodal_max_image_size_mb(),
allow_remote_fetch: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct IdentityConfig {
#[serde(default = "default_identity_format")]
pub format: String,
#[serde(default)]
pub aieos_path: Option<String>,
#[serde(default)]
pub aieos_inline: Option<String>,
}
fn default_identity_format() -> String {
"openclaw".into()
}
impl Default for IdentityConfig {
fn default() -> Self {
Self {
format: default_identity_format(),
aieos_path: None,
aieos_inline: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CostConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_daily_limit")]
pub daily_limit_usd: f64,
#[serde(default = "default_monthly_limit")]
pub monthly_limit_usd: f64,
#[serde(default = "default_warn_percent")]
pub warn_at_percent: u8,
#[serde(default)]
pub allow_override: bool,
#[serde(default)]
pub prices: std::collections::HashMap<String, ModelPricing>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ModelPricing {
#[serde(default)]
pub input: f64,
#[serde(default)]
pub output: f64,
}
fn default_daily_limit() -> f64 {
10.0
}
fn default_monthly_limit() -> f64 {
100.0
}
fn default_warn_percent() -> u8 {
80
}
impl Default for CostConfig {
fn default() -> Self {
Self {
enabled: false,
daily_limit_usd: default_daily_limit(),
monthly_limit_usd: default_monthly_limit(),
warn_at_percent: default_warn_percent(),
allow_override: false,
prices: get_default_pricing(),
}
}
}
fn get_default_pricing() -> std::collections::HashMap<String, ModelPricing> {
let mut prices = std::collections::HashMap::new();
prices.insert(
"anthropic/claude-sonnet-4-20250514".into(),
ModelPricing {
input: 3.0,
output: 15.0,
},
);
prices.insert(
"anthropic/claude-opus-4-20250514".into(),
ModelPricing {
input: 15.0,
output: 75.0,
},
);
prices.insert(
"anthropic/claude-3.5-sonnet".into(),
ModelPricing {
input: 3.0,
output: 15.0,
},
);
prices.insert(
"anthropic/claude-3-haiku".into(),
ModelPricing {
input: 0.25,
output: 1.25,
},
);
prices.insert(
"openai/gpt-4o".into(),
ModelPricing {
input: 5.0,
output: 15.0,
},
);
prices.insert(
"openai/gpt-4o-mini".into(),
ModelPricing {
input: 0.15,
output: 0.60,
},
);
prices.insert(
"openai/o1-preview".into(),
ModelPricing {
input: 15.0,
output: 60.0,
},
);
prices.insert(
"google/gemini-2.0-flash".into(),
ModelPricing {
input: 0.10,
output: 0.40,
},
);
prices.insert(
"google/gemini-1.5-pro".into(),
ModelPricing {
input: 1.25,
output: 5.0,
},
);
prices
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct PeripheralsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub boards: Vec<PeripheralBoardConfig>,
#[serde(default)]
pub datasheet_dir: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct PeripheralBoardConfig {
pub board: String,
#[serde(default = "default_peripheral_transport")]
pub transport: String,
#[serde(default)]
pub path: Option<String>,
#[serde(default = "default_peripheral_baud")]
pub baud: u32,
}
fn default_peripheral_transport() -> String {
"serial".into()
}
fn default_peripheral_baud() -> u32 {
115_200
}
impl Default for PeripheralBoardConfig {
fn default() -> Self {
Self {
board: String::new(),
transport: default_peripheral_transport(),
path: None,
baud: default_peripheral_baud(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct GatewayConfig {
#[serde(default = "default_gateway_port")]
pub port: u16,
#[serde(default = "default_gateway_host")]
pub host: String,
#[serde(default = "default_true")]
pub require_pairing: bool,
#[serde(default)]
pub allow_public_bind: bool,
#[serde(default)]
pub paired_tokens: Vec<String>,
#[serde(default = "default_pair_rate_limit")]
pub pair_rate_limit_per_minute: u32,
#[serde(default = "default_webhook_rate_limit")]
pub webhook_rate_limit_per_minute: u32,
#[serde(default)]
pub trust_forwarded_headers: bool,
#[serde(default = "default_gateway_rate_limit_max_keys")]
pub rate_limit_max_keys: usize,
#[serde(default = "default_idempotency_ttl_secs")]
pub idempotency_ttl_secs: u64,
#[serde(default = "default_gateway_idempotency_max_keys")]
pub idempotency_max_keys: usize,
}
fn default_gateway_port() -> u16 {
3000
}
fn default_gateway_host() -> String {
"127.0.0.1".into()
}
fn default_pair_rate_limit() -> u32 {
10
}
fn default_webhook_rate_limit() -> u32 {
60
}
fn default_idempotency_ttl_secs() -> u64 {
300
}
fn default_gateway_rate_limit_max_keys() -> usize {
10_000
}
fn default_gateway_idempotency_max_keys() -> usize {
10_000
}
fn default_true() -> bool {
true
}
impl Default for GatewayConfig {
fn default() -> Self {
Self {
port: default_gateway_port(),
host: default_gateway_host(),
require_pairing: true,
allow_public_bind: false,
paired_tokens: Vec::new(),
pair_rate_limit_per_minute: default_pair_rate_limit(),
webhook_rate_limit_per_minute: default_webhook_rate_limit(),
trust_forwarded_headers: false,
rate_limit_max_keys: default_gateway_rate_limit_max_keys(),
idempotency_ttl_secs: default_idempotency_ttl_secs(),
idempotency_max_keys: default_gateway_idempotency_max_keys(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ComposioConfig {
#[serde(default, alias = "enable")]
pub enabled: bool,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default = "default_entity_id")]
pub entity_id: String,
}
fn default_entity_id() -> String {
"default".into()
}
impl Default for ComposioConfig {
fn default() -> Self {
Self {
enabled: false,
api_key: None,
entity_id: default_entity_id(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SecretsConfig {
#[serde(default = "default_true")]
pub encrypt: bool,
}
impl Default for SecretsConfig {
fn default() -> Self {
Self { encrypt: true }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BrowserComputerUseConfig {
#[serde(default = "default_browser_computer_use_endpoint")]
pub endpoint: String,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default = "default_browser_computer_use_timeout_ms")]
pub timeout_ms: u64,
#[serde(default)]
pub allow_remote_endpoint: bool,
#[serde(default)]
pub window_allowlist: Vec<String>,
#[serde(default)]
pub max_coordinate_x: Option<i64>,
#[serde(default)]
pub max_coordinate_y: Option<i64>,
}
fn default_browser_computer_use_endpoint() -> String {
"http://127.0.0.1:8787/v1/actions".into()
}
fn default_browser_computer_use_timeout_ms() -> u64 {
15_000
}
impl Default for BrowserComputerUseConfig {
fn default() -> Self {
Self {
endpoint: default_browser_computer_use_endpoint(),
api_key: None,
timeout_ms: default_browser_computer_use_timeout_ms(),
allow_remote_endpoint: false,
window_allowlist: Vec::new(),
max_coordinate_x: None,
max_coordinate_y: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct BrowserConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default)]
pub session_name: Option<String>,
#[serde(default = "default_browser_backend")]
pub backend: String,
#[serde(default = "default_true")]
pub native_headless: bool,
#[serde(default = "default_browser_webdriver_url")]
pub native_webdriver_url: String,
#[serde(default)]
pub native_chrome_path: Option<String>,
#[serde(default)]
pub computer_use: BrowserComputerUseConfig,
}
fn default_browser_backend() -> String {
"agent_browser".into()
}
fn default_browser_webdriver_url() -> String {
"http://127.0.0.1:9515".into()
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
enabled: false,
allowed_domains: Vec::new(),
session_name: None,
backend: default_browser_backend(),
native_headless: default_true(),
native_webdriver_url: default_browser_webdriver_url(),
native_chrome_path: None,
computer_use: BrowserComputerUseConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct HttpRequestConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub allowed_domains: Vec<String>,
#[serde(default = "default_http_max_response_size")]
pub max_response_size: usize,
#[serde(default = "default_http_timeout_secs")]
pub timeout_secs: u64,
}
fn default_http_max_response_size() -> usize {
1_000_000 }
fn default_http_timeout_secs() -> u64 {
30
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WebSearchConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_web_search_provider")]
pub provider: String,
#[serde(default)]
pub brave_api_key: Option<String>,
#[serde(default = "default_web_search_max_results")]
pub max_results: usize,
#[serde(default = "default_web_search_timeout_secs")]
pub timeout_secs: u64,
}
fn default_web_search_provider() -> String {
"duckduckgo".into()
}
fn default_web_search_max_results() -> usize {
5
}
fn default_web_search_timeout_secs() -> u64 {
15
}
impl Default for WebSearchConfig {
fn default() -> Self {
Self {
enabled: false,
provider: default_web_search_provider(),
brave_api_key: None,
max_results: default_web_search_max_results(),
timeout_secs: default_web_search_timeout_secs(),
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ProxyScope {
Environment,
#[default]
Velaclaw,
Services,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ProxyConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub http_proxy: Option<String>,
#[serde(default)]
pub https_proxy: Option<String>,
#[serde(default)]
pub all_proxy: Option<String>,
#[serde(default)]
pub no_proxy: Vec<String>,
#[serde(default)]
pub scope: ProxyScope,
#[serde(default)]
pub services: Vec<String>,
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
enabled: false,
http_proxy: None,
https_proxy: None,
all_proxy: None,
no_proxy: Vec::new(),
scope: ProxyScope::Velaclaw,
services: Vec::new(),
}
}
}
impl ProxyConfig {
pub fn supported_service_keys() -> &'static [&'static str] {
SUPPORTED_PROXY_SERVICE_KEYS
}
pub fn supported_service_selectors() -> &'static [&'static str] {
SUPPORTED_PROXY_SERVICE_SELECTORS
}
pub fn has_any_proxy_url(&self) -> bool {
normalize_proxy_url_option(self.http_proxy.as_deref()).is_some()
|| normalize_proxy_url_option(self.https_proxy.as_deref()).is_some()
|| normalize_proxy_url_option(self.all_proxy.as_deref()).is_some()
}
pub fn normalized_services(&self) -> Vec<String> {
normalize_service_list(self.services.clone())
}
pub fn normalized_no_proxy(&self) -> Vec<String> {
normalize_no_proxy_list(self.no_proxy.clone())
}
pub fn validate(&self) -> Result<()> {
for (field, value) in [
("http_proxy", self.http_proxy.as_deref()),
("https_proxy", self.https_proxy.as_deref()),
("all_proxy", self.all_proxy.as_deref()),
] {
if let Some(url) = normalize_proxy_url_option(value) {
validate_proxy_url(field, &url)?;
}
}
for selector in self.normalized_services() {
if !is_supported_proxy_service_selector(&selector) {
anyhow::bail!(
"Unsupported proxy service selector '{selector}'. Use tool `proxy_config` action `list_services` for valid values"
);
}
}
if self.enabled && !self.has_any_proxy_url() {
anyhow::bail!(
"Proxy is enabled but no proxy URL is configured. Set at least one of http_proxy, https_proxy, or all_proxy"
);
}
if self.enabled
&& self.scope == ProxyScope::Services
&& self.normalized_services().is_empty()
{
anyhow::bail!(
"proxy.scope='services' requires a non-empty proxy.services list when proxy is enabled"
);
}
Ok(())
}
pub fn should_apply_to_service(&self, service_key: &str) -> bool {
if !self.enabled {
return false;
}
match self.scope {
ProxyScope::Environment => false,
ProxyScope::Velaclaw => true,
ProxyScope::Services => {
let service_key = service_key.trim().to_ascii_lowercase();
if service_key.is_empty() {
return false;
}
self.normalized_services()
.iter()
.any(|selector| service_selector_matches(selector, &service_key))
}
}
}
pub fn apply_to_reqwest_builder(
&self,
mut builder: reqwest::ClientBuilder,
service_key: &str,
) -> reqwest::ClientBuilder {
if !self.should_apply_to_service(service_key) {
return builder;
}
let no_proxy = self.no_proxy_value();
if let Some(url) = normalize_proxy_url_option(self.all_proxy.as_deref()) {
match reqwest::Proxy::all(&url) {
Ok(proxy) => {
builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
}
Err(error) => {
tracing::warn!(
proxy_url = %url,
service_key,
"Ignoring invalid all_proxy URL: {error}"
);
}
}
}
if let Some(url) = normalize_proxy_url_option(self.http_proxy.as_deref()) {
match reqwest::Proxy::http(&url) {
Ok(proxy) => {
builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
}
Err(error) => {
tracing::warn!(
proxy_url = %url,
service_key,
"Ignoring invalid http_proxy URL: {error}"
);
}
}
}
if let Some(url) = normalize_proxy_url_option(self.https_proxy.as_deref()) {
match reqwest::Proxy::https(&url) {
Ok(proxy) => {
builder = builder.proxy(apply_no_proxy(proxy, no_proxy));
}
Err(error) => {
tracing::warn!(
proxy_url = %url,
service_key,
"Ignoring invalid https_proxy URL: {error}"
);
}
}
}
builder
}
pub fn apply_to_process_env(&self) {
set_proxy_env_pair("HTTP_PROXY", self.http_proxy.as_deref());
set_proxy_env_pair("HTTPS_PROXY", self.https_proxy.as_deref());
set_proxy_env_pair("ALL_PROXY", self.all_proxy.as_deref());
let no_proxy_joined = {
let list = self.normalized_no_proxy();
(!list.is_empty()).then(|| list.join(","))
};
set_proxy_env_pair("NO_PROXY", no_proxy_joined.as_deref());
}
pub fn clear_process_env() {
clear_proxy_env_pair("HTTP_PROXY");
clear_proxy_env_pair("HTTPS_PROXY");
clear_proxy_env_pair("ALL_PROXY");
clear_proxy_env_pair("NO_PROXY");
}
fn no_proxy_value(&self) -> Option<reqwest::NoProxy> {
let joined = {
let list = self.normalized_no_proxy();
(!list.is_empty()).then(|| list.join(","))
};
joined.as_deref().and_then(reqwest::NoProxy::from_string)
}
}
fn apply_no_proxy(proxy: reqwest::Proxy, no_proxy: Option<reqwest::NoProxy>) -> reqwest::Proxy {
proxy.no_proxy(no_proxy)
}
fn normalize_proxy_url_option(raw: Option<&str>) -> Option<String> {
let value = raw?.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn normalize_no_proxy_list(values: Vec<String>) -> Vec<String> {
normalize_comma_values(values)
}
fn normalize_service_list(values: Vec<String>) -> Vec<String> {
let mut normalized = normalize_comma_values(values)
.into_iter()
.map(|value| value.to_ascii_lowercase())
.collect::<Vec<_>>();
normalized.sort_unstable();
normalized.dedup();
normalized
}
fn normalize_comma_values(values: Vec<String>) -> Vec<String> {
let mut output = Vec::new();
for value in values {
for part in value.split(',') {
let normalized = part.trim();
if normalized.is_empty() {
continue;
}
output.push(normalized.to_string());
}
}
output.sort_unstable();
output.dedup();
output
}
fn is_supported_proxy_service_selector(selector: &str) -> bool {
if SUPPORTED_PROXY_SERVICE_KEYS
.iter()
.any(|known| known.eq_ignore_ascii_case(selector))
{
return true;
}
SUPPORTED_PROXY_SERVICE_SELECTORS
.iter()
.any(|known| known.eq_ignore_ascii_case(selector))
}
fn service_selector_matches(selector: &str, service_key: &str) -> bool {
if selector == service_key {
return true;
}
if let Some(prefix) = selector.strip_suffix(".*") {
return service_key.starts_with(prefix)
&& service_key
.strip_prefix(prefix)
.is_some_and(|suffix| suffix.starts_with('.'));
}
false
}
fn validate_proxy_url(field: &str, url: &str) -> Result<()> {
let parsed = reqwest::Url::parse(url)
.with_context(|| format!("Invalid {field} URL: '{url}' is not a valid URL"))?;
match parsed.scheme() {
"http" | "https" | "socks5" | "socks5h" => {}
scheme => {
anyhow::bail!(
"Invalid {field} URL scheme '{scheme}'. Allowed: http, https, socks5, socks5h"
);
}
}
if parsed.host_str().is_none() {
anyhow::bail!("Invalid {field} URL: host is required");
}
Ok(())
}
fn set_proxy_env_pair(key: &str, value: Option<&str>) {
let lowercase_key = key.to_ascii_lowercase();
if let Some(value) = value.and_then(|candidate| normalize_proxy_url_option(Some(candidate))) {
std::env::set_var(key, &value);
std::env::set_var(lowercase_key, value);
} else {
std::env::remove_var(key);
std::env::remove_var(lowercase_key);
}
}
fn clear_proxy_env_pair(key: &str) {
std::env::remove_var(key);
std::env::remove_var(key.to_ascii_lowercase());
}
fn runtime_proxy_state() -> &'static RwLock<ProxyConfig> {
RUNTIME_PROXY_CONFIG.get_or_init(|| RwLock::new(ProxyConfig::default()))
}
fn runtime_proxy_client_cache() -> &'static RwLock<HashMap<String, reqwest::Client>> {
RUNTIME_PROXY_CLIENT_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
}
fn clear_runtime_proxy_client_cache() {
match runtime_proxy_client_cache().write() {
Ok(mut guard) => {
guard.clear();
}
Err(poisoned) => {
poisoned.into_inner().clear();
}
}
}
fn runtime_proxy_cache_key(
service_key: &str,
timeout_secs: Option<u64>,
connect_timeout_secs: Option<u64>,
) -> String {
format!(
"{}|timeout={}|connect_timeout={}",
service_key.trim().to_ascii_lowercase(),
timeout_secs
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
connect_timeout_secs
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string())
)
}
fn runtime_proxy_cached_client(cache_key: &str) -> Option<reqwest::Client> {
match runtime_proxy_client_cache().read() {
Ok(guard) => guard.get(cache_key).cloned(),
Err(poisoned) => poisoned.into_inner().get(cache_key).cloned(),
}
}
fn set_runtime_proxy_cached_client(cache_key: String, client: reqwest::Client) {
match runtime_proxy_client_cache().write() {
Ok(mut guard) => {
guard.insert(cache_key, client);
}
Err(poisoned) => {
poisoned.into_inner().insert(cache_key, client);
}
}
}
pub fn set_runtime_proxy_config(config: ProxyConfig) {
match runtime_proxy_state().write() {
Ok(mut guard) => {
*guard = config;
}
Err(poisoned) => {
*poisoned.into_inner() = config;
}
}
clear_runtime_proxy_client_cache();
}
pub fn runtime_proxy_config() -> ProxyConfig {
match runtime_proxy_state().read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
pub fn apply_runtime_proxy_to_builder(
builder: reqwest::ClientBuilder,
service_key: &str,
) -> reqwest::ClientBuilder {
runtime_proxy_config().apply_to_reqwest_builder(builder, service_key)
}
pub fn build_runtime_proxy_client(service_key: &str) -> reqwest::Client {
let cache_key = runtime_proxy_cache_key(service_key, None, None);
if let Some(client) = runtime_proxy_cached_client(&cache_key) {
return client;
}
let builder = apply_runtime_proxy_to_builder(reqwest::Client::builder(), service_key);
let client = builder.build().unwrap_or_else(|error| {
tracing::warn!(service_key, "Failed to build proxied client: {error}");
reqwest::Client::new()
});
set_runtime_proxy_cached_client(cache_key, client.clone());
client
}
pub fn build_runtime_proxy_client_with_timeouts(
service_key: &str,
timeout_secs: u64,
connect_timeout_secs: u64,
) -> reqwest::Client {
let cache_key =
runtime_proxy_cache_key(service_key, Some(timeout_secs), Some(connect_timeout_secs));
if let Some(client) = runtime_proxy_cached_client(&cache_key) {
return client;
}
let builder = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.connect_timeout(std::time::Duration::from_secs(connect_timeout_secs));
let builder = apply_runtime_proxy_to_builder(builder, service_key);
let client = builder.build().unwrap_or_else(|error| {
tracing::warn!(
service_key,
"Failed to build proxied timeout client: {error}"
);
reqwest::Client::new()
});
set_runtime_proxy_cached_client(cache_key, client.clone());
client
}
fn parse_proxy_scope(raw: &str) -> Option<ProxyScope> {
match raw.trim().to_ascii_lowercase().as_str() {
"environment" | "env" => Some(ProxyScope::Environment),
"velaclaw" | "internal" | "core" => Some(ProxyScope::Velaclaw),
"services" | "service" => Some(ProxyScope::Services),
_ => None,
}
}
fn parse_proxy_enabled(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct StorageConfig {
#[serde(default)]
pub provider: StorageProviderSection,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct StorageProviderSection {
#[serde(default)]
pub config: StorageProviderConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct StorageProviderConfig {
#[serde(default)]
pub provider: String,
#[serde(
default,
alias = "dbURL",
alias = "database_url",
alias = "databaseUrl"
)]
pub db_url: Option<String>,
#[serde(default = "default_storage_schema")]
pub schema: String,
#[serde(default = "default_storage_table")]
pub table: String,
#[serde(default)]
pub connect_timeout_secs: Option<u64>,
}
fn default_storage_schema() -> String {
"public".into()
}
fn default_storage_table() -> String {
"memories".into()
}
impl Default for StorageProviderConfig {
fn default() -> Self {
Self {
provider: String::new(),
db_url: None,
schema: default_storage_schema(),
table: default_storage_table(),
connect_timeout_secs: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[allow(clippy::struct_excessive_bools)]
pub struct MemoryConfig {
pub backend: String,
pub auto_save: bool,
#[serde(default = "default_hygiene_enabled")]
pub hygiene_enabled: bool,
#[serde(default = "default_archive_after_days")]
pub archive_after_days: u32,
#[serde(default = "default_purge_after_days")]
pub purge_after_days: u32,
#[serde(default = "default_conversation_retention_days")]
pub conversation_retention_days: u32,
#[serde(default = "default_embedding_provider")]
pub embedding_provider: String,
#[serde(default = "default_embedding_model")]
pub embedding_model: String,
#[serde(default = "default_embedding_dims")]
pub embedding_dimensions: usize,
#[serde(default = "default_vector_weight")]
pub vector_weight: f64,
#[serde(default = "default_keyword_weight")]
pub keyword_weight: f64,
#[serde(default = "default_min_relevance_score")]
pub min_relevance_score: f64,
#[serde(default = "default_cache_size")]
pub embedding_cache_size: usize,
#[serde(default = "default_chunk_size")]
pub chunk_max_tokens: usize,
#[serde(default)]
pub response_cache_enabled: bool,
#[serde(default = "default_response_cache_ttl")]
pub response_cache_ttl_minutes: u32,
#[serde(default = "default_response_cache_max")]
pub response_cache_max_entries: usize,
#[serde(default)]
pub snapshot_enabled: bool,
#[serde(default)]
pub snapshot_on_hygiene: bool,
#[serde(default = "default_true")]
pub auto_hydrate: bool,
#[serde(default)]
pub sqlite_open_timeout_secs: Option<u64>,
}
fn default_embedding_provider() -> String {
"none".into()
}
fn default_hygiene_enabled() -> bool {
true
}
fn default_archive_after_days() -> u32 {
7
}
fn default_purge_after_days() -> u32 {
30
}
fn default_conversation_retention_days() -> u32 {
30
}
fn default_embedding_model() -> String {
"text-embedding-3-small".into()
}
fn default_embedding_dims() -> usize {
1536
}
fn default_vector_weight() -> f64 {
0.7
}
fn default_keyword_weight() -> f64 {
0.3
}
fn default_min_relevance_score() -> f64 {
0.4
}
fn default_cache_size() -> usize {
10_000
}
fn default_chunk_size() -> usize {
512
}
fn default_response_cache_ttl() -> u32 {
60
}
fn default_response_cache_max() -> usize {
5_000
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
backend: "sqlite".into(),
auto_save: true,
hygiene_enabled: default_hygiene_enabled(),
archive_after_days: default_archive_after_days(),
purge_after_days: default_purge_after_days(),
conversation_retention_days: default_conversation_retention_days(),
embedding_provider: default_embedding_provider(),
embedding_model: default_embedding_model(),
embedding_dimensions: default_embedding_dims(),
vector_weight: default_vector_weight(),
keyword_weight: default_keyword_weight(),
min_relevance_score: default_min_relevance_score(),
embedding_cache_size: default_cache_size(),
chunk_max_tokens: default_chunk_size(),
response_cache_enabled: false,
response_cache_ttl_minutes: default_response_cache_ttl(),
response_cache_max_entries: default_response_cache_max(),
snapshot_enabled: false,
snapshot_on_hygiene: false,
auto_hydrate: true,
sqlite_open_timeout_secs: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ObservabilityConfig {
pub backend: String,
#[serde(default)]
pub otel_endpoint: Option<String>,
#[serde(default)]
pub otel_service_name: Option<String>,
}
impl Default for ObservabilityConfig {
fn default() -> Self {
Self {
backend: "none".into(),
otel_endpoint: None,
otel_service_name: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AutonomyConfig {
pub level: AutonomyLevel,
pub workspace_only: bool,
pub allowed_commands: Vec<String>,
pub forbidden_paths: Vec<String>,
pub max_actions_per_hour: u32,
pub max_cost_per_day_cents: u32,
#[serde(default = "default_true")]
pub require_approval_for_medium_risk: bool,
#[serde(default = "default_true")]
pub block_high_risk_commands: bool,
#[serde(default = "default_auto_approve")]
pub auto_approve: Vec<String>,
#[serde(default = "default_always_ask")]
pub always_ask: Vec<String>,
}
fn default_auto_approve() -> Vec<String> {
vec!["file_read".into(), "memory_recall".into()]
}
fn default_always_ask() -> Vec<String> {
vec![]
}
impl Default for AutonomyConfig {
fn default() -> Self {
Self {
level: AutonomyLevel::Supervised,
workspace_only: true,
allowed_commands: vec![
"git".into(),
"npm".into(),
"cargo".into(),
"ls".into(),
"cat".into(),
"grep".into(),
"find".into(),
"echo".into(),
"pwd".into(),
"wc".into(),
"head".into(),
"tail".into(),
],
forbidden_paths: vec![
"/etc".into(),
"/root".into(),
"/home".into(),
"/usr".into(),
"/bin".into(),
"/sbin".into(),
"/lib".into(),
"/opt".into(),
"/boot".into(),
"/dev".into(),
"/proc".into(),
"/sys".into(),
"/var".into(),
"/tmp".into(),
"~/.ssh".into(),
"~/.gnupg".into(),
"~/.aws".into(),
"~/.config".into(),
],
max_actions_per_hour: 20,
max_cost_per_day_cents: 500,
require_approval_for_medium_risk: true,
block_high_risk_commands: true,
auto_approve: default_auto_approve(),
always_ask: default_always_ask(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct RuntimeConfig {
#[serde(default = "default_runtime_kind")]
pub kind: String,
#[serde(default)]
pub docker: DockerRuntimeConfig,
#[serde(default)]
pub reasoning_enabled: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DockerRuntimeConfig {
#[serde(default = "default_docker_image")]
pub image: String,
#[serde(default = "default_docker_network")]
pub network: String,
#[serde(default = "default_docker_memory_limit_mb")]
pub memory_limit_mb: Option<u64>,
#[serde(default = "default_docker_cpu_limit")]
pub cpu_limit: Option<f64>,
#[serde(default = "default_true")]
pub read_only_rootfs: bool,
#[serde(default = "default_true")]
pub mount_workspace: bool,
#[serde(default)]
pub allowed_workspace_roots: Vec<String>,
}
fn default_runtime_kind() -> String {
"native".into()
}
fn default_docker_image() -> String {
"alpine:3.20".into()
}
fn default_docker_network() -> String {
"none".into()
}
fn default_docker_memory_limit_mb() -> Option<u64> {
Some(512)
}
fn default_docker_cpu_limit() -> Option<f64> {
Some(1.0)
}
impl Default for DockerRuntimeConfig {
fn default() -> Self {
Self {
image: default_docker_image(),
network: default_docker_network(),
memory_limit_mb: default_docker_memory_limit_mb(),
cpu_limit: default_docker_cpu_limit(),
read_only_rootfs: true,
mount_workspace: true,
allowed_workspace_roots: Vec::new(),
}
}
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
kind: default_runtime_kind(),
docker: DockerRuntimeConfig::default(),
reasoning_enabled: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ReliabilityConfig {
#[serde(default = "default_provider_retries")]
pub provider_retries: u32,
#[serde(default = "default_provider_backoff_ms")]
pub provider_backoff_ms: u64,
#[serde(default)]
pub fallback_providers: Vec<String>,
#[serde(default)]
pub api_keys: Vec<String>,
#[serde(default)]
pub model_fallbacks: std::collections::HashMap<String, Vec<String>>,
#[serde(default = "default_channel_backoff_secs")]
pub channel_initial_backoff_secs: u64,
#[serde(default = "default_channel_backoff_max_secs")]
pub channel_max_backoff_secs: u64,
#[serde(default = "default_scheduler_poll_secs")]
pub scheduler_poll_secs: u64,
#[serde(default = "default_scheduler_retries")]
pub scheduler_retries: u32,
}
fn default_provider_retries() -> u32 {
2
}
fn default_provider_backoff_ms() -> u64 {
500
}
fn default_channel_backoff_secs() -> u64 {
2
}
fn default_channel_backoff_max_secs() -> u64 {
60
}
fn default_scheduler_poll_secs() -> u64 {
15
}
fn default_scheduler_retries() -> u32 {
2
}
impl Default for ReliabilityConfig {
fn default() -> Self {
Self {
provider_retries: default_provider_retries(),
provider_backoff_ms: default_provider_backoff_ms(),
fallback_providers: Vec::new(),
api_keys: Vec::new(),
model_fallbacks: std::collections::HashMap::new(),
channel_initial_backoff_secs: default_channel_backoff_secs(),
channel_max_backoff_secs: default_channel_backoff_max_secs(),
scheduler_poll_secs: default_scheduler_poll_secs(),
scheduler_retries: default_scheduler_retries(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SchedulerConfig {
#[serde(default = "default_scheduler_enabled")]
pub enabled: bool,
#[serde(default = "default_scheduler_max_tasks")]
pub max_tasks: usize,
#[serde(default = "default_scheduler_max_concurrent")]
pub max_concurrent: usize,
}
fn default_scheduler_enabled() -> bool {
true
}
fn default_scheduler_max_tasks() -> usize {
64
}
fn default_scheduler_max_concurrent() -> usize {
4
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
enabled: default_scheduler_enabled(),
max_tasks: default_scheduler_max_tasks(),
max_concurrent: default_scheduler_max_concurrent(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
#[serde(rename_all = "lowercase")]
pub enum ProviderRoutingMode {
#[default]
Byok,
Prism,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct ExecutionRoutingConfig {
#[serde(default)]
pub provider_mode: ProviderRoutingMode,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Default)]
pub struct TelemetryConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub endpoint: Option<String>,
#[serde(default)]
pub user_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ModelRouteConfig {
pub hint: String,
pub provider: String,
pub model: String,
#[serde(default)]
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct EmbeddingRouteConfig {
pub hint: String,
pub provider: String,
pub model: String,
#[serde(default)]
pub dimensions: Option<usize>,
#[serde(default)]
pub api_key: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct QueryClassificationConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub rules: Vec<ClassificationRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct ClassificationRule {
pub hint: String,
#[serde(default)]
pub keywords: Vec<String>,
#[serde(default)]
pub patterns: Vec<String>,
#[serde(default)]
pub min_length: Option<usize>,
#[serde(default)]
pub max_length: Option<usize>,
#[serde(default)]
pub priority: i32,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct HeartbeatConfig {
pub enabled: bool,
pub interval_minutes: u32,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: false,
interval_minutes: 30,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CronConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_max_run_history")]
pub max_run_history: u32,
}
fn default_max_run_history() -> u32 {
50
}
impl Default for CronConfig {
fn default() -> Self {
Self {
enabled: true,
max_run_history: default_max_run_history(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TunnelConfig {
pub provider: String,
#[serde(default)]
pub cloudflare: Option<CloudflareTunnelConfig>,
#[serde(default)]
pub tailscale: Option<TailscaleTunnelConfig>,
#[serde(default)]
pub ngrok: Option<NgrokTunnelConfig>,
#[serde(default)]
pub custom: Option<CustomTunnelConfig>,
}
impl Default for TunnelConfig {
fn default() -> Self {
Self {
provider: "none".into(),
cloudflare: None,
tailscale: None,
ngrok: None,
custom: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CloudflareTunnelConfig {
pub token: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TailscaleTunnelConfig {
#[serde(default)]
pub funnel: bool,
pub hostname: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct NgrokTunnelConfig {
pub auth_token: String,
pub domain: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CustomTunnelConfig {
pub start_command: String,
pub health_url: Option<String>,
pub url_pattern: Option<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
pub struct DeployConfig {
#[serde(default)]
pub servers: Vec<DeploymentTargetConfig>,
#[serde(default)]
pub settings: DeploymentSettingsConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DeploymentTargetConfig {
pub id: String,
pub host: String,
#[serde(default)]
pub port: u16,
pub user: String,
#[serde(default)]
pub ssh_key: Option<String>,
#[serde(default)]
pub labels: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DeploymentSettingsConfig {
#[serde(default)]
pub mode: String,
#[serde(default = "default_deploy_binary_path")]
pub binary_path: String,
#[serde(default)]
pub config_path: Option<String>,
#[serde(default = "default_deploy_working_dir")]
pub working_dir: String,
#[serde(default)]
pub auto_start: bool,
#[serde(default = "default_health_check_interval")]
pub health_check_interval_secs: u64,
#[serde(default)]
pub restart_on_failure: bool,
#[serde(default)]
pub max_restarts: u32,
#[serde(default)]
pub use_sudo: bool,
}
fn default_deploy_binary_path() -> String {
"/usr/local/bin/velaclaw".into()
}
fn default_deploy_working_dir() -> String {
"/opt/velaclaw".into()
}
fn default_health_check_interval() -> u64 {
30
}
impl Default for DeploymentSettingsConfig {
fn default() -> Self {
Self {
mode: "direct".into(),
binary_path: default_deploy_binary_path(),
config_path: None,
working_dir: default_deploy_working_dir(),
auto_start: true,
health_check_interval_secs: default_health_check_interval(),
restart_on_failure: true,
max_restarts: 3,
use_sudo: true,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ChannelsConfig {
pub cli: bool,
pub telegram: Option<TelegramConfig>,
pub discord: Option<DiscordConfig>,
pub slack: Option<SlackConfig>,
pub mattermost: Option<MattermostConfig>,
pub webhook: Option<WebhookConfig>,
pub imessage: Option<IMessageConfig>,
pub matrix: Option<MatrixConfig>,
pub signal: Option<SignalConfig>,
pub whatsapp: Option<WhatsAppConfig>,
pub linq: Option<LinqConfig>,
pub nextcloud_talk: Option<NextcloudTalkConfig>,
pub email: Option<crate::channels::email_channel::EmailConfig>,
pub irc: Option<IrcConfig>,
pub lark: Option<LarkConfig>,
pub dingtalk: Option<DingTalkConfig>,
pub qq: Option<QQConfig>,
#[serde(default = "default_channel_message_timeout_secs")]
pub message_timeout_secs: u64,
}
fn default_channel_message_timeout_secs() -> u64 {
300
}
impl Default for ChannelsConfig {
fn default() -> Self {
Self {
cli: true,
telegram: None,
discord: None,
slack: None,
mattermost: None,
webhook: None,
imessage: None,
matrix: None,
signal: None,
whatsapp: None,
linq: None,
nextcloud_talk: None,
email: None,
irc: None,
lark: None,
dingtalk: None,
qq: None,
message_timeout_secs: default_channel_message_timeout_secs(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum StreamMode {
#[default]
Off,
Partial,
}
fn default_draft_update_interval_ms() -> u64 {
1000
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct TelegramConfig {
pub bot_token: String,
pub allowed_users: Vec<String>,
#[serde(default)]
pub stream_mode: StreamMode,
#[serde(default = "default_draft_update_interval_ms")]
pub draft_update_interval_ms: u64,
#[serde(default)]
pub interrupt_on_new_message: bool,
#[serde(default)]
pub mention_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DiscordConfig {
pub bot_token: String,
pub guild_id: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
#[serde(default)]
pub listen_to_bots: bool,
#[serde(default)]
pub mention_only: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SlackConfig {
pub bot_token: String,
pub app_token: Option<String>,
pub channel_id: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MattermostConfig {
pub url: String,
pub bot_token: String,
pub channel_id: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
#[serde(default)]
pub thread_replies: Option<bool>,
#[serde(default)]
pub mention_only: Option<bool>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WebhookConfig {
pub port: u16,
pub secret: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct IMessageConfig {
pub allowed_contacts: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct MatrixConfig {
pub homeserver: String,
pub access_token: String,
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub device_id: Option<String>,
pub room_id: String,
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SignalConfig {
pub http_url: String,
pub account: String,
#[serde(default)]
pub group_id: Option<String>,
#[serde(default)]
pub allowed_from: Vec<String>,
#[serde(default)]
pub ignore_attachments: bool,
#[serde(default)]
pub ignore_stories: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct WhatsAppConfig {
#[serde(default)]
pub access_token: Option<String>,
#[serde(default)]
pub phone_number_id: Option<String>,
#[serde(default)]
pub verify_token: Option<String>,
#[serde(default)]
pub app_secret: Option<String>,
#[serde(default)]
pub session_path: Option<String>,
#[serde(default)]
pub pair_phone: Option<String>,
#[serde(default)]
pub pair_code: Option<String>,
#[serde(default)]
pub allowed_numbers: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LinqConfig {
pub api_token: String,
pub from_phone: String,
#[serde(default)]
pub signing_secret: Option<String>,
#[serde(default)]
pub allowed_senders: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct NextcloudTalkConfig {
pub base_url: String,
pub app_token: String,
#[serde(default)]
pub webhook_secret: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
}
impl WhatsAppConfig {
pub fn backend_type(&self) -> &'static str {
if self.phone_number_id.is_some() {
"cloud"
} else if self.session_path.is_some() {
"web"
} else {
"cloud"
}
}
pub fn is_cloud_config(&self) -> bool {
self.phone_number_id.is_some() && self.access_token.is_some() && self.verify_token.is_some()
}
pub fn is_web_config(&self) -> bool {
self.session_path.is_some()
}
pub fn is_ambiguous_config(&self) -> bool {
self.phone_number_id.is_some() && self.session_path.is_some()
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct IrcConfig {
pub server: String,
#[serde(default = "default_irc_port")]
pub port: u16,
pub nickname: String,
pub username: Option<String>,
#[serde(default)]
pub channels: Vec<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
pub server_password: Option<String>,
pub nickserv_password: Option<String>,
pub sasl_password: Option<String>,
pub verify_tls: Option<bool>,
}
fn default_irc_port() -> u16 {
6697
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum LarkReceiveMode {
#[default]
Websocket,
Webhook,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct LarkConfig {
pub app_id: String,
pub app_secret: String,
#[serde(default)]
pub encrypt_key: Option<String>,
#[serde(default)]
pub verification_token: Option<String>,
#[serde(default)]
pub allowed_users: Vec<String>,
#[serde(default)]
pub use_feishu: bool,
#[serde(default)]
pub receive_mode: LarkReceiveMode,
#[serde(default)]
pub port: Option<u16>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
pub struct SecurityConfig {
#[serde(default)]
pub sandbox: SandboxConfig,
#[serde(default)]
pub resources: ResourceLimitsConfig,
#[serde(default)]
pub audit: AuditConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct SandboxConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub backend: SandboxBackend,
#[serde(default)]
pub firejail_args: Vec<String>,
}
impl Default for SandboxConfig {
fn default() -> Self {
Self {
enabled: None, backend: SandboxBackend::Auto,
firejail_args: Vec::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum SandboxBackend {
#[default]
Auto,
Landlock,
Firejail,
Bubblewrap,
Docker,
None,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ResourceLimitsConfig {
#[serde(default = "default_max_memory_mb")]
pub max_memory_mb: u32,
#[serde(default = "default_max_cpu_time_seconds")]
pub max_cpu_time_seconds: u64,
#[serde(default = "default_max_subprocesses")]
pub max_subprocesses: u32,
#[serde(default = "default_memory_monitoring_enabled")]
pub memory_monitoring: bool,
}
fn default_max_memory_mb() -> u32 {
512
}
fn default_max_cpu_time_seconds() -> u64 {
60
}
fn default_max_subprocesses() -> u32 {
10
}
fn default_memory_monitoring_enabled() -> bool {
true
}
impl Default for ResourceLimitsConfig {
fn default() -> Self {
Self {
max_memory_mb: default_max_memory_mb(),
max_cpu_time_seconds: default_max_cpu_time_seconds(),
max_subprocesses: default_max_subprocesses(),
memory_monitoring: default_memory_monitoring_enabled(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct AuditConfig {
#[serde(default = "default_audit_enabled")]
pub enabled: bool,
#[serde(default = "default_audit_log_path")]
pub log_path: String,
#[serde(default = "default_audit_max_size_mb")]
pub max_size_mb: u32,
#[serde(default)]
pub sign_events: bool,
}
fn default_audit_enabled() -> bool {
true
}
fn default_audit_log_path() -> String {
"audit.log".to_string()
}
fn default_audit_max_size_mb() -> u32 {
100
}
impl Default for AuditConfig {
fn default() -> Self {
Self {
enabled: default_audit_enabled(),
log_path: default_audit_log_path(),
max_size_mb: default_audit_max_size_mb(),
sign_events: false,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct DingTalkConfig {
pub client_id: String,
pub client_secret: String,
#[serde(default)]
pub allowed_users: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct QQConfig {
pub app_id: String,
pub app_secret: String,
#[serde(default)]
pub allowed_users: Vec<String>,
}
impl Default for Config {
fn default() -> Self {
let home =
UserDirs::new().map_or_else(|| PathBuf::from("."), |u| u.home_dir().to_path_buf());
let velaclaw_dir = home.join(".velaclaw");
Self {
workspace_dir: velaclaw_dir.join("workspace"),
config_path: velaclaw_dir.join("config.toml"),
api_key: None,
api_url: None,
default_provider: Some(DEFAULT_PROTOCOL_MODEL_ID.to_string()),
default_model: Some(DEFAULT_PROTOCOL_MODEL_ID.to_string()),
default_temperature: 0.7,
observability: ObservabilityConfig::default(),
autonomy: AutonomyConfig::default(),
runtime: RuntimeConfig::default(),
reliability: ReliabilityConfig::default(),
scheduler: SchedulerConfig::default(),
agent: AgentConfig::default(),
skills: SkillsConfig::default(),
routing: ExecutionRoutingConfig::default(),
telemetry: TelemetryConfig::default(),
model_routes: Vec::new(),
embedding_routes: Vec::new(),
heartbeat: HeartbeatConfig::default(),
cron: CronConfig::default(),
channels_config: ChannelsConfig::default(),
memory: MemoryConfig::default(),
storage: StorageConfig::default(),
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),
multimodal: MultimodalConfig::default(),
web_search: WebSearchConfig::default(),
proxy: ProxyConfig::default(),
identity: IdentityConfig::default(),
cost: CostConfig::default(),
peripherals: PeripheralsConfig::default(),
deploy: DeployConfig::default(),
agents: HashMap::new(),
hardware: HardwareConfig::default(),
query_classification: QueryClassificationConfig::default(),
}
}
}
fn default_config_and_workspace_dirs() -> Result<(PathBuf, PathBuf)> {
let config_dir = default_config_dir()?;
Ok((config_dir.clone(), config_dir.join("workspace")))
}
const ACTIVE_WORKSPACE_STATE_FILE: &str = "active_workspace.toml";
#[derive(Debug, Serialize, Deserialize)]
struct ActiveWorkspaceState {
config_dir: String,
}
fn default_config_dir() -> Result<PathBuf> {
let home = UserDirs::new()
.map(|u| u.home_dir().to_path_buf())
.context("Could not find home directory")?;
Ok(home.join(".velaclaw"))
}
fn active_workspace_state_path(default_dir: &Path) -> PathBuf {
default_dir.join(ACTIVE_WORKSPACE_STATE_FILE)
}
async fn load_persisted_workspace_dirs(
default_config_dir: &Path,
) -> Result<Option<(PathBuf, PathBuf)>> {
let state_path = active_workspace_state_path(default_config_dir);
if !state_path.exists() {
return Ok(None);
}
let contents = match fs::read_to_string(&state_path).await {
Ok(contents) => contents,
Err(error) => {
tracing::warn!(
"Failed to read active workspace marker {}: {error}",
state_path.display()
);
return Ok(None);
}
};
let state: ActiveWorkspaceState = match toml::from_str(&contents) {
Ok(state) => state,
Err(error) => {
tracing::warn!(
"Failed to parse active workspace marker {}: {error}",
state_path.display()
);
return Ok(None);
}
};
let raw_config_dir = state.config_dir.trim();
if raw_config_dir.is_empty() {
tracing::warn!(
"Ignoring active workspace marker {} because config_dir is empty",
state_path.display()
);
return Ok(None);
}
let parsed_dir = PathBuf::from(raw_config_dir);
let config_dir = if parsed_dir.is_absolute() {
parsed_dir
} else {
default_config_dir.join(parsed_dir)
};
Ok(Some((config_dir.clone(), config_dir.join("workspace"))))
}
pub(crate) async fn persist_active_workspace_config_dir(config_dir: &Path) -> Result<()> {
let default_config_dir = default_config_dir()?;
let state_path = active_workspace_state_path(&default_config_dir);
if config_dir == default_config_dir {
if state_path.exists() {
fs::remove_file(&state_path).await.with_context(|| {
format!(
"Failed to clear active workspace marker: {}",
state_path.display()
)
})?;
}
return Ok(());
}
fs::create_dir_all(&default_config_dir)
.await
.with_context(|| {
format!(
"Failed to create default config directory: {}",
default_config_dir.display()
)
})?;
let state = ActiveWorkspaceState {
config_dir: config_dir.to_string_lossy().into_owned(),
};
let serialized =
toml::to_string_pretty(&state).context("Failed to serialize active workspace marker")?;
let temp_path = default_config_dir.join(format!(
".{ACTIVE_WORKSPACE_STATE_FILE}.tmp-{}",
uuid::Uuid::new_v4()
));
fs::write(&temp_path, serialized).await.with_context(|| {
format!(
"Failed to write temporary active workspace marker: {}",
temp_path.display()
)
})?;
if let Err(error) = fs::rename(&temp_path, &state_path).await {
let _ = fs::remove_file(&temp_path).await;
anyhow::bail!(
"Failed to atomically persist active workspace marker {}: {error}",
state_path.display()
);
}
sync_directory(&default_config_dir).await?;
Ok(())
}
fn resolve_config_dir_for_workspace(workspace_dir: &Path) -> (PathBuf, PathBuf) {
let workspace_config_dir = workspace_dir.to_path_buf();
if workspace_config_dir.join("config.toml").exists() {
return (
workspace_config_dir.clone(),
workspace_config_dir.join("workspace"),
);
}
let legacy_config_dir = workspace_dir
.parent()
.map(|parent| parent.join(".velaclaw"));
if let Some(legacy_dir) = legacy_config_dir {
if legacy_dir.join("config.toml").exists() {
return (legacy_dir, workspace_config_dir);
}
if workspace_dir
.file_name()
.is_some_and(|name| name == std::ffi::OsStr::new("workspace"))
{
return (legacy_dir, workspace_config_dir);
}
}
(
workspace_config_dir.clone(),
workspace_config_dir.join("workspace"),
)
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ConfigResolutionSource {
EnvConfigDir,
EnvWorkspace,
ActiveWorkspaceMarker,
DefaultConfigDir,
}
impl ConfigResolutionSource {
const fn as_str(self) -> &'static str {
match self {
Self::EnvConfigDir => "VELACLAW_CONFIG_DIR",
Self::EnvWorkspace => "VELACLAW_WORKSPACE",
Self::ActiveWorkspaceMarker => "active_workspace.toml",
Self::DefaultConfigDir => "default",
}
}
}
async fn resolve_runtime_config_dirs(
default_velaclaw_dir: &Path,
default_workspace_dir: &Path,
) -> Result<(PathBuf, PathBuf, ConfigResolutionSource)> {
if let Ok(custom_config_dir) = std::env::var("VELACLAW_CONFIG_DIR") {
let custom_config_dir = custom_config_dir.trim();
if !custom_config_dir.is_empty() {
let velaclaw_dir = PathBuf::from(custom_config_dir);
return Ok((
velaclaw_dir.clone(),
velaclaw_dir.join("workspace"),
ConfigResolutionSource::EnvConfigDir,
));
}
}
if let Ok(custom_workspace) = std::env::var("VELACLAW_WORKSPACE") {
if !custom_workspace.is_empty() {
let (velaclaw_dir, workspace_dir) =
resolve_config_dir_for_workspace(&PathBuf::from(custom_workspace));
return Ok((
velaclaw_dir,
workspace_dir,
ConfigResolutionSource::EnvWorkspace,
));
}
}
if let Some((velaclaw_dir, workspace_dir)) =
load_persisted_workspace_dirs(default_velaclaw_dir).await?
{
return Ok((
velaclaw_dir,
workspace_dir,
ConfigResolutionSource::ActiveWorkspaceMarker,
));
}
Ok((
default_velaclaw_dir.to_path_buf(),
default_workspace_dir.to_path_buf(),
ConfigResolutionSource::DefaultConfigDir,
))
}
fn decrypt_optional_secret(
store: &crate::security::SecretStore,
value: &mut Option<String>,
field_name: &str,
) -> Result<()> {
if let Some(raw) = value.clone() {
if crate::security::SecretStore::is_encrypted(&raw) {
*value = Some(
store
.decrypt(&raw)
.with_context(|| format!("Failed to decrypt {field_name}"))?,
);
}
}
Ok(())
}
fn encrypt_optional_secret(
store: &crate::security::SecretStore,
value: &mut Option<String>,
field_name: &str,
) -> Result<()> {
if let Some(raw) = value.clone() {
if !crate::security::SecretStore::is_encrypted(&raw) {
*value = Some(
store
.encrypt(&raw)
.with_context(|| format!("Failed to encrypt {field_name}"))?,
);
}
}
Ok(())
}
fn config_dir_creation_error(path: &Path) -> String {
format!(
"Failed to create config directory: {}. If running as an OpenRC service, \
ensure this path is writable by user 'velaclaw'.",
path.display()
)
}
fn is_local_ollama_endpoint(api_url: Option<&str>) -> bool {
let Some(raw) = api_url.map(str::trim).filter(|value| !value.is_empty()) else {
return true;
};
reqwest::Url::parse(raw)
.ok()
.and_then(|url| url.host_str().map(|host| host.to_ascii_lowercase()))
.is_some_and(|host| matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1" | "0.0.0.0"))
}
fn has_ollama_cloud_credential(config_api_key: Option<&str>) -> bool {
let config_key_present = config_api_key
.map(str::trim)
.is_some_and(|value| !value.is_empty());
if config_key_present {
return true;
}
["OLLAMA_API_KEY", "VELACLAW_API_KEY", "API_KEY"]
.iter()
.any(|name| {
std::env::var(name)
.ok()
.is_some_and(|value| !value.trim().is_empty())
})
}
impl Config {
pub async fn load_or_init() -> Result<Self> {
let (default_velaclaw_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
let (velaclaw_dir, workspace_dir, resolution_source) =
resolve_runtime_config_dirs(&default_velaclaw_dir, &default_workspace_dir).await?;
let config_path = velaclaw_dir.join("config.toml");
fs::create_dir_all(&velaclaw_dir)
.await
.with_context(|| config_dir_creation_error(&velaclaw_dir))?;
fs::create_dir_all(&workspace_dir)
.await
.context("Failed to create workspace directory")?;
if config_path.exists() {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Ok(meta) = fs::metadata(&config_path).await {
if meta.permissions().mode() & 0o004 != 0 {
tracing::warn!(
"Config file {:?} is world-readable (mode {:o}). \
Consider restricting with: chmod 600 {:?}",
config_path,
meta.permissions().mode() & 0o777,
config_path,
);
}
}
}
let contents = fs::read_to_string(&config_path)
.await
.context("Failed to read config file")?;
let mut ignored_paths: Vec<String> = Vec::new();
let mut config: Config = serde_ignored::deserialize(
toml::de::Deserializer::parse(&contents).context("Failed to parse config file")?,
|path| {
ignored_paths.push(path.to_string());
},
)
.context("Failed to deserialize config file")?;
for path in ignored_paths {
tracing::warn!(
"Unknown config key ignored: \"{}\". Check config.toml for typos or deprecated options.",
path
);
}
config.config_path = config_path.clone();
config.workspace_dir = workspace_dir;
let store = crate::security::SecretStore::new(&velaclaw_dir, config.secrets.encrypt);
decrypt_optional_secret(&store, &mut config.api_key, "config.api_key")?;
decrypt_optional_secret(
&store,
&mut config.composio.api_key,
"config.composio.api_key",
)?;
decrypt_optional_secret(
&store,
&mut config.browser.computer_use.api_key,
"config.browser.computer_use.api_key",
)?;
decrypt_optional_secret(
&store,
&mut config.web_search.brave_api_key,
"config.web_search.brave_api_key",
)?;
decrypt_optional_secret(
&store,
&mut config.storage.provider.config.db_url,
"config.storage.provider.config.db_url",
)?;
for agent in config.agents.values_mut() {
decrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
}
config.apply_env_overrides();
config.validate()?;
tracing::info!(
path = %config.config_path.display(),
workspace = %config.workspace_dir.display(),
source = resolution_source.as_str(),
initialized = false,
"Config loaded"
);
Ok(config)
} else {
let mut config = Config::default();
config.config_path = config_path.clone();
config.workspace_dir = workspace_dir;
config.save().await?;
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
let _ = fs::set_permissions(&config_path, Permissions::from_mode(0o600)).await;
}
config.apply_env_overrides();
config.validate()?;
tracing::info!(
path = %config.config_path.display(),
workspace = %config.workspace_dir.display(),
source = resolution_source.as_str(),
initialized = true,
"Config loaded"
);
Ok(config)
}
}
pub fn validate(&self) -> Result<()> {
if self.gateway.host.trim().is_empty() {
anyhow::bail!("gateway.host must not be empty");
}
if self.autonomy.max_actions_per_hour == 0 {
anyhow::bail!("autonomy.max_actions_per_hour must be greater than 0");
}
if self.scheduler.max_concurrent == 0 {
anyhow::bail!("scheduler.max_concurrent must be greater than 0");
}
if self.scheduler.max_tasks == 0 {
anyhow::bail!("scheduler.max_tasks must be greater than 0");
}
for (i, route) in self.model_routes.iter().enumerate() {
if route.hint.trim().is_empty() {
anyhow::bail!("model_routes[{i}].hint must not be empty");
}
if route.provider.trim().is_empty() {
anyhow::bail!("model_routes[{i}].provider must not be empty");
}
if route.model.trim().is_empty() {
anyhow::bail!("model_routes[{i}].model must not be empty");
}
}
for (i, route) in self.embedding_routes.iter().enumerate() {
if route.hint.trim().is_empty() {
anyhow::bail!("embedding_routes[{i}].hint must not be empty");
}
if route.provider.trim().is_empty() {
anyhow::bail!("embedding_routes[{i}].provider must not be empty");
}
if route.model.trim().is_empty() {
anyhow::bail!("embedding_routes[{i}].model must not be empty");
}
}
if self
.default_provider
.as_deref()
.is_some_and(|provider| provider.trim().eq_ignore_ascii_case("ollama"))
&& self
.default_model
.as_deref()
.is_some_and(|model| model.trim().ends_with(":cloud"))
{
if is_local_ollama_endpoint(self.api_url.as_deref()) {
anyhow::bail!(
"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)."
);
}
if !has_ollama_cloud_credential(self.api_key.as_deref()) {
anyhow::bail!(
"default_model uses ':cloud' with provider 'ollama', but no API key is configured. Set api_key or OLLAMA_API_KEY."
);
}
}
self.proxy.validate()?;
Ok(())
}
pub fn apply_env_overrides(&mut self) {
if let Ok(key) = std::env::var("VELACLAW_API_KEY").or_else(|_| std::env::var("API_KEY")) {
if !key.is_empty() {
self.api_key = Some(key);
}
}
if self.default_provider.as_deref().is_some_and(is_glm_alias) {
if let Ok(key) = std::env::var("GLM_API_KEY") {
if !key.is_empty() {
self.api_key = Some(key);
}
}
}
if self.default_provider.as_deref().is_some_and(is_zai_alias) {
if let Ok(key) = std::env::var("ZAI_API_KEY") {
if !key.is_empty() {
self.api_key = Some(key);
}
}
}
if let Ok(provider) = std::env::var("VELACLAW_PROVIDER") {
if !provider.is_empty() {
self.default_provider = Some(provider);
}
} else if let Ok(provider) = std::env::var("PROVIDER") {
let should_apply_legacy_provider =
self.default_provider.as_deref().map_or(true, |configured| {
configured
.trim()
.eq_ignore_ascii_case(DEFAULT_PROTOCOL_MODEL_ID)
});
if should_apply_legacy_provider && !provider.is_empty() {
self.default_provider = Some(provider);
}
}
if let Ok(model) = std::env::var("VELACLAW_MODEL").or_else(|_| std::env::var("MODEL")) {
if !model.is_empty() {
self.default_model = Some(model);
}
}
if let Ok(workspace) = std::env::var("VELACLAW_WORKSPACE") {
if !workspace.is_empty() {
let (_, workspace_dir) =
resolve_config_dir_for_workspace(&PathBuf::from(workspace));
self.workspace_dir = workspace_dir;
}
}
if let Ok(flag) = std::env::var("VELACLAW_OPEN_SKILLS_ENABLED") {
if !flag.trim().is_empty() {
match flag.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => self.skills.open_skills_enabled = true,
"0" | "false" | "no" | "off" => self.skills.open_skills_enabled = false,
_ => tracing::warn!(
"Ignoring invalid VELACLAW_OPEN_SKILLS_ENABLED (valid: 1|0|true|false|yes|no|on|off)"
),
}
}
}
if let Ok(path) = std::env::var("VELACLAW_OPEN_SKILLS_DIR") {
let trimmed = path.trim();
if !trimmed.is_empty() {
self.skills.open_skills_dir = Some(trimmed.to_string());
}
}
if let Ok(mode) = std::env::var("VELACLAW_SKILLS_PROMPT_MODE") {
if !mode.trim().is_empty() {
if let Some(parsed) = parse_skills_prompt_injection_mode(&mode) {
self.skills.prompt_injection_mode = parsed;
} else {
tracing::warn!(
"Ignoring invalid VELACLAW_SKILLS_PROMPT_MODE (valid: full|compact)"
);
}
}
}
if let Ok(port_str) =
std::env::var("VELACLAW_GATEWAY_PORT").or_else(|_| std::env::var("PORT"))
{
if let Ok(port) = port_str.parse::<u16>() {
self.gateway.port = port;
}
}
if let Ok(host) = std::env::var("VELACLAW_GATEWAY_HOST").or_else(|_| std::env::var("HOST"))
{
if !host.is_empty() {
self.gateway.host = host;
}
}
if let Ok(val) = std::env::var("VELACLAW_ALLOW_PUBLIC_BIND") {
self.gateway.allow_public_bind = val == "1" || val.eq_ignore_ascii_case("true");
}
if let Ok(temp_str) = std::env::var("VELACLAW_TEMPERATURE") {
if let Ok(temp) = temp_str.parse::<f64>() {
if (0.0..=2.0).contains(&temp) {
self.default_temperature = temp;
}
}
}
if let Ok(flag) = std::env::var("VELACLAW_REASONING_ENABLED")
.or_else(|_| std::env::var("REASONING_ENABLED"))
{
let normalized = flag.trim().to_ascii_lowercase();
match normalized.as_str() {
"1" | "true" | "yes" | "on" => self.runtime.reasoning_enabled = Some(true),
"0" | "false" | "no" | "off" => self.runtime.reasoning_enabled = Some(false),
_ => {}
}
}
if let Ok(enabled) = std::env::var("VELACLAW_WEB_SEARCH_ENABLED")
.or_else(|_| std::env::var("WEB_SEARCH_ENABLED"))
{
self.web_search.enabled = enabled == "1" || enabled.eq_ignore_ascii_case("true");
}
if let Ok(provider) = std::env::var("VELACLAW_WEB_SEARCH_PROVIDER")
.or_else(|_| std::env::var("WEB_SEARCH_PROVIDER"))
{
let provider = provider.trim();
if !provider.is_empty() {
self.web_search.provider = provider.to_string();
}
}
if let Ok(api_key) =
std::env::var("VELACLAW_BRAVE_API_KEY").or_else(|_| std::env::var("BRAVE_API_KEY"))
{
let api_key = api_key.trim();
if !api_key.is_empty() {
self.web_search.brave_api_key = Some(api_key.to_string());
}
}
if let Ok(max_results) = std::env::var("VELACLAW_WEB_SEARCH_MAX_RESULTS")
.or_else(|_| std::env::var("WEB_SEARCH_MAX_RESULTS"))
{
if let Ok(max_results) = max_results.parse::<usize>() {
if (1..=10).contains(&max_results) {
self.web_search.max_results = max_results;
}
}
}
if let Ok(timeout_secs) = std::env::var("VELACLAW_WEB_SEARCH_TIMEOUT_SECS")
.or_else(|_| std::env::var("WEB_SEARCH_TIMEOUT_SECS"))
{
if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
if timeout_secs > 0 {
self.web_search.timeout_secs = timeout_secs;
}
}
}
if let Ok(provider) = std::env::var("VELACLAW_STORAGE_PROVIDER") {
let provider = provider.trim();
if !provider.is_empty() {
self.storage.provider.config.provider = provider.to_string();
}
}
if let Ok(db_url) = std::env::var("VELACLAW_STORAGE_DB_URL") {
let db_url = db_url.trim();
if !db_url.is_empty() {
self.storage.provider.config.db_url = Some(db_url.to_string());
}
}
if let Ok(timeout_secs) = std::env::var("VELACLAW_STORAGE_CONNECT_TIMEOUT_SECS") {
if let Ok(timeout_secs) = timeout_secs.parse::<u64>() {
if timeout_secs > 0 {
self.storage.provider.config.connect_timeout_secs = Some(timeout_secs);
}
}
}
let explicit_proxy_enabled = std::env::var("VELACLAW_PROXY_ENABLED")
.ok()
.as_deref()
.and_then(parse_proxy_enabled);
if let Some(enabled) = explicit_proxy_enabled {
self.proxy.enabled = enabled;
}
let mut proxy_url_overridden = false;
if let Ok(proxy_url) =
std::env::var("VELACLAW_HTTP_PROXY").or_else(|_| std::env::var("HTTP_PROXY"))
{
self.proxy.http_proxy = normalize_proxy_url_option(Some(&proxy_url));
proxy_url_overridden = true;
}
if let Ok(proxy_url) =
std::env::var("VELACLAW_HTTPS_PROXY").or_else(|_| std::env::var("HTTPS_PROXY"))
{
self.proxy.https_proxy = normalize_proxy_url_option(Some(&proxy_url));
proxy_url_overridden = true;
}
if let Ok(proxy_url) =
std::env::var("VELACLAW_ALL_PROXY").or_else(|_| std::env::var("ALL_PROXY"))
{
self.proxy.all_proxy = normalize_proxy_url_option(Some(&proxy_url));
proxy_url_overridden = true;
}
if let Ok(no_proxy) =
std::env::var("VELACLAW_NO_PROXY").or_else(|_| std::env::var("NO_PROXY"))
{
self.proxy.no_proxy = normalize_no_proxy_list(vec![no_proxy]);
}
if explicit_proxy_enabled.is_none()
&& proxy_url_overridden
&& self.proxy.has_any_proxy_url()
{
self.proxy.enabled = true;
}
if let Ok(scope_raw) = std::env::var("VELACLAW_PROXY_SCOPE") {
if let Some(scope) = parse_proxy_scope(&scope_raw) {
self.proxy.scope = scope;
} else {
tracing::warn!(
scope = %scope_raw,
"Ignoring invalid VELACLAW_PROXY_SCOPE (valid: environment|velaclaw|services)"
);
}
}
if let Ok(services_raw) = std::env::var("VELACLAW_PROXY_SERVICES") {
self.proxy.services = normalize_service_list(vec![services_raw]);
}
if let Err(error) = self.proxy.validate() {
tracing::warn!("Invalid proxy configuration ignored: {error}");
self.proxy.enabled = false;
}
if self.proxy.enabled && self.proxy.scope == ProxyScope::Environment {
self.proxy.apply_to_process_env();
}
set_runtime_proxy_config(self.proxy.clone());
}
pub async fn save(&self) -> Result<()> {
let mut config_to_save = self.clone();
let velaclaw_dir = self
.config_path
.parent()
.context("Config path must have a parent directory")?;
let store = crate::security::SecretStore::new(velaclaw_dir, self.secrets.encrypt);
encrypt_optional_secret(&store, &mut config_to_save.api_key, "config.api_key")?;
encrypt_optional_secret(
&store,
&mut config_to_save.composio.api_key,
"config.composio.api_key",
)?;
encrypt_optional_secret(
&store,
&mut config_to_save.browser.computer_use.api_key,
"config.browser.computer_use.api_key",
)?;
encrypt_optional_secret(
&store,
&mut config_to_save.web_search.brave_api_key,
"config.web_search.brave_api_key",
)?;
encrypt_optional_secret(
&store,
&mut config_to_save.storage.provider.config.db_url,
"config.storage.provider.config.db_url",
)?;
for agent in config_to_save.agents.values_mut() {
encrypt_optional_secret(&store, &mut agent.api_key, "config.agents.*.api_key")?;
}
let toml_str =
toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
let parent_dir = self
.config_path
.parent()
.context("Config path must have a parent directory")?;
fs::create_dir_all(parent_dir).await.with_context(|| {
format!(
"Failed to create config directory: {}",
parent_dir.display()
)
})?;
let file_name = self
.config_path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("config.toml");
let temp_path = parent_dir.join(format!(".{file_name}.tmp-{}", uuid::Uuid::new_v4()));
let backup_path = parent_dir.join(format!("{file_name}.bak"));
let mut temp_file = OpenOptions::new()
.create_new(true)
.write(true)
.open(&temp_path)
.await
.with_context(|| {
format!(
"Failed to create temporary config file: {}",
temp_path.display()
)
})?;
temp_file
.write_all(toml_str.as_bytes())
.await
.context("Failed to write temporary config contents")?;
temp_file
.sync_all()
.await
.context("Failed to fsync temporary config file")?;
drop(temp_file);
let had_existing_config = self.config_path.exists();
if had_existing_config {
fs::copy(&self.config_path, &backup_path)
.await
.with_context(|| {
format!(
"Failed to create config backup before atomic replace: {}",
backup_path.display()
)
})?;
}
if let Err(e) = fs::rename(&temp_path, &self.config_path).await {
let _ = fs::remove_file(&temp_path).await;
if had_existing_config && backup_path.exists() {
fs::copy(&backup_path, &self.config_path)
.await
.context("Failed to restore config backup")?;
}
anyhow::bail!("Failed to atomically replace config file: {e}");
}
sync_directory(parent_dir).await?;
if had_existing_config {
let _ = fs::remove_file(&backup_path).await;
}
Ok(())
}
}
#[cfg_attr(not(unix), allow(clippy::unused_async))]
async fn sync_directory(path: &Path) -> Result<()> {
#[cfg(unix)]
{
let dir = File::open(path)
.await
.with_context(|| format!("Failed to open directory for fsync: {}", path.display()))?;
dir.sync_all()
.await
.with_context(|| format!("Failed to fsync directory metadata: {}", path.display()))?;
Ok(())
}
#[cfg(not(unix))]
{
let _ = path;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[cfg(unix)]
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
use tokio::sync::{Mutex, MutexGuard};
use tokio::test;
use tokio_stream::wrappers::ReadDirStream;
use tokio_stream::StreamExt;
#[test]
async fn config_default_has_sane_values() {
let c = Config::default();
assert_eq!(
c.default_provider.as_deref(),
Some(DEFAULT_PROTOCOL_MODEL_ID)
);
assert_eq!(c.default_model.as_deref(), Some(DEFAULT_PROTOCOL_MODEL_ID));
assert!((c.default_temperature - 0.7).abs() < f64::EPSILON);
assert!(c.api_key.is_none());
assert_eq!(c.routing.provider_mode, ProviderRoutingMode::Byok);
assert!(!c.skills.open_skills_enabled);
assert_eq!(
c.skills.prompt_injection_mode,
SkillsPromptInjectionMode::Full
);
assert!(c.workspace_dir.to_string_lossy().contains("workspace"));
assert!(c.config_path.to_string_lossy().contains("config.toml"));
}
#[test]
async fn routing_defaults_to_byok_json() {
let raw = r"{}";
let cfg: ExecutionRoutingConfig = serde_json::from_str(raw).unwrap();
assert_eq!(cfg.provider_mode, ProviderRoutingMode::Byok);
}
#[test]
async fn routing_parses_prism_mode_json() {
let raw = r#"{"provider_mode":"prism"}"#;
let cfg: ExecutionRoutingConfig = serde_json::from_str(raw).unwrap();
assert_eq!(cfg.provider_mode, ProviderRoutingMode::Prism);
}
#[test]
async fn config_dir_creation_error_mentions_openrc_and_path() {
let msg = config_dir_creation_error(Path::new("/etc/velaclaw"));
assert!(msg.contains("/etc/velaclaw"));
assert!(msg.contains("OpenRC"));
assert!(msg.contains("velaclaw"));
}
#[test]
async fn config_schema_export_contains_expected_contract_shape() {
let schema = schemars::schema_for!(Config);
let schema_json = serde_json::to_value(&schema).expect("schema should serialize to json");
assert_eq!(
schema_json
.get("$schema")
.and_then(serde_json::Value::as_str),
Some("https://json-schema.org/draft/2020-12/schema")
);
let properties = schema_json
.get("properties")
.and_then(serde_json::Value::as_object)
.expect("schema should expose top-level properties");
assert!(properties.contains_key("default_provider"));
assert!(properties.contains_key("skills"));
assert!(properties.contains_key("gateway"));
assert!(properties.contains_key("channels_config"));
assert!(!properties.contains_key("workspace_dir"));
assert!(!properties.contains_key("config_path"));
assert!(
schema_json
.get("$defs")
.and_then(serde_json::Value::as_object)
.is_some(),
"schema should include reusable type definitions"
);
}
#[test]
async fn observability_config_default() {
let o = ObservabilityConfig::default();
assert_eq!(o.backend, "none");
}
#[test]
async fn autonomy_config_default() {
let a = AutonomyConfig::default();
assert_eq!(a.level, AutonomyLevel::Supervised);
assert!(a.workspace_only);
assert!(a.allowed_commands.contains(&"git".to_string()));
assert!(a.allowed_commands.contains(&"cargo".to_string()));
assert!(a.forbidden_paths.contains(&"/etc".to_string()));
assert_eq!(a.max_actions_per_hour, 20);
assert_eq!(a.max_cost_per_day_cents, 500);
assert!(a.require_approval_for_medium_risk);
assert!(a.block_high_risk_commands);
}
#[test]
async fn runtime_config_default() {
let r = RuntimeConfig::default();
assert_eq!(r.kind, "native");
assert_eq!(r.docker.image, "alpine:3.20");
assert_eq!(r.docker.network, "none");
assert_eq!(r.docker.memory_limit_mb, Some(512));
assert_eq!(r.docker.cpu_limit, Some(1.0));
assert!(r.docker.read_only_rootfs);
assert!(r.docker.mount_workspace);
}
#[test]
async fn heartbeat_config_default() {
let h = HeartbeatConfig::default();
assert!(!h.enabled);
assert_eq!(h.interval_minutes, 30);
}
#[test]
async fn cron_config_default() {
let c = CronConfig::default();
assert!(c.enabled);
assert_eq!(c.max_run_history, 50);
}
#[test]
async fn cron_config_serde_roundtrip() {
let c = CronConfig {
enabled: false,
max_run_history: 100,
};
let json = serde_json::to_string(&c).unwrap();
let parsed: CronConfig = serde_json::from_str(&json).unwrap();
assert!(!parsed.enabled);
assert_eq!(parsed.max_run_history, 100);
}
#[test]
async fn config_defaults_cron_when_section_missing() {
let toml_str = r#"
workspace_dir = "/tmp/workspace"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(toml_str).unwrap();
assert!(parsed.cron.enabled);
assert_eq!(parsed.cron.max_run_history, 50);
}
#[test]
async fn memory_config_default_hygiene_settings() {
let m = MemoryConfig::default();
assert_eq!(m.backend, "sqlite");
assert!(m.auto_save);
assert!(m.hygiene_enabled);
assert_eq!(m.archive_after_days, 7);
assert_eq!(m.purge_after_days, 30);
assert_eq!(m.conversation_retention_days, 30);
assert!(m.sqlite_open_timeout_secs.is_none());
}
#[test]
async fn storage_provider_config_defaults() {
let storage = StorageConfig::default();
assert!(storage.provider.config.provider.is_empty());
assert!(storage.provider.config.db_url.is_none());
assert_eq!(storage.provider.config.schema, "public");
assert_eq!(storage.provider.config.table, "memories");
assert!(storage.provider.config.connect_timeout_secs.is_none());
}
#[test]
async fn channels_config_default() {
let c = ChannelsConfig::default();
assert!(c.cli);
assert!(c.telegram.is_none());
assert!(c.discord.is_none());
}
#[test]
async fn config_toml_roundtrip() {
let config = Config {
workspace_dir: PathBuf::from("/tmp/test/workspace"),
config_path: PathBuf::from("/tmp/test/config.toml"),
api_key: Some("sk-test-key".into()),
api_url: None,
default_provider: Some(DEFAULT_PROTOCOL_MODEL_ID.into()),
default_model: Some(DEFAULT_PROTOCOL_MODEL_ID.into()),
default_temperature: 0.5,
observability: ObservabilityConfig {
backend: "log".into(),
..ObservabilityConfig::default()
},
autonomy: AutonomyConfig {
level: AutonomyLevel::Full,
workspace_only: false,
allowed_commands: vec!["docker".into()],
forbidden_paths: vec!["/secret".into()],
max_actions_per_hour: 50,
max_cost_per_day_cents: 1000,
require_approval_for_medium_risk: false,
block_high_risk_commands: true,
auto_approve: vec!["file_read".into()],
always_ask: vec![],
},
runtime: RuntimeConfig {
kind: "docker".into(),
..RuntimeConfig::default()
},
reliability: ReliabilityConfig::default(),
scheduler: SchedulerConfig::default(),
skills: SkillsConfig::default(),
routing: ExecutionRoutingConfig::default(),
telemetry: TelemetryConfig::default(),
model_routes: Vec::new(),
embedding_routes: Vec::new(),
query_classification: QueryClassificationConfig::default(),
heartbeat: HeartbeatConfig {
enabled: true,
interval_minutes: 15,
},
cron: CronConfig::default(),
channels_config: ChannelsConfig {
cli: true,
telegram: Some(TelegramConfig {
bot_token: "123:ABC".into(),
allowed_users: vec!["user1".into()],
stream_mode: StreamMode::default(),
draft_update_interval_ms: default_draft_update_interval_ms(),
interrupt_on_new_message: false,
mention_only: false,
}),
discord: None,
slack: None,
mattermost: None,
webhook: None,
imessage: None,
matrix: None,
signal: None,
whatsapp: None,
linq: None,
nextcloud_talk: None,
email: None,
irc: None,
lark: None,
dingtalk: None,
qq: None,
message_timeout_secs: 300,
},
memory: MemoryConfig::default(),
storage: StorageConfig::default(),
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),
multimodal: MultimodalConfig::default(),
web_search: WebSearchConfig::default(),
proxy: ProxyConfig::default(),
agent: AgentConfig::default(),
identity: IdentityConfig::default(),
cost: CostConfig::default(),
peripherals: PeripheralsConfig::default(),
deploy: DeployConfig::default(),
agents: HashMap::new(),
hardware: HardwareConfig::default(),
};
let toml_str = toml::to_string_pretty(&config).unwrap();
let parsed: Config = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.api_key, config.api_key);
assert_eq!(parsed.default_provider, config.default_provider);
assert_eq!(parsed.default_model, config.default_model);
assert!((parsed.default_temperature - config.default_temperature).abs() < f64::EPSILON);
assert_eq!(parsed.observability.backend, "log");
assert_eq!(parsed.autonomy.level, AutonomyLevel::Full);
assert!(!parsed.autonomy.workspace_only);
assert_eq!(parsed.runtime.kind, "docker");
assert!(parsed.heartbeat.enabled);
assert_eq!(parsed.heartbeat.interval_minutes, 15);
assert!(parsed.channels_config.telegram.is_some());
assert_eq!(
parsed.channels_config.telegram.unwrap().bot_token,
"123:ABC"
);
}
#[test]
async fn config_minimal_toml_uses_defaults() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(parsed.api_key.is_none());
assert!(parsed.default_provider.is_none());
assert_eq!(parsed.observability.backend, "none");
assert_eq!(parsed.autonomy.level, AutonomyLevel::Supervised);
assert_eq!(parsed.runtime.kind, "native");
assert!(!parsed.heartbeat.enabled);
assert!(parsed.channels_config.cli);
assert!(parsed.memory.hygiene_enabled);
assert_eq!(parsed.memory.archive_after_days, 7);
assert_eq!(parsed.memory.purge_after_days, 30);
assert_eq!(parsed.memory.conversation_retention_days, 30);
}
#[test]
async fn config_toml_accepts_protocol_logical_ids_in_provider_and_reliability() {
let raw = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
default_provider = "openai/gpt-4o-mini"
default_model = "gpt-4o-mini"
[reliability]
fallback_providers = ["anthropic/claude-3-5-sonnet-20241022", "openai/gpt-4o"]
[reliability.model_fallbacks]
"gpt-4o" = ["openai/gpt-4o-mini", "anthropic/claude-3-5-sonnet-20241022"]
"#;
let parsed: Config = toml::from_str(raw).unwrap();
assert_eq!(
parsed.default_provider.as_deref(),
Some("openai/gpt-4o-mini")
);
assert_eq!(parsed.default_model.as_deref(), Some("gpt-4o-mini"));
assert_eq!(parsed.reliability.fallback_providers.len(), 2);
assert_eq!(
parsed.reliability.fallback_providers[0],
"anthropic/claude-3-5-sonnet-20241022"
);
let alts = parsed
.reliability
.model_fallbacks
.get("gpt-4o")
.expect("model_fallbacks key");
assert_eq!(alts.len(), 2);
assert_eq!(&alts[0], "openai/gpt-4o-mini");
}
#[test]
async fn storage_provider_dburl_alias_deserializes() {
let raw = r#"
default_temperature = 0.7
[storage.provider.config]
provider = "postgres"
dbURL = "postgres://postgres:postgres@localhost:5432/velaclaw"
schema = "public"
table = "memories"
connect_timeout_secs = 12
"#;
let parsed: Config = toml::from_str(raw).unwrap();
assert_eq!(parsed.storage.provider.config.provider, "postgres");
assert_eq!(
parsed.storage.provider.config.db_url.as_deref(),
Some("postgres://postgres:postgres@localhost:5432/velaclaw")
);
assert_eq!(parsed.storage.provider.config.schema, "public");
assert_eq!(parsed.storage.provider.config.table, "memories");
assert_eq!(
parsed.storage.provider.config.connect_timeout_secs,
Some(12)
);
}
#[test]
async fn runtime_reasoning_enabled_deserializes() {
let raw = r#"
default_temperature = 0.7
[runtime]
reasoning_enabled = false
"#;
let parsed: Config = toml::from_str(raw).unwrap();
assert_eq!(parsed.runtime.reasoning_enabled, Some(false));
}
#[test]
async fn agent_config_defaults() {
let cfg = AgentConfig::default();
assert!(!cfg.compact_context);
assert_eq!(cfg.max_tool_iterations, 10);
assert_eq!(cfg.max_history_messages, 50);
assert!(!cfg.parallel_tools);
assert_eq!(cfg.tool_dispatcher, "auto");
}
#[test]
async fn agent_config_deserializes() {
let raw = r#"
default_temperature = 0.7
[agent]
compact_context = true
max_tool_iterations = 20
max_history_messages = 80
parallel_tools = true
tool_dispatcher = "xml"
"#;
let parsed: Config = toml::from_str(raw).unwrap();
assert!(parsed.agent.compact_context);
assert_eq!(parsed.agent.max_tool_iterations, 20);
assert_eq!(parsed.agent.max_history_messages, 80);
assert!(parsed.agent.parallel_tools);
assert_eq!(parsed.agent.tool_dispatcher, "xml");
}
#[tokio::test]
async fn sync_directory_handles_existing_directory() {
let dir = std::env::temp_dir().join(format!(
"velaclaw_test_sync_directory_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&dir).await.unwrap();
sync_directory(&dir).await.unwrap();
let _ = fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn config_save_and_load_tmpdir() {
let dir = std::env::temp_dir().join("velaclaw_test_config");
let _ = fs::remove_dir_all(&dir).await;
fs::create_dir_all(&dir).await.unwrap();
let config_path = dir.join("config.toml");
let config = Config {
workspace_dir: dir.join("workspace"),
config_path: config_path.clone(),
api_key: Some("sk-roundtrip".into()),
api_url: None,
default_provider: Some(DEFAULT_PROTOCOL_MODEL_ID.into()),
default_model: Some(DEFAULT_PROTOCOL_MODEL_ID.into()),
default_temperature: 0.9,
observability: ObservabilityConfig::default(),
autonomy: AutonomyConfig::default(),
runtime: RuntimeConfig::default(),
reliability: ReliabilityConfig::default(),
scheduler: SchedulerConfig::default(),
skills: SkillsConfig::default(),
routing: ExecutionRoutingConfig::default(),
telemetry: TelemetryConfig::default(),
model_routes: Vec::new(),
embedding_routes: Vec::new(),
query_classification: QueryClassificationConfig::default(),
heartbeat: HeartbeatConfig::default(),
cron: CronConfig::default(),
channels_config: ChannelsConfig::default(),
memory: MemoryConfig::default(),
storage: StorageConfig::default(),
tunnel: TunnelConfig::default(),
gateway: GatewayConfig::default(),
composio: ComposioConfig::default(),
secrets: SecretsConfig::default(),
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),
multimodal: MultimodalConfig::default(),
web_search: WebSearchConfig::default(),
proxy: ProxyConfig::default(),
agent: AgentConfig::default(),
identity: IdentityConfig::default(),
cost: CostConfig::default(),
peripherals: PeripheralsConfig::default(),
deploy: DeployConfig::default(),
agents: HashMap::new(),
hardware: HardwareConfig::default(),
};
config.save().await.unwrap();
assert!(config_path.exists());
let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
let loaded: Config = toml::from_str(&contents).unwrap();
assert!(loaded
.api_key
.as_deref()
.is_some_and(crate::security::SecretStore::is_encrypted));
let store = crate::security::SecretStore::new(&dir, true);
let decrypted = store.decrypt(loaded.api_key.as_deref().unwrap()).unwrap();
assert_eq!(decrypted, "sk-roundtrip");
assert_eq!(
loaded.default_model.as_deref(),
Some(DEFAULT_PROTOCOL_MODEL_ID)
);
assert!((loaded.default_temperature - 0.9).abs() < f64::EPSILON);
let _ = fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn config_save_encrypts_nested_credentials() {
let dir = std::env::temp_dir().join(format!(
"velaclaw_test_nested_credentials_{}",
uuid::Uuid::new_v4()
));
fs::create_dir_all(&dir).await.unwrap();
let mut config = Config::default();
config.workspace_dir = dir.join("workspace");
config.config_path = dir.join("config.toml");
config.api_key = Some("root-credential".into());
config.composio.api_key = Some("composio-credential".into());
config.browser.computer_use.api_key = Some("browser-credential".into());
config.web_search.brave_api_key = Some("brave-credential".into());
config.storage.provider.config.db_url = Some("postgres://user:pw@host/db".into());
config.agents.insert(
"worker".into(),
DelegateAgentConfig {
provider: DEFAULT_PROTOCOL_MODEL_ID.into(),
model: DEFAULT_PROTOCOL_MODEL_ID.into(),
system_prompt: None,
api_key: Some("agent-credential".into()),
temperature: None,
max_depth: 3,
agentic: false,
allowed_tools: Vec::new(),
max_iterations: 10,
},
);
config.save().await.unwrap();
let contents = tokio::fs::read_to_string(config.config_path.clone())
.await
.unwrap();
let stored: Config = toml::from_str(&contents).unwrap();
let store = crate::security::SecretStore::new(&dir, true);
let root_encrypted = stored.api_key.as_deref().unwrap();
assert!(crate::security::SecretStore::is_encrypted(root_encrypted));
assert_eq!(store.decrypt(root_encrypted).unwrap(), "root-credential");
let composio_encrypted = stored.composio.api_key.as_deref().unwrap();
assert!(crate::security::SecretStore::is_encrypted(
composio_encrypted
));
assert_eq!(
store.decrypt(composio_encrypted).unwrap(),
"composio-credential"
);
let browser_encrypted = stored.browser.computer_use.api_key.as_deref().unwrap();
assert!(crate::security::SecretStore::is_encrypted(
browser_encrypted
));
assert_eq!(
store.decrypt(browser_encrypted).unwrap(),
"browser-credential"
);
let web_search_encrypted = stored.web_search.brave_api_key.as_deref().unwrap();
assert!(crate::security::SecretStore::is_encrypted(
web_search_encrypted
));
assert_eq!(
store.decrypt(web_search_encrypted).unwrap(),
"brave-credential"
);
let worker = stored.agents.get("worker").unwrap();
let worker_encrypted = worker.api_key.as_deref().unwrap();
assert!(crate::security::SecretStore::is_encrypted(worker_encrypted));
assert_eq!(store.decrypt(worker_encrypted).unwrap(), "agent-credential");
let storage_db_url = stored.storage.provider.config.db_url.as_deref().unwrap();
assert!(crate::security::SecretStore::is_encrypted(storage_db_url));
assert_eq!(
store.decrypt(storage_db_url).unwrap(),
"postgres://user:pw@host/db"
);
let _ = fs::remove_dir_all(&dir).await;
}
#[tokio::test]
async fn config_save_atomic_cleanup() {
let dir =
std::env::temp_dir().join(format!("velaclaw_test_config_{}", uuid::Uuid::new_v4()));
fs::create_dir_all(&dir).await.unwrap();
let config_path = dir.join("config.toml");
let mut config = Config::default();
config.workspace_dir = dir.join("workspace");
config.config_path = config_path.clone();
config.default_model = Some("model-a".into());
config.save().await.unwrap();
assert!(config_path.exists());
config.default_model = Some("model-b".into());
config.save().await.unwrap();
let contents = tokio::fs::read_to_string(&config_path).await.unwrap();
assert!(contents.contains("model-b"));
let names: Vec<String> = ReadDirStream::new(fs::read_dir(&dir).await.unwrap())
.map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
.collect()
.await;
assert!(!names.iter().any(|name| name.contains(".tmp-")));
assert!(!names.iter().any(|name| name.ends_with(".bak")));
let _ = fs::remove_dir_all(&dir).await;
}
#[test]
async fn telegram_config_serde() {
let tc = TelegramConfig {
bot_token: "123:XYZ".into(),
allowed_users: vec!["alice".into(), "bob".into()],
stream_mode: StreamMode::Partial,
draft_update_interval_ms: 500,
interrupt_on_new_message: true,
mention_only: false,
};
let json = serde_json::to_string(&tc).unwrap();
let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.bot_token, "123:XYZ");
assert_eq!(parsed.allowed_users.len(), 2);
assert_eq!(parsed.stream_mode, StreamMode::Partial);
assert_eq!(parsed.draft_update_interval_ms, 500);
assert!(parsed.interrupt_on_new_message);
}
#[test]
async fn telegram_config_defaults_stream_off() {
let json = r#"{"bot_token":"tok","allowed_users":[]}"#;
let parsed: TelegramConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.stream_mode, StreamMode::Off);
assert_eq!(parsed.draft_update_interval_ms, 1000);
assert!(!parsed.interrupt_on_new_message);
}
#[test]
async fn discord_config_serde() {
let dc = DiscordConfig {
bot_token: "discord-token".into(),
guild_id: Some("12345".into()),
allowed_users: vec![],
listen_to_bots: false,
mention_only: false,
};
let json = serde_json::to_string(&dc).unwrap();
let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.bot_token, "discord-token");
assert_eq!(parsed.guild_id.as_deref(), Some("12345"));
}
#[test]
async fn discord_config_optional_guild() {
let dc = DiscordConfig {
bot_token: "tok".into(),
guild_id: None,
allowed_users: vec![],
listen_to_bots: false,
mention_only: false,
};
let json = serde_json::to_string(&dc).unwrap();
let parsed: DiscordConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.guild_id.is_none());
}
#[test]
async fn imessage_config_serde() {
let ic = IMessageConfig {
allowed_contacts: vec!["+1234567890".into(), "user@icloud.com".into()],
};
let json = serde_json::to_string(&ic).unwrap();
let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.allowed_contacts.len(), 2);
assert_eq!(parsed.allowed_contacts[0], "+1234567890");
}
#[test]
async fn imessage_config_empty_contacts() {
let ic = IMessageConfig {
allowed_contacts: vec![],
};
let json = serde_json::to_string(&ic).unwrap();
let parsed: IMessageConfig = serde_json::from_str(&json).unwrap();
assert!(parsed.allowed_contacts.is_empty());
}
#[test]
async fn imessage_config_wildcard() {
let ic = IMessageConfig {
allowed_contacts: vec!["*".into()],
};
let toml_str = toml::to_string(&ic).unwrap();
let parsed: IMessageConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.allowed_contacts, vec!["*"]);
}
#[test]
async fn matrix_config_serde() {
let mc = MatrixConfig {
homeserver: "https://matrix.org".into(),
access_token: "syt_token_abc".into(),
user_id: Some("@bot:matrix.org".into()),
device_id: Some("DEVICE123".into()),
room_id: "!room123:matrix.org".into(),
allowed_users: vec!["@user:matrix.org".into()],
};
let json = serde_json::to_string(&mc).unwrap();
let parsed: MatrixConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.homeserver, "https://matrix.org");
assert_eq!(parsed.access_token, "syt_token_abc");
assert_eq!(parsed.user_id.as_deref(), Some("@bot:matrix.org"));
assert_eq!(parsed.device_id.as_deref(), Some("DEVICE123"));
assert_eq!(parsed.room_id, "!room123:matrix.org");
assert_eq!(parsed.allowed_users.len(), 1);
}
#[test]
async fn matrix_config_toml_roundtrip() {
let mc = MatrixConfig {
homeserver: "https://synapse.local:8448".into(),
access_token: "tok".into(),
user_id: None,
device_id: None,
room_id: "!abc:synapse.local".into(),
allowed_users: vec!["@admin:synapse.local".into(), "*".into()],
};
let toml_str = toml::to_string(&mc).unwrap();
let parsed: MatrixConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.homeserver, "https://synapse.local:8448");
assert_eq!(parsed.allowed_users.len(), 2);
}
#[test]
async fn matrix_config_backward_compatible_without_session_hints() {
let toml = r#"
homeserver = "https://matrix.org"
access_token = "tok"
room_id = "!ops:matrix.org"
allowed_users = ["@ops:matrix.org"]
"#;
let parsed: MatrixConfig = toml::from_str(toml).unwrap();
assert_eq!(parsed.homeserver, "https://matrix.org");
assert!(parsed.user_id.is_none());
assert!(parsed.device_id.is_none());
}
#[test]
async fn signal_config_serde() {
let sc = SignalConfig {
http_url: "http://127.0.0.1:8686".into(),
account: "+1234567890".into(),
group_id: Some("group123".into()),
allowed_from: vec!["+1111111111".into()],
ignore_attachments: true,
ignore_stories: false,
};
let json = serde_json::to_string(&sc).unwrap();
let parsed: SignalConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.http_url, "http://127.0.0.1:8686");
assert_eq!(parsed.account, "+1234567890");
assert_eq!(parsed.group_id.as_deref(), Some("group123"));
assert_eq!(parsed.allowed_from.len(), 1);
assert!(parsed.ignore_attachments);
assert!(!parsed.ignore_stories);
}
#[test]
async fn signal_config_toml_roundtrip() {
let sc = SignalConfig {
http_url: "http://localhost:8080".into(),
account: "+9876543210".into(),
group_id: None,
allowed_from: vec!["*".into()],
ignore_attachments: false,
ignore_stories: true,
};
let toml_str = toml::to_string(&sc).unwrap();
let parsed: SignalConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.http_url, "http://localhost:8080");
assert_eq!(parsed.account, "+9876543210");
assert!(parsed.group_id.is_none());
assert!(parsed.ignore_stories);
}
#[test]
async fn signal_config_defaults() {
let json = r#"{"http_url":"http://127.0.0.1:8686","account":"+1234567890"}"#;
let parsed: SignalConfig = serde_json::from_str(json).unwrap();
assert!(parsed.group_id.is_none());
assert!(parsed.allowed_from.is_empty());
assert!(!parsed.ignore_attachments);
assert!(!parsed.ignore_stories);
}
#[test]
async fn channels_config_with_imessage_and_matrix() {
let c = ChannelsConfig {
cli: true,
telegram: None,
discord: None,
slack: None,
mattermost: None,
webhook: None,
imessage: Some(IMessageConfig {
allowed_contacts: vec!["+1".into()],
}),
matrix: Some(MatrixConfig {
homeserver: "https://m.org".into(),
access_token: "tok".into(),
user_id: None,
device_id: None,
room_id: "!r:m".into(),
allowed_users: vec!["@u:m".into()],
}),
signal: None,
whatsapp: None,
linq: None,
nextcloud_talk: None,
email: None,
irc: None,
lark: None,
dingtalk: None,
qq: None,
message_timeout_secs: 300,
};
let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.imessage.is_some());
assert!(parsed.matrix.is_some());
assert_eq!(parsed.imessage.unwrap().allowed_contacts, vec!["+1"]);
assert_eq!(parsed.matrix.unwrap().homeserver, "https://m.org");
}
#[test]
async fn channels_config_default_has_no_imessage_matrix() {
let c = ChannelsConfig::default();
assert!(c.imessage.is_none());
assert!(c.matrix.is_none());
}
#[test]
async fn discord_config_deserializes_without_allowed_users() {
let json = r#"{"bot_token":"tok","guild_id":"123"}"#;
let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
assert!(parsed.allowed_users.is_empty());
}
#[test]
async fn discord_config_deserializes_with_allowed_users() {
let json = r#"{"bot_token":"tok","guild_id":"123","allowed_users":["111","222"]}"#;
let parsed: DiscordConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.allowed_users, vec!["111", "222"]);
}
#[test]
async fn slack_config_deserializes_without_allowed_users() {
let json = r#"{"bot_token":"xoxb-tok"}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert!(parsed.allowed_users.is_empty());
}
#[test]
async fn slack_config_deserializes_with_allowed_users() {
let json = r#"{"bot_token":"xoxb-tok","allowed_users":["U111"]}"#;
let parsed: SlackConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.allowed_users, vec!["U111"]);
}
#[test]
async fn discord_config_toml_backward_compat() {
let toml_str = r#"
bot_token = "tok"
guild_id = "123"
"#;
let parsed: DiscordConfig = toml::from_str(toml_str).unwrap();
assert!(parsed.allowed_users.is_empty());
assert_eq!(parsed.bot_token, "tok");
}
#[test]
async fn slack_config_toml_backward_compat() {
let toml_str = r#"
bot_token = "xoxb-tok"
channel_id = "C123"
"#;
let parsed: SlackConfig = toml::from_str(toml_str).unwrap();
assert!(parsed.allowed_users.is_empty());
assert_eq!(parsed.channel_id.as_deref(), Some("C123"));
}
#[test]
async fn webhook_config_with_secret() {
let json = r#"{"port":8080,"secret":"my-secret-key"}"#;
let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.secret.as_deref(), Some("my-secret-key"));
}
#[test]
async fn webhook_config_without_secret() {
let json = r#"{"port":8080}"#;
let parsed: WebhookConfig = serde_json::from_str(json).unwrap();
assert!(parsed.secret.is_none());
assert_eq!(parsed.port, 8080);
}
#[test]
async fn whatsapp_config_serde() {
let wc = WhatsAppConfig {
access_token: Some("EAABx...".into()),
phone_number_id: Some("123456789".into()),
verify_token: Some("my-verify-token".into()),
app_secret: None,
session_path: None,
pair_phone: None,
pair_code: None,
allowed_numbers: vec!["+1234567890".into(), "+9876543210".into()],
};
let json = serde_json::to_string(&wc).unwrap();
let parsed: WhatsAppConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.access_token, Some("EAABx...".into()));
assert_eq!(parsed.phone_number_id, Some("123456789".into()));
assert_eq!(parsed.verify_token, Some("my-verify-token".into()));
assert_eq!(parsed.allowed_numbers.len(), 2);
}
#[test]
async fn whatsapp_config_toml_roundtrip() {
let wc = WhatsAppConfig {
access_token: Some("tok".into()),
phone_number_id: Some("12345".into()),
verify_token: Some("verify".into()),
app_secret: Some("secret123".into()),
session_path: None,
pair_phone: None,
pair_code: None,
allowed_numbers: vec!["+1".into()],
};
let toml_str = toml::to_string(&wc).unwrap();
let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.phone_number_id, Some("12345".into()));
assert_eq!(parsed.allowed_numbers, vec!["+1"]);
}
#[test]
async fn whatsapp_config_deserializes_without_allowed_numbers() {
let json = r#"{"access_token":"tok","phone_number_id":"123","verify_token":"ver"}"#;
let parsed: WhatsAppConfig = serde_json::from_str(json).unwrap();
assert!(parsed.allowed_numbers.is_empty());
}
#[test]
async fn whatsapp_config_wildcard_allowed() {
let wc = WhatsAppConfig {
access_token: Some("tok".into()),
phone_number_id: Some("123".into()),
verify_token: Some("ver".into()),
app_secret: None,
session_path: None,
pair_phone: None,
pair_code: None,
allowed_numbers: vec!["*".into()],
};
let toml_str = toml::to_string(&wc).unwrap();
let parsed: WhatsAppConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.allowed_numbers, vec!["*"]);
}
#[test]
async fn whatsapp_config_backend_type_cloud_precedence_when_ambiguous() {
let wc = WhatsAppConfig {
access_token: Some("tok".into()),
phone_number_id: Some("123".into()),
verify_token: Some("ver".into()),
app_secret: None,
session_path: Some("~/.velaclaw/state/whatsapp-web/session.db".into()),
pair_phone: None,
pair_code: None,
allowed_numbers: vec!["+1".into()],
};
assert!(wc.is_ambiguous_config());
assert_eq!(wc.backend_type(), "cloud");
}
#[test]
async fn whatsapp_config_backend_type_web() {
let wc = WhatsAppConfig {
access_token: None,
phone_number_id: None,
verify_token: None,
app_secret: None,
session_path: Some("~/.velaclaw/state/whatsapp-web/session.db".into()),
pair_phone: None,
pair_code: None,
allowed_numbers: vec![],
};
assert!(!wc.is_ambiguous_config());
assert_eq!(wc.backend_type(), "web");
}
#[test]
async fn channels_config_with_whatsapp() {
let c = ChannelsConfig {
cli: true,
telegram: None,
discord: None,
slack: None,
mattermost: None,
webhook: None,
imessage: None,
matrix: None,
signal: None,
whatsapp: Some(WhatsAppConfig {
access_token: Some("tok".into()),
phone_number_id: Some("123".into()),
verify_token: Some("ver".into()),
app_secret: None,
session_path: None,
pair_phone: None,
pair_code: None,
allowed_numbers: vec!["+1".into()],
}),
linq: None,
nextcloud_talk: None,
email: None,
irc: None,
lark: None,
dingtalk: None,
qq: None,
message_timeout_secs: 300,
};
let toml_str = toml::to_string_pretty(&c).unwrap();
let parsed: ChannelsConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.whatsapp.is_some());
let wa = parsed.whatsapp.unwrap();
assert_eq!(wa.phone_number_id, Some("123".into()));
assert_eq!(wa.allowed_numbers, vec!["+1"]);
}
#[test]
async fn channels_config_default_has_no_whatsapp() {
let c = ChannelsConfig::default();
assert!(c.whatsapp.is_none());
}
#[test]
async fn channels_config_default_has_no_nextcloud_talk() {
let c = ChannelsConfig::default();
assert!(c.nextcloud_talk.is_none());
}
#[test]
async fn checklist_gateway_default_requires_pairing() {
let g = GatewayConfig::default();
assert!(g.require_pairing, "Pairing must be required by default");
}
#[test]
async fn checklist_gateway_default_blocks_public_bind() {
let g = GatewayConfig::default();
assert!(
!g.allow_public_bind,
"Public bind must be blocked by default"
);
}
#[test]
async fn checklist_gateway_default_no_tokens() {
let g = GatewayConfig::default();
assert!(
g.paired_tokens.is_empty(),
"No pre-paired tokens by default"
);
assert_eq!(g.pair_rate_limit_per_minute, 10);
assert_eq!(g.webhook_rate_limit_per_minute, 60);
assert!(!g.trust_forwarded_headers);
assert_eq!(g.rate_limit_max_keys, 10_000);
assert_eq!(g.idempotency_ttl_secs, 300);
assert_eq!(g.idempotency_max_keys, 10_000);
}
#[test]
async fn checklist_gateway_cli_default_host_is_localhost() {
let c = Config::default();
assert!(
c.gateway.require_pairing,
"Config default must require pairing"
);
assert!(
!c.gateway.allow_public_bind,
"Config default must block public bind"
);
}
#[test]
async fn checklist_gateway_serde_roundtrip() {
let g = GatewayConfig {
port: 3000,
host: "127.0.0.1".into(),
require_pairing: true,
allow_public_bind: false,
paired_tokens: vec!["zc_test_token".into()],
pair_rate_limit_per_minute: 12,
webhook_rate_limit_per_minute: 80,
trust_forwarded_headers: true,
rate_limit_max_keys: 2048,
idempotency_ttl_secs: 600,
idempotency_max_keys: 4096,
};
let toml_str = toml::to_string(&g).unwrap();
let parsed: GatewayConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.require_pairing);
assert!(!parsed.allow_public_bind);
assert_eq!(parsed.paired_tokens, vec!["zc_test_token"]);
assert_eq!(parsed.pair_rate_limit_per_minute, 12);
assert_eq!(parsed.webhook_rate_limit_per_minute, 80);
assert!(parsed.trust_forwarded_headers);
assert_eq!(parsed.rate_limit_max_keys, 2048);
assert_eq!(parsed.idempotency_ttl_secs, 600);
assert_eq!(parsed.idempotency_max_keys, 4096);
}
#[test]
async fn checklist_gateway_backward_compat_no_gateway_section() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(
parsed.gateway.require_pairing,
"Missing [gateway] must default to require_pairing=true"
);
assert!(
!parsed.gateway.allow_public_bind,
"Missing [gateway] must default to allow_public_bind=false"
);
}
#[test]
async fn checklist_autonomy_default_is_workspace_scoped() {
let a = AutonomyConfig::default();
assert!(a.workspace_only, "Default autonomy must be workspace_only");
assert!(
a.forbidden_paths.contains(&"/etc".to_string()),
"Must block /etc"
);
assert!(
a.forbidden_paths.contains(&"/proc".to_string()),
"Must block /proc"
);
assert!(
a.forbidden_paths.contains(&"~/.ssh".to_string()),
"Must block ~/.ssh"
);
}
#[test]
async fn composio_config_default_disabled() {
let c = ComposioConfig::default();
assert!(!c.enabled, "Composio must be disabled by default");
assert!(c.api_key.is_none(), "No API key by default");
assert_eq!(c.entity_id, "default");
}
#[test]
async fn composio_config_serde_roundtrip() {
let c = ComposioConfig {
enabled: true,
api_key: Some("comp-key-123".into()),
entity_id: "user42".into(),
};
let toml_str = toml::to_string(&c).unwrap();
let parsed: ComposioConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.api_key.as_deref(), Some("comp-key-123"));
assert_eq!(parsed.entity_id, "user42");
}
#[test]
async fn composio_config_backward_compat_missing_section() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(
!parsed.composio.enabled,
"Missing [composio] must default to disabled"
);
assert!(parsed.composio.api_key.is_none());
}
#[test]
async fn composio_config_partial_toml() {
let toml_str = r"
enabled = true
";
let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
assert!(parsed.enabled);
assert!(parsed.api_key.is_none());
assert_eq!(parsed.entity_id, "default");
}
#[test]
async fn composio_config_enable_alias_supported() {
let toml_str = r"
enable = true
";
let parsed: ComposioConfig = toml::from_str(toml_str).unwrap();
assert!(parsed.enabled);
assert!(parsed.api_key.is_none());
assert_eq!(parsed.entity_id, "default");
}
#[test]
async fn secrets_config_default_encrypts() {
let s = SecretsConfig::default();
assert!(s.encrypt, "Encryption must be enabled by default");
}
#[test]
async fn secrets_config_serde_roundtrip() {
let s = SecretsConfig { encrypt: false };
let toml_str = toml::to_string(&s).unwrap();
let parsed: SecretsConfig = toml::from_str(&toml_str).unwrap();
assert!(!parsed.encrypt);
}
#[test]
async fn secrets_config_backward_compat_missing_section() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(
parsed.secrets.encrypt,
"Missing [secrets] must default to encrypt=true"
);
}
#[test]
async fn config_default_has_composio_and_secrets() {
let c = Config::default();
assert!(!c.composio.enabled);
assert!(c.composio.api_key.is_none());
assert!(c.secrets.encrypt);
assert!(!c.browser.enabled);
assert!(c.browser.allowed_domains.is_empty());
}
#[test]
async fn browser_config_default_disabled() {
let b = BrowserConfig::default();
assert!(!b.enabled);
assert!(b.allowed_domains.is_empty());
assert_eq!(b.backend, "agent_browser");
assert!(b.native_headless);
assert_eq!(b.native_webdriver_url, "http://127.0.0.1:9515");
assert!(b.native_chrome_path.is_none());
assert_eq!(b.computer_use.endpoint, "http://127.0.0.1:8787/v1/actions");
assert_eq!(b.computer_use.timeout_ms, 15_000);
assert!(!b.computer_use.allow_remote_endpoint);
assert!(b.computer_use.window_allowlist.is_empty());
assert!(b.computer_use.max_coordinate_x.is_none());
assert!(b.computer_use.max_coordinate_y.is_none());
}
#[test]
async fn browser_config_serde_roundtrip() {
let b = BrowserConfig {
enabled: true,
allowed_domains: vec!["example.com".into(), "docs.example.com".into()],
session_name: None,
backend: "auto".into(),
native_headless: false,
native_webdriver_url: "http://localhost:4444".into(),
native_chrome_path: Some("/usr/bin/chromium".into()),
computer_use: BrowserComputerUseConfig {
endpoint: "https://computer-use.example.com/v1/actions".into(),
api_key: Some("test-token".into()),
timeout_ms: 8_000,
allow_remote_endpoint: true,
window_allowlist: vec!["Chrome".into(), "Visual Studio Code".into()],
max_coordinate_x: Some(3840),
max_coordinate_y: Some(2160),
},
};
let toml_str = toml::to_string(&b).unwrap();
let parsed: BrowserConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.allowed_domains.len(), 2);
assert_eq!(parsed.allowed_domains[0], "example.com");
assert_eq!(parsed.backend, "auto");
assert!(!parsed.native_headless);
assert_eq!(parsed.native_webdriver_url, "http://localhost:4444");
assert_eq!(
parsed.native_chrome_path.as_deref(),
Some("/usr/bin/chromium")
);
assert_eq!(
parsed.computer_use.endpoint,
"https://computer-use.example.com/v1/actions"
);
assert_eq!(parsed.computer_use.api_key.as_deref(), Some("test-token"));
assert_eq!(parsed.computer_use.timeout_ms, 8_000);
assert!(parsed.computer_use.allow_remote_endpoint);
assert_eq!(parsed.computer_use.window_allowlist.len(), 2);
assert_eq!(parsed.computer_use.max_coordinate_x, Some(3840));
assert_eq!(parsed.computer_use.max_coordinate_y, Some(2160));
}
#[test]
async fn browser_config_backward_compat_missing_section() {
let minimal = r#"
workspace_dir = "/tmp/ws"
config_path = "/tmp/config.toml"
default_temperature = 0.7
"#;
let parsed: Config = toml::from_str(minimal).unwrap();
assert!(!parsed.browser.enabled);
assert!(parsed.browser.allowed_domains.is_empty());
}
async fn env_override_lock() -> MutexGuard<'static, ()> {
static ENV_OVERRIDE_TEST_LOCK: Mutex<()> = Mutex::const_new(());
ENV_OVERRIDE_TEST_LOCK.lock().await
}
fn clear_proxy_env_test_vars() {
for key in [
"VELACLAW_PROXY_ENABLED",
"VELACLAW_HTTP_PROXY",
"VELACLAW_HTTPS_PROXY",
"VELACLAW_ALL_PROXY",
"VELACLAW_NO_PROXY",
"VELACLAW_PROXY_SCOPE",
"VELACLAW_PROXY_SERVICES",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
] {
std::env::remove_var(key);
}
}
#[test]
async fn env_override_api_key() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
assert!(config.api_key.is_none());
std::env::set_var("VELACLAW_API_KEY", "sk-test-env-key");
config.apply_env_overrides();
assert_eq!(config.api_key.as_deref(), Some("sk-test-env-key"));
std::env::remove_var("VELACLAW_API_KEY");
}
#[test]
async fn env_override_api_key_fallback() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
std::env::remove_var("VELACLAW_API_KEY");
std::env::set_var("API_KEY", "sk-fallback-key");
config.apply_env_overrides();
assert_eq!(config.api_key.as_deref(), Some("sk-fallback-key"));
std::env::remove_var("API_KEY");
}
#[test]
async fn env_override_provider() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
std::env::set_var("VELACLAW_PROVIDER", "anthropic");
config.apply_env_overrides();
assert_eq!(config.default_provider.as_deref(), Some("anthropic"));
std::env::remove_var("VELACLAW_PROVIDER");
}
#[test]
async fn env_override_open_skills_enabled_and_dir() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
assert!(!config.skills.open_skills_enabled);
assert!(config.skills.open_skills_dir.is_none());
assert_eq!(
config.skills.prompt_injection_mode,
SkillsPromptInjectionMode::Full
);
std::env::set_var("VELACLAW_OPEN_SKILLS_ENABLED", "true");
std::env::set_var("VELACLAW_OPEN_SKILLS_DIR", "/tmp/open-skills");
std::env::set_var("VELACLAW_SKILLS_PROMPT_MODE", "compact");
config.apply_env_overrides();
assert!(config.skills.open_skills_enabled);
assert_eq!(
config.skills.open_skills_dir.as_deref(),
Some("/tmp/open-skills")
);
assert_eq!(
config.skills.prompt_injection_mode,
SkillsPromptInjectionMode::Compact
);
std::env::remove_var("VELACLAW_OPEN_SKILLS_ENABLED");
std::env::remove_var("VELACLAW_OPEN_SKILLS_DIR");
std::env::remove_var("VELACLAW_SKILLS_PROMPT_MODE");
}
#[test]
async fn env_override_open_skills_enabled_invalid_value_keeps_existing_value() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
config.skills.open_skills_enabled = true;
config.skills.prompt_injection_mode = SkillsPromptInjectionMode::Compact;
std::env::set_var("VELACLAW_OPEN_SKILLS_ENABLED", "maybe");
std::env::set_var("VELACLAW_SKILLS_PROMPT_MODE", "invalid");
config.apply_env_overrides();
assert!(config.skills.open_skills_enabled);
assert_eq!(
config.skills.prompt_injection_mode,
SkillsPromptInjectionMode::Compact
);
std::env::remove_var("VELACLAW_OPEN_SKILLS_ENABLED");
std::env::remove_var("VELACLAW_SKILLS_PROMPT_MODE");
}
#[test]
async fn env_override_provider_fallback() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
std::env::remove_var("VELACLAW_PROVIDER");
std::env::set_var("PROVIDER", "openai");
config.apply_env_overrides();
assert_eq!(config.default_provider.as_deref(), Some("openai"));
std::env::remove_var("PROVIDER");
}
#[test]
async fn env_override_provider_fallback_does_not_replace_non_default_provider() {
let _env_guard = env_override_lock().await;
let mut config = Config {
default_provider: Some("local-gateway/my-model".to_string()),
..Config::default()
};
std::env::remove_var("VELACLAW_PROVIDER");
std::env::set_var("PROVIDER", DEFAULT_PROTOCOL_MODEL_ID);
config.apply_env_overrides();
assert_eq!(
config.default_provider.as_deref(),
Some("local-gateway/my-model")
);
std::env::remove_var("PROVIDER");
}
#[test]
async fn env_override_velaclaw_provider_overrides_non_default_provider() {
let _env_guard = env_override_lock().await;
let mut config = Config {
default_provider: Some("local-gateway/my-model".to_string()),
..Config::default()
};
std::env::set_var("VELACLAW_PROVIDER", DEFAULT_PROTOCOL_MODEL_ID);
std::env::set_var("PROVIDER", "anthropic/claude-sonnet-4-5-20250929");
config.apply_env_overrides();
assert_eq!(
config.default_provider.as_deref(),
Some(DEFAULT_PROTOCOL_MODEL_ID)
);
std::env::remove_var("VELACLAW_PROVIDER");
std::env::remove_var("PROVIDER");
}
#[test]
async fn env_override_glm_api_key_for_regional_aliases() {
let _env_guard = env_override_lock().await;
let mut config = Config {
default_provider: Some("glm-cn".to_string()),
..Config::default()
};
std::env::set_var("GLM_API_KEY", "glm-regional-key");
config.apply_env_overrides();
assert_eq!(config.api_key.as_deref(), Some("glm-regional-key"));
std::env::remove_var("GLM_API_KEY");
}
#[test]
async fn env_override_zai_api_key_for_regional_aliases() {
let _env_guard = env_override_lock().await;
let mut config = Config {
default_provider: Some("zai-cn".to_string()),
..Config::default()
};
std::env::set_var("ZAI_API_KEY", "zai-regional-key");
config.apply_env_overrides();
assert_eq!(config.api_key.as_deref(), Some("zai-regional-key"));
std::env::remove_var("ZAI_API_KEY");
}
#[test]
async fn env_override_model() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
std::env::set_var("VELACLAW_MODEL", "gpt-4o");
config.apply_env_overrides();
assert_eq!(config.default_model.as_deref(), Some("gpt-4o"));
std::env::remove_var("VELACLAW_MODEL");
}
#[test]
async fn validate_ollama_cloud_model_requires_remote_api_url() {
let _env_guard = env_override_lock().await;
let config = Config {
default_provider: Some("ollama".to_string()),
default_model: Some("glm-5:cloud".to_string()),
api_url: None,
api_key: Some("ollama-key".to_string()),
..Config::default()
};
let error = config.validate().expect_err("expected validation to fail");
assert!(error.to_string().contains(
"default_model uses ':cloud' with provider 'ollama', but api_url is local or unset"
));
}
#[test]
async fn validate_ollama_cloud_model_accepts_remote_endpoint_and_env_key() {
let _env_guard = env_override_lock().await;
let config = Config {
default_provider: Some("ollama".to_string()),
default_model: Some("glm-5:cloud".to_string()),
api_url: Some("https://ollama.com/api".to_string()),
api_key: None,
..Config::default()
};
std::env::set_var("OLLAMA_API_KEY", "ollama-env-key");
let result = config.validate();
std::env::remove_var("OLLAMA_API_KEY");
assert!(result.is_ok(), "expected validation to pass: {result:?}");
}
#[test]
async fn env_override_model_fallback() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
std::env::remove_var("VELACLAW_MODEL");
std::env::set_var("MODEL", "anthropic/claude-3.5-sonnet");
config.apply_env_overrides();
assert_eq!(
config.default_model.as_deref(),
Some("anthropic/claude-3.5-sonnet")
);
std::env::remove_var("MODEL");
}
#[test]
async fn env_override_workspace() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
std::env::set_var("VELACLAW_WORKSPACE", "/custom/workspace");
config.apply_env_overrides();
assert_eq!(config.workspace_dir, PathBuf::from("/custom/workspace"));
std::env::remove_var("VELACLAW_WORKSPACE");
}
#[test]
async fn resolve_runtime_config_dirs_uses_env_workspace_first() {
let _env_guard = env_override_lock().await;
let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
let default_workspace_dir = default_config_dir.join("workspace");
let workspace_dir = default_config_dir.join("profile-a");
std::env::set_var("VELACLAW_WORKSPACE", &workspace_dir);
let (config_dir, resolved_workspace_dir, source) =
resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
.await
.unwrap();
assert_eq!(source, ConfigResolutionSource::EnvWorkspace);
assert_eq!(config_dir, workspace_dir);
assert_eq!(resolved_workspace_dir, workspace_dir.join("workspace"));
std::env::remove_var("VELACLAW_WORKSPACE");
let _ = fs::remove_dir_all(default_config_dir).await;
}
#[test]
async fn resolve_runtime_config_dirs_uses_env_config_dir_first() {
let _env_guard = env_override_lock().await;
let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
let default_workspace_dir = default_config_dir.join("workspace");
let explicit_config_dir = default_config_dir.join("explicit-config");
let marker_config_dir = default_config_dir.join("profiles").join("alpha");
let state_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
fs::create_dir_all(&default_config_dir).await.unwrap();
let state = ActiveWorkspaceState {
config_dir: marker_config_dir.to_string_lossy().into_owned(),
};
fs::write(&state_path, toml::to_string(&state).unwrap())
.await
.unwrap();
std::env::set_var("VELACLAW_CONFIG_DIR", &explicit_config_dir);
std::env::remove_var("VELACLAW_WORKSPACE");
let (config_dir, resolved_workspace_dir, source) =
resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
.await
.unwrap();
assert_eq!(source, ConfigResolutionSource::EnvConfigDir);
assert_eq!(config_dir, explicit_config_dir);
assert_eq!(
resolved_workspace_dir,
explicit_config_dir.join("workspace")
);
std::env::remove_var("VELACLAW_CONFIG_DIR");
let _ = fs::remove_dir_all(default_config_dir).await;
}
#[test]
async fn resolve_runtime_config_dirs_uses_active_workspace_marker() {
let _env_guard = env_override_lock().await;
let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
let default_workspace_dir = default_config_dir.join("workspace");
let marker_config_dir = default_config_dir.join("profiles").join("alpha");
let state_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
std::env::remove_var("VELACLAW_WORKSPACE");
fs::create_dir_all(&default_config_dir).await.unwrap();
let state = ActiveWorkspaceState {
config_dir: marker_config_dir.to_string_lossy().into_owned(),
};
fs::write(&state_path, toml::to_string(&state).unwrap())
.await
.unwrap();
let (config_dir, resolved_workspace_dir, source) =
resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
.await
.unwrap();
assert_eq!(source, ConfigResolutionSource::ActiveWorkspaceMarker);
assert_eq!(config_dir, marker_config_dir);
assert_eq!(resolved_workspace_dir, marker_config_dir.join("workspace"));
let _ = fs::remove_dir_all(default_config_dir).await;
}
#[test]
async fn resolve_runtime_config_dirs_falls_back_to_default_layout() {
let _env_guard = env_override_lock().await;
let default_config_dir = std::env::temp_dir().join(uuid::Uuid::new_v4().to_string());
let default_workspace_dir = default_config_dir.join("workspace");
std::env::remove_var("VELACLAW_WORKSPACE");
let (config_dir, resolved_workspace_dir, source) =
resolve_runtime_config_dirs(&default_config_dir, &default_workspace_dir)
.await
.unwrap();
assert_eq!(source, ConfigResolutionSource::DefaultConfigDir);
assert_eq!(config_dir, default_config_dir);
assert_eq!(resolved_workspace_dir, default_workspace_dir);
let _ = fs::remove_dir_all(default_config_dir).await;
}
#[test]
#[cfg(unix)]
async fn load_or_init_workspace_override_uses_workspace_root_for_config() {
let _env_guard = env_override_lock().await;
let temp_home =
std::env::temp_dir().join(format!("velaclaw_test_home_{}", uuid::Uuid::new_v4()));
let workspace_dir = temp_home.join("profile-a");
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", &temp_home);
std::env::set_var("VELACLAW_WORKSPACE", &workspace_dir);
let config = Config::load_or_init().await.unwrap();
assert_eq!(config.workspace_dir, workspace_dir.join("workspace"));
assert_eq!(config.config_path, workspace_dir.join("config.toml"));
assert!(workspace_dir.join("config.toml").exists());
std::env::remove_var("VELACLAW_WORKSPACE");
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = fs::remove_dir_all(temp_home).await;
}
#[test]
#[cfg(unix)]
async fn load_or_init_workspace_suffix_uses_legacy_config_layout() {
let _env_guard = env_override_lock().await;
let temp_home =
std::env::temp_dir().join(format!("velaclaw_test_home_{}", uuid::Uuid::new_v4()));
let workspace_dir = temp_home.join("workspace");
let legacy_config_path = temp_home.join(".velaclaw").join("config.toml");
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", &temp_home);
std::env::set_var("VELACLAW_WORKSPACE", &workspace_dir);
let config = Config::load_or_init().await.unwrap();
assert_eq!(config.workspace_dir, workspace_dir);
assert_eq!(config.config_path, legacy_config_path);
assert!(config.config_path.exists());
std::env::remove_var("VELACLAW_WORKSPACE");
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = fs::remove_dir_all(temp_home).await;
}
#[test]
#[cfg(unix)]
async fn load_or_init_workspace_override_keeps_existing_legacy_config() {
let _env_guard = env_override_lock().await;
let temp_home =
std::env::temp_dir().join(format!("velaclaw_test_home_{}", uuid::Uuid::new_v4()));
let workspace_dir = temp_home.join("custom-workspace");
let legacy_config_dir = temp_home.join(".velaclaw");
let legacy_config_path = legacy_config_dir.join("config.toml");
fs::create_dir_all(&legacy_config_dir).await.unwrap();
fs::write(
&legacy_config_path,
r#"default_temperature = 0.7
default_model = "legacy-model"
"#,
)
.await
.unwrap();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", &temp_home);
std::env::set_var("VELACLAW_WORKSPACE", &workspace_dir);
let config = Config::load_or_init().await.unwrap();
assert_eq!(config.workspace_dir, workspace_dir);
assert_eq!(config.config_path, legacy_config_path);
assert_eq!(config.default_model.as_deref(), Some("legacy-model"));
std::env::remove_var("VELACLAW_WORKSPACE");
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = fs::remove_dir_all(temp_home).await;
}
#[test]
#[cfg(unix)]
async fn load_or_init_uses_persisted_active_workspace_marker() {
let _env_guard = env_override_lock().await;
let temp_home =
std::env::temp_dir().join(format!("velaclaw_test_home_{}", uuid::Uuid::new_v4()));
let custom_config_dir = temp_home.join("profiles").join("agent-alpha");
fs::create_dir_all(&custom_config_dir).await.unwrap();
fs::write(
custom_config_dir.join("config.toml"),
"default_temperature = 0.7\ndefault_model = \"persisted-profile\"\n",
)
.await
.unwrap();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", &temp_home);
std::env::remove_var("VELACLAW_WORKSPACE");
persist_active_workspace_config_dir(&custom_config_dir)
.await
.unwrap();
let config = Config::load_or_init().await.unwrap();
assert_eq!(config.config_path, custom_config_dir.join("config.toml"));
assert_eq!(config.workspace_dir, custom_config_dir.join("workspace"));
assert_eq!(config.default_model.as_deref(), Some("persisted-profile"));
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = fs::remove_dir_all(temp_home).await;
}
#[test]
#[cfg(unix)]
async fn load_or_init_env_workspace_override_takes_priority_over_marker() {
let _env_guard = env_override_lock().await;
let temp_home =
std::env::temp_dir().join(format!("velaclaw_test_home_{}", uuid::Uuid::new_v4()));
let marker_config_dir = temp_home.join("profiles").join("persisted-profile");
let env_workspace_dir = temp_home.join("env-workspace");
fs::create_dir_all(&marker_config_dir).await.unwrap();
fs::write(
marker_config_dir.join("config.toml"),
"default_temperature = 0.7\ndefault_model = \"marker-model\"\n",
)
.await
.unwrap();
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", &temp_home);
persist_active_workspace_config_dir(&marker_config_dir)
.await
.unwrap();
std::env::set_var("VELACLAW_WORKSPACE", &env_workspace_dir);
let config = Config::load_or_init().await.unwrap();
assert_eq!(config.workspace_dir, env_workspace_dir.join("workspace"));
assert_eq!(config.config_path, env_workspace_dir.join("config.toml"));
std::env::remove_var("VELACLAW_WORKSPACE");
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = fs::remove_dir_all(temp_home).await;
}
#[test]
#[cfg(unix)]
async fn persist_active_workspace_marker_is_cleared_for_default_config_dir() {
let _env_guard = env_override_lock().await;
let temp_home =
std::env::temp_dir().join(format!("velaclaw_test_home_{}", uuid::Uuid::new_v4()));
let default_config_dir = temp_home.join(".velaclaw");
let custom_config_dir = temp_home.join("profiles").join("custom-profile");
let marker_path = default_config_dir.join(ACTIVE_WORKSPACE_STATE_FILE);
let original_home = std::env::var("HOME").ok();
std::env::set_var("HOME", &temp_home);
persist_active_workspace_config_dir(&custom_config_dir)
.await
.unwrap();
assert!(marker_path.exists());
persist_active_workspace_config_dir(&default_config_dir)
.await
.unwrap();
assert!(!marker_path.exists());
if let Some(home) = original_home {
std::env::set_var("HOME", home);
} else {
std::env::remove_var("HOME");
}
let _ = fs::remove_dir_all(temp_home).await;
}
#[test]
async fn env_override_empty_values_ignored() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
let original_provider = config.default_provider.clone();
std::env::set_var("VELACLAW_PROVIDER", "");
config.apply_env_overrides();
assert_eq!(config.default_provider, original_provider);
std::env::remove_var("VELACLAW_PROVIDER");
}
#[test]
async fn env_override_gateway_port() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
assert_eq!(config.gateway.port, 3000);
std::env::set_var("VELACLAW_GATEWAY_PORT", "8080");
config.apply_env_overrides();
assert_eq!(config.gateway.port, 8080);
std::env::remove_var("VELACLAW_GATEWAY_PORT");
}
#[test]
async fn env_override_port_fallback() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
std::env::remove_var("VELACLAW_GATEWAY_PORT");
std::env::set_var("PORT", "9000");
config.apply_env_overrides();
assert_eq!(config.gateway.port, 9000);
std::env::remove_var("PORT");
}
#[test]
async fn env_override_gateway_host() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
assert_eq!(config.gateway.host, "127.0.0.1");
std::env::set_var("VELACLAW_GATEWAY_HOST", "0.0.0.0");
config.apply_env_overrides();
assert_eq!(config.gateway.host, "0.0.0.0");
std::env::remove_var("VELACLAW_GATEWAY_HOST");
}
#[test]
async fn env_override_host_fallback() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
std::env::remove_var("VELACLAW_GATEWAY_HOST");
std::env::set_var("HOST", "0.0.0.0");
config.apply_env_overrides();
assert_eq!(config.gateway.host, "0.0.0.0");
std::env::remove_var("HOST");
}
#[test]
async fn env_override_temperature() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
std::env::set_var("VELACLAW_TEMPERATURE", "0.5");
config.apply_env_overrides();
assert!((config.default_temperature - 0.5).abs() < f64::EPSILON);
std::env::remove_var("VELACLAW_TEMPERATURE");
}
#[test]
async fn env_override_temperature_out_of_range_ignored() {
let _env_guard = env_override_lock().await;
std::env::remove_var("VELACLAW_TEMPERATURE");
let mut config = Config::default();
let original_temp = config.default_temperature;
std::env::set_var("VELACLAW_TEMPERATURE", "3.0");
config.apply_env_overrides();
assert!(
(config.default_temperature - original_temp).abs() < f64::EPSILON,
"Temperature 3.0 should be ignored (out of range)"
);
std::env::remove_var("VELACLAW_TEMPERATURE");
}
#[test]
async fn env_override_reasoning_enabled() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
assert_eq!(config.runtime.reasoning_enabled, None);
std::env::set_var("VELACLAW_REASONING_ENABLED", "false");
config.apply_env_overrides();
assert_eq!(config.runtime.reasoning_enabled, Some(false));
std::env::set_var("VELACLAW_REASONING_ENABLED", "true");
config.apply_env_overrides();
assert_eq!(config.runtime.reasoning_enabled, Some(true));
std::env::remove_var("VELACLAW_REASONING_ENABLED");
}
#[test]
async fn env_override_reasoning_invalid_value_ignored() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
config.runtime.reasoning_enabled = Some(false);
std::env::set_var("VELACLAW_REASONING_ENABLED", "maybe");
config.apply_env_overrides();
assert_eq!(config.runtime.reasoning_enabled, Some(false));
std::env::remove_var("VELACLAW_REASONING_ENABLED");
}
#[test]
async fn env_override_invalid_port_ignored() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
let original_port = config.gateway.port;
std::env::set_var("PORT", "not_a_number");
config.apply_env_overrides();
assert_eq!(config.gateway.port, original_port);
std::env::remove_var("PORT");
}
#[test]
async fn env_override_web_search_config() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
std::env::set_var("WEB_SEARCH_ENABLED", "false");
std::env::set_var("WEB_SEARCH_PROVIDER", "brave");
std::env::set_var("WEB_SEARCH_MAX_RESULTS", "7");
std::env::set_var("WEB_SEARCH_TIMEOUT_SECS", "20");
std::env::set_var("BRAVE_API_KEY", "brave-test-key");
config.apply_env_overrides();
assert!(!config.web_search.enabled);
assert_eq!(config.web_search.provider, "brave");
assert_eq!(config.web_search.max_results, 7);
assert_eq!(config.web_search.timeout_secs, 20);
assert_eq!(
config.web_search.brave_api_key.as_deref(),
Some("brave-test-key")
);
std::env::remove_var("WEB_SEARCH_ENABLED");
std::env::remove_var("WEB_SEARCH_PROVIDER");
std::env::remove_var("WEB_SEARCH_MAX_RESULTS");
std::env::remove_var("WEB_SEARCH_TIMEOUT_SECS");
std::env::remove_var("BRAVE_API_KEY");
}
#[test]
async fn env_override_web_search_invalid_values_ignored() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
let original_max_results = config.web_search.max_results;
let original_timeout = config.web_search.timeout_secs;
std::env::set_var("WEB_SEARCH_MAX_RESULTS", "99");
std::env::set_var("WEB_SEARCH_TIMEOUT_SECS", "0");
config.apply_env_overrides();
assert_eq!(config.web_search.max_results, original_max_results);
assert_eq!(config.web_search.timeout_secs, original_timeout);
std::env::remove_var("WEB_SEARCH_MAX_RESULTS");
std::env::remove_var("WEB_SEARCH_TIMEOUT_SECS");
}
#[test]
async fn env_override_storage_provider_config() {
let _env_guard = env_override_lock().await;
let mut config = Config::default();
std::env::set_var("VELACLAW_STORAGE_PROVIDER", "postgres");
std::env::set_var("VELACLAW_STORAGE_DB_URL", "postgres://example/db");
std::env::set_var("VELACLAW_STORAGE_CONNECT_TIMEOUT_SECS", "15");
config.apply_env_overrides();
assert_eq!(config.storage.provider.config.provider, "postgres");
assert_eq!(
config.storage.provider.config.db_url.as_deref(),
Some("postgres://example/db")
);
assert_eq!(
config.storage.provider.config.connect_timeout_secs,
Some(15)
);
std::env::remove_var("VELACLAW_STORAGE_PROVIDER");
std::env::remove_var("VELACLAW_STORAGE_DB_URL");
std::env::remove_var("VELACLAW_STORAGE_CONNECT_TIMEOUT_SECS");
}
#[test]
async fn proxy_config_scope_services_requires_entries_when_enabled() {
let proxy = ProxyConfig {
enabled: true,
http_proxy: Some("http://127.0.0.1:7890".into()),
https_proxy: None,
all_proxy: None,
no_proxy: Vec::new(),
scope: ProxyScope::Services,
services: Vec::new(),
};
let error = proxy.validate().unwrap_err().to_string();
assert!(error.contains("proxy.scope='services'"));
}
#[test]
async fn env_override_proxy_scope_services() {
let _env_guard = env_override_lock().await;
clear_proxy_env_test_vars();
let mut config = Config::default();
std::env::set_var("VELACLAW_PROXY_ENABLED", "true");
std::env::set_var("VELACLAW_HTTP_PROXY", "http://127.0.0.1:7890");
std::env::set_var(
"VELACLAW_PROXY_SERVICES",
"provider.openai, tool.http_request",
);
std::env::set_var("VELACLAW_PROXY_SCOPE", "services");
config.apply_env_overrides();
assert!(config.proxy.enabled);
assert_eq!(config.proxy.scope, ProxyScope::Services);
assert_eq!(
config.proxy.http_proxy.as_deref(),
Some("http://127.0.0.1:7890")
);
assert!(config.proxy.should_apply_to_service("provider.openai"));
assert!(config.proxy.should_apply_to_service("tool.http_request"));
assert!(!config.proxy.should_apply_to_service("provider.anthropic"));
clear_proxy_env_test_vars();
}
#[test]
async fn env_override_proxy_scope_environment_applies_process_env() {
let _env_guard = env_override_lock().await;
clear_proxy_env_test_vars();
let mut config = Config::default();
std::env::set_var("VELACLAW_PROXY_ENABLED", "true");
std::env::set_var("VELACLAW_PROXY_SCOPE", "environment");
std::env::set_var("VELACLAW_HTTP_PROXY", "http://127.0.0.1:7890");
std::env::set_var("VELACLAW_HTTPS_PROXY", "http://127.0.0.1:7891");
std::env::set_var("VELACLAW_NO_PROXY", "localhost,127.0.0.1");
config.apply_env_overrides();
assert_eq!(config.proxy.scope, ProxyScope::Environment);
assert_eq!(
std::env::var("HTTP_PROXY").ok().as_deref(),
Some("http://127.0.0.1:7890")
);
assert_eq!(
std::env::var("HTTPS_PROXY").ok().as_deref(),
Some("http://127.0.0.1:7891")
);
assert!(std::env::var("NO_PROXY")
.ok()
.is_some_and(|value| value.contains("localhost")));
clear_proxy_env_test_vars();
}
fn runtime_proxy_cache_contains(cache_key: &str) -> bool {
match runtime_proxy_client_cache().read() {
Ok(guard) => guard.contains_key(cache_key),
Err(poisoned) => poisoned.into_inner().contains_key(cache_key),
}
}
#[test]
async fn runtime_proxy_client_cache_reuses_default_profile_key() {
let service_key = format!(
"provider.cache_test.{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos()
);
let cache_key = runtime_proxy_cache_key(&service_key, None, None);
clear_runtime_proxy_client_cache();
assert!(!runtime_proxy_cache_contains(&cache_key));
let _ = build_runtime_proxy_client(&service_key);
assert!(runtime_proxy_cache_contains(&cache_key));
let _ = build_runtime_proxy_client(&service_key);
assert!(runtime_proxy_cache_contains(&cache_key));
}
#[test]
async fn set_runtime_proxy_config_clears_runtime_proxy_client_cache() {
let service_key = format!(
"provider.cache_timeout_test.{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos()
);
let cache_key = runtime_proxy_cache_key(&service_key, Some(30), Some(5));
clear_runtime_proxy_client_cache();
let _ = build_runtime_proxy_client_with_timeouts(&service_key, 30, 5);
assert!(runtime_proxy_cache_contains(&cache_key));
set_runtime_proxy_config(ProxyConfig::default());
assert!(!runtime_proxy_cache_contains(&cache_key));
}
#[test]
async fn gateway_config_default_values() {
let g = GatewayConfig::default();
assert_eq!(g.port, 3000);
assert_eq!(g.host, "127.0.0.1");
assert!(g.require_pairing);
assert!(!g.allow_public_bind);
assert!(g.paired_tokens.is_empty());
assert!(!g.trust_forwarded_headers);
assert_eq!(g.rate_limit_max_keys, 10_000);
assert_eq!(g.idempotency_max_keys, 10_000);
}
#[test]
async fn peripherals_config_default_disabled() {
let p = PeripheralsConfig::default();
assert!(!p.enabled);
assert!(p.boards.is_empty());
}
#[test]
async fn peripheral_board_config_defaults() {
let b = PeripheralBoardConfig::default();
assert!(b.board.is_empty());
assert_eq!(b.transport, "serial");
assert!(b.path.is_none());
assert_eq!(b.baud, 115_200);
}
#[test]
async fn peripherals_config_toml_roundtrip() {
let p = PeripheralsConfig {
enabled: true,
boards: vec![PeripheralBoardConfig {
board: "nucleo-f401re".into(),
transport: "serial".into(),
path: Some("/dev/ttyACM0".into()),
baud: 115_200,
}],
datasheet_dir: None,
};
let toml_str = toml::to_string(&p).unwrap();
let parsed: PeripheralsConfig = toml::from_str(&toml_str).unwrap();
assert!(parsed.enabled);
assert_eq!(parsed.boards.len(), 1);
assert_eq!(parsed.boards[0].board, "nucleo-f401re");
assert_eq!(parsed.boards[0].path.as_deref(), Some("/dev/ttyACM0"));
}
#[test]
async fn lark_config_serde() {
let lc = LarkConfig {
app_id: "cli_123456".into(),
app_secret: "secret_abc".into(),
encrypt_key: Some("encrypt_key".into()),
verification_token: Some("verify_token".into()),
allowed_users: vec!["user_123".into(), "user_456".into()],
use_feishu: true,
receive_mode: LarkReceiveMode::Websocket,
port: None,
};
let json = serde_json::to_string(&lc).unwrap();
let parsed: LarkConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.app_id, "cli_123456");
assert_eq!(parsed.app_secret, "secret_abc");
assert_eq!(parsed.encrypt_key.as_deref(), Some("encrypt_key"));
assert_eq!(parsed.verification_token.as_deref(), Some("verify_token"));
assert_eq!(parsed.allowed_users.len(), 2);
assert!(parsed.use_feishu);
}
#[test]
async fn lark_config_toml_roundtrip() {
let lc = LarkConfig {
app_id: "cli_123456".into(),
app_secret: "secret_abc".into(),
encrypt_key: Some("encrypt_key".into()),
verification_token: Some("verify_token".into()),
allowed_users: vec!["*".into()],
use_feishu: false,
receive_mode: LarkReceiveMode::Webhook,
port: Some(9898),
};
let toml_str = toml::to_string(&lc).unwrap();
let parsed: LarkConfig = toml::from_str(&toml_str).unwrap();
assert_eq!(parsed.app_id, "cli_123456");
assert_eq!(parsed.app_secret, "secret_abc");
assert!(!parsed.use_feishu);
}
#[test]
async fn lark_config_deserializes_without_optional_fields() {
let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
assert!(parsed.encrypt_key.is_none());
assert!(parsed.verification_token.is_none());
assert!(parsed.allowed_users.is_empty());
assert!(!parsed.use_feishu);
}
#[test]
async fn lark_config_defaults_to_lark_endpoint() {
let json = r#"{"app_id":"cli_123","app_secret":"secret"}"#;
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
assert!(
!parsed.use_feishu,
"use_feishu should default to false (Lark)"
);
}
#[test]
async fn lark_config_with_wildcard_allowed_users() {
let json = r#"{"app_id":"cli_123","app_secret":"secret","allowed_users":["*"]}"#;
let parsed: LarkConfig = serde_json::from_str(json).unwrap();
assert_eq!(parsed.allowed_users, vec!["*"]);
}
#[test]
async fn nextcloud_talk_config_serde() {
let nc = NextcloudTalkConfig {
base_url: "https://cloud.example.com".into(),
app_token: "app-token".into(),
webhook_secret: Some("webhook-secret".into()),
allowed_users: vec!["user_a".into(), "*".into()],
};
let json = serde_json::to_string(&nc).unwrap();
let parsed: NextcloudTalkConfig = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.base_url, "https://cloud.example.com");
assert_eq!(parsed.app_token, "app-token");
assert_eq!(parsed.webhook_secret.as_deref(), Some("webhook-secret"));
assert_eq!(parsed.allowed_users, vec!["user_a", "*"]);
}
#[test]
async fn nextcloud_talk_config_defaults_optional_fields() {
let json = r#"{"base_url":"https://cloud.example.com","app_token":"app-token"}"#;
let parsed: NextcloudTalkConfig = serde_json::from_str(json).unwrap();
assert!(parsed.webhook_secret.is_none());
assert!(parsed.allowed_users.is_empty());
}
#[cfg(unix)]
#[test]
async fn new_config_file_has_restricted_permissions() {
let tmp = tempfile::TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
let mut config = Config::default();
config.config_path = config_path.clone();
config.save().await.unwrap();
fs::set_permissions(&config_path, Permissions::from_mode(0o600))
.await
.expect("Failed to set permissions");
let meta = fs::metadata(&config_path).await.unwrap();
let mode = meta.permissions().mode() & 0o777;
assert_eq!(
mode, 0o600,
"New config file should be owner-only (0600), got {mode:o}"
);
}
#[cfg(unix)]
#[test]
async fn world_readable_config_is_detectable() {
use std::os::unix::fs::PermissionsExt;
let tmp = tempfile::TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "# test config").unwrap();
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o644)).unwrap();
let meta = std::fs::metadata(&config_path).unwrap();
let mode = meta.permissions().mode();
assert!(
mode & 0o004 != 0,
"Test setup: file should be world-readable (mode {mode:o})"
);
}
}