use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
fn redact(secret: &str) -> &'static str {
if secret.is_empty() {
"\"\""
} else {
"[REDACTED]"
}
}
fn redact_option(secret: &Option<String>) -> &'static str {
match secret {
Some(s) if !s.is_empty() => "Some([REDACTED])",
Some(_) => "Some(\"\")",
None => "None",
}
}
pub(crate) const KEYCHAIN_SERVICE: &str = "aidaemon";
pub fn expand_env_vars(content: &str) -> anyhow::Result<String> {
let re = regex::Regex::new(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}").unwrap();
let mut missing: Vec<String> = Vec::new();
let result = re.replace_all(content, |caps: ®ex::Captures| {
let var_name = &caps[1];
match std::env::var(var_name) {
Ok(val) => val,
Err(_) => {
missing.push(var_name.to_string());
caps[0].to_string()
}
}
});
if !missing.is_empty() {
anyhow::bail!(
"Config references undefined environment variable(s): {}",
missing.join(", ")
);
}
Ok(result.into_owned())
}
fn keychain_disabled() -> bool {
std::env::var("AIDAEMON_NO_KEYCHAIN").is_ok_and(|v| v == "1" || v == "true")
}
fn resolve_env_file_path() -> PathBuf {
if let Ok(path) = std::env::var(crate::RUNTIME_ENV_FILE_ENV_KEY) {
if !path.trim().is_empty() {
return PathBuf::from(path);
}
}
if let Ok(path) = std::env::var("AIDAEMON_ENV_FILE") {
if !path.trim().is_empty() {
return PathBuf::from(path);
}
}
PathBuf::from(".env")
}
fn env_file_lock() -> &'static std::sync::Mutex<()> {
static LOCK: std::sync::OnceLock<std::sync::Mutex<()>> = std::sync::OnceLock::new();
LOCK.get_or_init(|| std::sync::Mutex::new(()))
}
fn is_env_key_assignment(line: &str, env_key: &str) -> bool {
let trimmed = line.trim_start();
if trimmed.starts_with('#') {
return false;
}
let Some((k, _)) = trimmed.split_once('=') else {
return false;
};
k.trim() == env_key
}
fn encode_env_value(value: &str) -> String {
let is_simple = value.bytes().all(|b| {
matches!(
b,
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'_'
| b'-'
| b'.'
| b'/'
| b':'
| b'@'
| b'+'
| b'='
)
});
if is_simple {
return value.to_string();
}
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'$' => escaped.push_str("\\$"),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
_ => escaped.push(ch),
}
}
format!("\"{}\"", escaped)
}
#[cfg(unix)]
fn set_file_mode_0600(path: &Path) -> anyhow::Result<()> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)?.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms)?;
Ok(())
}
#[cfg(not(unix))]
fn set_file_mode_0600(_path: &Path) -> anyhow::Result<()> {
Ok(())
}
fn upsert_env_secret(field_name: &str, value: &str) -> anyhow::Result<()> {
let _guard = env_file_lock()
.lock()
.map_err(|_| anyhow::anyhow!("Failed to lock env file writer"))?;
let env_path = resolve_env_file_path();
let env_key = field_name.to_uppercase();
let assignment = format!("{}={}", env_key, encode_env_value(value));
let mut lines: Vec<String> = if env_path.exists() {
std::fs::read_to_string(&env_path)?
.lines()
.map(|l| l.to_string())
.collect()
} else {
Vec::new()
};
let mut replaced = false;
for line in &mut lines {
if is_env_key_assignment(line, &env_key) {
*line = assignment.clone();
replaced = true;
}
}
if !replaced {
lines.push(assignment);
}
if let Some(parent) = env_path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let mut body = lines.join("\n");
if !body.is_empty() {
body.push('\n');
}
std::fs::write(&env_path, body)?;
set_file_mode_0600(&env_path)?;
Ok(())
}
fn remove_env_secret(field_name: &str) -> anyhow::Result<()> {
let _guard = env_file_lock()
.lock()
.map_err(|_| anyhow::anyhow!("Failed to lock env file writer"))?;
let env_path = resolve_env_file_path();
if !env_path.exists() {
return Ok(());
}
let env_key = field_name.to_uppercase();
let mut lines: Vec<String> = std::fs::read_to_string(&env_path)?
.lines()
.map(|l| l.to_string())
.collect();
let original_len = lines.len();
lines.retain(|line| !is_env_key_assignment(line, &env_key));
if lines.len() == original_len {
return Ok(());
}
let mut body = lines.join("\n");
if !body.is_empty() {
body.push('\n');
}
std::fs::write(&env_path, body)?;
set_file_mode_0600(&env_path)?;
Ok(())
}
fn decode_env_value_loose(raw: &str) -> String {
let trimmed = raw.trim();
let inner = if (trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\''))
{
&trimmed[1..trimmed.len().saturating_sub(1)]
} else {
trimmed
};
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '\\' {
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('$') => out.push('$'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
} else {
out.push(ch);
}
}
out
}
fn read_env_secret_from_file(env_path: &Path, env_key: &str) -> anyhow::Result<Option<String>> {
if !env_path.exists() {
return Ok(None);
}
let mut found_via_dotenv: Option<String> = None;
if let Ok(iter) = dotenvy::from_path_iter(env_path) {
for entry in iter {
match entry {
Ok((key, value)) => {
if key == env_key {
found_via_dotenv = Some(value);
}
}
Err(_) => {
continue;
}
}
}
}
if found_via_dotenv.is_some() {
return Ok(found_via_dotenv);
}
let content = std::fs::read_to_string(env_path)?;
let mut found_via_loose_scan: Option<String> = None;
for line in content.lines() {
if is_env_key_assignment(line, env_key) {
if let Some((_, raw_value)) = line.split_once('=') {
found_via_loose_scan = Some(decode_env_value_loose(raw_value));
}
}
}
Ok(found_via_loose_scan)
}
pub fn resolve_from_keychain(field_name: &str) -> anyhow::Result<String> {
let env_key = field_name.to_uppercase();
if keychain_disabled() {
let env_path = resolve_env_file_path();
if env_path.exists() {
if let Some(val) = read_env_secret_from_file(&env_path, &env_key)? {
if !val.is_empty() {
return Ok(val);
}
}
anyhow::bail!(
"Keychain disabled and env var '{}' not found in '{}'",
env_key,
env_path.display()
);
}
if let Ok(val) = std::env::var(&env_key) {
if !val.is_empty() {
return Ok(val);
}
}
anyhow::bail!(
"Keychain disabled and env var '{}' not set for '{}'",
env_key,
field_name
);
}
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, field_name)?;
entry
.get_password()
.map_err(|e| anyhow::anyhow!("Failed to read '{}' from OS keychain: {}", field_name, e))
}
pub fn store_in_keychain(field_name: &str, value: &str) -> anyhow::Result<()> {
if keychain_disabled() {
return upsert_env_secret(field_name, value);
}
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, field_name)?;
entry.set_password(value)?;
Ok(())
}
pub fn delete_from_keychain(field_name: &str) -> anyhow::Result<()> {
if keychain_disabled() {
return remove_env_secret(field_name);
}
let entry = keyring::Entry::new(KEYCHAIN_SERVICE, field_name)?;
entry
.delete_credential()
.map_err(|e| anyhow::anyhow!("Failed to delete '{}' from OS keychain: {}", field_name, e))
}
#[derive(Debug, Deserialize, Clone)]
pub struct AppConfig {
pub provider: ProviderConfig,
#[serde(default)]
pub telegram: Option<TelegramConfig>,
#[serde(default)]
pub telegram_bots: Vec<TelegramBotConfig>,
#[serde(default)]
pub telegram_webhook_defaults: TelegramWebhookDefaults,
#[cfg(feature = "discord")]
#[serde(default)]
pub discord: Option<DiscordConfig>,
#[cfg(feature = "discord")]
#[serde(default)]
pub discord_bots: Vec<DiscordBotConfig>,
#[cfg(feature = "slack")]
#[serde(default)]
pub slack: Option<SlackConfig>,
#[cfg(feature = "slack")]
#[serde(default)]
pub slack_bots: Vec<SlackBotConfig>,
#[serde(default)]
pub state: StateConfig,
#[serde(default)]
pub terminal: TerminalConfig,
#[serde(default)]
pub path_aliases: PathAliasConfig,
#[serde(default)]
pub daemon: DaemonConfig,
#[serde(default)]
pub triggers: TriggersConfig,
#[serde(default)]
pub mcp: HashMap<String, McpServerConfig>,
#[serde(default)]
pub browser: BrowserConfig,
#[serde(default)]
pub computer_use: ComputerUseConfig,
#[serde(default)]
pub skills: SkillsConfig,
#[serde(default)]
pub subagents: SubagentsConfig,
#[serde(default)]
pub cli_agents: CliAgentsConfig,
#[serde(default)]
pub search: SearchConfig,
#[serde(default)]
pub files: FilesConfig,
#[serde(default)]
pub health: HealthConfig,
#[serde(default)]
pub updates: UpdateConfig,
#[serde(default)]
pub users: UsersConfig,
#[serde(default)]
pub people: PeopleConfig,
#[serde(default)]
pub oauth: OAuthConfig,
#[serde(default)]
pub http_auth: HashMap<String, HttpAuthProfile>,
#[serde(default)]
pub heartbeat: HeartbeatConfig,
#[serde(default)]
pub policy: PolicyConfig,
#[serde(default)]
pub tools: ToolsConfig,
#[serde(default)]
pub diagnostics: DiagnosticsConfig,
}
#[derive(Deserialize, Clone)]
pub struct ProviderConfig {
#[serde(default)]
pub kind: ProviderKind,
pub api_key: String,
#[serde(default)]
pub gateway_token: Option<String>,
#[serde(default = "default_base_url")]
pub base_url: String,
#[serde(default)]
pub extra_headers: Option<HashMap<String, String>>,
#[serde(default)]
pub max_tokens: Option<u32>,
#[serde(default)]
pub reasoning_effort: Option<String>,
#[serde(default)]
pub streaming: bool,
#[serde(default)]
pub models: ModelsConfig,
#[serde(default, alias = "failover")]
pub fallbacks: Vec<ProviderConfig>,
#[serde(default)]
pub slot_routing: SlotRoutingConfig,
}
impl fmt::Debug for ProviderConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProviderConfig")
.field("kind", &self.kind)
.field("api_key", &redact(&self.api_key))
.field("gateway_token", &redact_option(&self.gateway_token))
.field("base_url", &self.base_url)
.field(
"extra_headers",
&self
.extra_headers
.as_ref()
.map(|h| h.keys().map(String::as_str).collect::<Vec<_>>()),
)
.field("max_tokens", &self.max_tokens)
.field("models", &self.models)
.field("fallback_provider_count", &self.fallbacks.len())
.field("slot_routing", &self.slot_routing)
.finish()
}
}
impl ProviderConfig {
pub fn apply_model_defaults_recursive(&mut self) {
self.models.apply_defaults(&self.kind);
for fallback in &mut self.fallbacks {
fallback.apply_model_defaults_recursive();
}
}
fn resolve_secrets_recursive(&mut self, key_prefix: Option<&str>) -> anyhow::Result<()> {
let api_key_key = key_prefix
.map(|prefix| format!("{}_api_key", prefix))
.unwrap_or_else(|| "api_key".to_string());
if self.api_key == "keychain" {
self.api_key = resolve_from_keychain(&api_key_key)?;
}
let gateway_token_key = key_prefix
.map(|prefix| format!("{}_gateway_token", prefix))
.unwrap_or_else(|| "gateway_token".to_string());
if self.gateway_token.as_deref() == Some("keychain") {
self.gateway_token = Some(resolve_from_keychain(&gateway_token_key)?);
}
if let Some(headers) = self.extra_headers.as_mut() {
for (name, value) in headers.iter_mut() {
if value == "keychain" {
let normalized_name = name
.to_ascii_lowercase()
.chars()
.map(|c| if c.is_ascii_alphanumeric() { c } else { '_' })
.collect::<String>();
let key = key_prefix
.map(|prefix| format!("{}_header_{}", prefix, normalized_name))
.unwrap_or_else(|| format!("provider_header_{}", normalized_name));
*value = resolve_from_keychain(&key)?;
}
}
}
for (idx, fallback) in self.fallbacks.iter_mut().enumerate() {
let nested_prefix = key_prefix
.map(|prefix| format!("{}_fallback_{}", prefix, idx))
.unwrap_or_else(|| format!("provider_fallback_{}", idx));
fallback.resolve_secrets_recursive(Some(&nested_prefix))?;
}
Ok(())
}
}
#[derive(Debug, Deserialize, Clone, Copy, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum ProviderKind {
#[default]
OpenaiCompatible,
XaiNative,
GoogleGenai,
Anthropic,
}
fn default_true() -> bool {
true
}
fn default_base_url() -> String {
"https://api.openai.com/v1".to_string()
}
fn default_background_slot() -> u32 {
1
}
#[derive(Debug, Deserialize, Clone)]
pub struct SlotRoutingConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub interactive_slot: u32,
#[serde(default = "default_background_slot")]
pub background_slot: u32,
}
impl Default for SlotRoutingConfig {
fn default() -> Self {
Self {
enabled: false,
interactive_slot: 0,
background_slot: default_background_slot(),
}
}
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct ModelsConfig {
#[serde(default, rename = "default")]
pub default_model: String,
#[serde(default, rename = "fallback")]
pub fallback_models: Vec<String>,
#[serde(default)]
pub primary: String,
#[serde(default)]
pub fast: String,
#[serde(default)]
pub smart: String,
}
impl ModelsConfig {
pub fn apply_defaults(&mut self, provider_kind: &ProviderKind) {
let provider_default = match provider_kind {
ProviderKind::GoogleGenai => "gemini-3-flash-preview".to_string(),
ProviderKind::Anthropic => "claude-sonnet-4-20250514".to_string(),
ProviderKind::XaiNative => "grok-4".to_string(),
ProviderKind::OpenaiCompatible => "openai/gpt-4o".to_string(),
};
let default_model = if !self.default_model.trim().is_empty() {
self.default_model.trim().to_string()
} else if !self.primary.trim().is_empty() {
self.primary.trim().to_string()
} else {
provider_default
};
if self.fallback_models.is_empty() {
for legacy in [&self.smart, &self.fast] {
let candidate = legacy.trim();
if candidate.is_empty() || candidate == default_model {
continue;
}
if !self.fallback_models.iter().any(|m| m == candidate) {
self.fallback_models.push(candidate.to_string());
}
}
} else {
let mut deduped = Vec::new();
for raw in &self.fallback_models {
let candidate = raw.trim();
if candidate.is_empty() || candidate == default_model {
continue;
}
if !deduped.iter().any(|m: &String| m == candidate) {
deduped.push(candidate.to_string());
}
}
self.fallback_models = deduped;
}
self.default_model = default_model.clone();
self.primary = default_model.clone();
let first_fallback = self
.fallback_models
.first()
.cloned()
.unwrap_or_else(|| default_model.clone());
if self.fast.trim().is_empty() {
self.fast = first_fallback.clone();
}
if self.smart.trim().is_empty() {
self.smart = first_fallback;
}
}
}
#[derive(Deserialize, Clone)]
pub struct TelegramConfig {
pub bot_token: String,
#[serde(default)]
pub allowed_user_ids: Vec<u64>,
#[serde(default)]
pub webhook: TelegramWebhookConfig,
}
impl fmt::Debug for TelegramConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TelegramConfig")
.field("bot_token", &redact(&self.bot_token))
.field("allowed_user_ids", &self.allowed_user_ids)
.field("webhook", &self.webhook)
.finish()
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct TelegramWebhookDefaults {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub base_domain: Option<String>,
#[serde(default = "default_webhook_port_start")]
pub port_start: u16,
#[serde(default = "default_webhook_max_connections")]
pub max_connections: u8,
#[serde(default)]
pub drop_pending_updates: bool,
#[serde(default = "default_true")]
pub bind_local_only: bool,
}
fn default_webhook_port_start() -> u16 {
8443
}
fn default_webhook_max_connections() -> u8 {
40
}
impl Default for TelegramWebhookDefaults {
fn default() -> Self {
Self {
enabled: false,
base_domain: None,
port_start: 8443,
max_connections: 40,
drop_pending_updates: false,
bind_local_only: true,
}
}
}
impl TelegramWebhookDefaults {
pub fn derive_webhook_config(&self, slug: &str, port: u16) -> TelegramWebhookConfig {
let listen_host = if self.bind_local_only {
"127.0.0.1"
} else {
"0.0.0.0"
};
let path = format!("/telegram/{}", slug);
let public_url = self
.base_domain
.as_ref()
.map(|domain| format!("https://{}.{}{}", slug, domain, path));
TelegramWebhookConfig {
enabled: true,
public_url,
listen_addr: Some(format!("{}:{}", listen_host, port)),
path: Some(path),
max_connections: Some(self.max_connections),
drop_pending_updates: self.drop_pending_updates,
}
}
pub fn next_available_port(&self, occupied: &mut std::collections::HashSet<u16>) -> u16 {
let mut port = self.port_start;
while occupied.contains(&port) {
port = port.saturating_add(1);
}
occupied.insert(port);
port
}
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct TelegramWebhookConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub public_url: Option<String>,
#[serde(default)]
pub listen_addr: Option<String>,
#[serde(default)]
pub path: Option<String>,
#[serde(default)]
pub max_connections: Option<u8>,
#[serde(default)]
pub drop_pending_updates: bool,
}
#[derive(Deserialize, Clone)]
pub struct TelegramBotConfig {
pub bot_token: String,
#[serde(default)]
pub allowed_user_ids: Vec<u64>,
#[serde(default)]
pub webhook: TelegramWebhookConfig,
}
impl fmt::Debug for TelegramBotConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TelegramBotConfig")
.field("bot_token", &redact(&self.bot_token))
.field("allowed_user_ids", &self.allowed_user_ids)
.field("webhook", &self.webhook)
.finish()
}
}
#[cfg(feature = "discord")]
#[derive(Deserialize, Clone, Default)]
pub struct DiscordConfig {
#[serde(default)]
pub bot_token: String,
#[serde(default)]
pub allowed_user_ids: Vec<u64>,
#[serde(default)]
pub guild_id: Option<u64>,
}
#[cfg(feature = "discord")]
impl fmt::Debug for DiscordConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DiscordConfig")
.field("bot_token", &redact(&self.bot_token))
.field("allowed_user_ids", &self.allowed_user_ids)
.field("guild_id", &self.guild_id)
.finish()
}
}
#[cfg(feature = "discord")]
#[derive(Deserialize, Clone)]
pub struct DiscordBotConfig {
pub bot_token: String,
#[serde(default)]
pub allowed_user_ids: Vec<u64>,
#[serde(default)]
pub guild_id: Option<u64>,
}
#[cfg(feature = "discord")]
impl fmt::Debug for DiscordBotConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("DiscordBotConfig")
.field("bot_token", &redact(&self.bot_token))
.field("allowed_user_ids", &self.allowed_user_ids)
.field("guild_id", &self.guild_id)
.finish()
}
}
#[cfg(feature = "slack")]
#[derive(Deserialize, Clone, Default)]
pub struct SlackConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub app_token: String,
#[serde(default)]
pub bot_token: String,
#[serde(default)]
pub allowed_user_ids: Vec<String>,
#[serde(default = "default_slack_use_threads")]
pub use_threads: bool,
}
#[cfg(feature = "slack")]
impl fmt::Debug for SlackConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SlackConfig")
.field("enabled", &self.enabled)
.field("app_token", &redact(&self.app_token))
.field("bot_token", &redact(&self.bot_token))
.field("allowed_user_ids", &self.allowed_user_ids)
.field("use_threads", &self.use_threads)
.finish()
}
}
#[cfg(feature = "slack")]
fn default_slack_use_threads() -> bool {
true
}
#[cfg(feature = "slack")]
#[derive(Deserialize, Clone)]
pub struct SlackBotConfig {
pub app_token: String,
pub bot_token: String,
#[serde(default)]
pub allowed_user_ids: Vec<String>,
#[serde(default = "default_slack_use_threads")]
pub use_threads: bool,
}
#[cfg(feature = "slack")]
impl fmt::Debug for SlackBotConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SlackBotConfig")
.field("app_token", &redact(&self.app_token))
.field("bot_token", &redact(&self.bot_token))
.field("allowed_user_ids", &self.allowed_user_ids)
.field("use_threads", &self.use_threads)
.finish()
}
}
#[derive(Deserialize, Clone)]
pub struct StateConfig {
#[serde(default = "default_db_path")]
pub db_path: String,
#[serde(default = "default_working_memory_cap")]
pub working_memory_cap: usize,
#[serde(default = "default_consolidation_interval_hours")]
pub consolidation_interval_hours: u64,
#[serde(default)]
pub encryption_key: Option<String>,
#[serde(default = "default_max_facts")]
pub max_facts: usize,
#[serde(default)]
pub daily_token_budget: Option<u64>,
#[serde(default)]
pub retention: RetentionConfig,
#[serde(default)]
pub context_window: ContextWindowConfig,
}
#[derive(Debug, Deserialize, Clone)]
pub struct RetentionConfig {
#[serde(default = "default_retention_90")]
pub messages_days: u32,
#[serde(default = "default_retention_90")]
pub superseded_facts_days: u32,
#[serde(default = "default_retention_30")]
pub token_usage_aggregate_days: u32,
#[serde(default = "default_retention_365")]
pub episodes_days: u32,
#[serde(default = "default_retention_90")]
pub behavior_patterns_days: u32,
#[serde(default = "default_retention_180")]
pub goals_days: u32,
#[serde(default = "default_retention_180")]
pub procedures_days: u32,
#[serde(default = "default_retention_90")]
pub error_solutions_days: u32,
}
impl Default for RetentionConfig {
fn default() -> Self {
Self {
messages_days: 90,
superseded_facts_days: 90,
token_usage_aggregate_days: 30,
episodes_days: 365,
behavior_patterns_days: 90,
goals_days: 180,
procedures_days: 180,
error_solutions_days: 90,
}
}
}
fn default_retention_30() -> u32 {
30
}
fn default_retention_90() -> u32 {
90
}
fn default_retention_180() -> u32 {
180
}
fn default_retention_365() -> u32 {
365
}
impl fmt::Debug for StateConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("StateConfig")
.field("db_path", &self.db_path)
.field("working_memory_cap", &self.working_memory_cap)
.field(
"consolidation_interval_hours",
&self.consolidation_interval_hours,
)
.field("encryption_key", &redact_option(&self.encryption_key))
.field("max_facts", &self.max_facts)
.field("daily_token_budget", &self.daily_token_budget)
.field("retention", &self.retention)
.field("context_window", &self.context_window)
.finish()
}
}
impl Default for StateConfig {
fn default() -> Self {
Self {
db_path: default_db_path(),
working_memory_cap: default_working_memory_cap(),
consolidation_interval_hours: default_consolidation_interval_hours(),
encryption_key: None,
max_facts: default_max_facts(),
daily_token_budget: None,
retention: RetentionConfig::default(),
context_window: ContextWindowConfig::default(),
}
}
}
fn default_db_path() -> String {
"aidaemon.db".to_string()
}
fn default_working_memory_cap() -> usize {
50
}
fn default_consolidation_interval_hours() -> u64 {
6
}
fn default_max_facts() -> usize {
20
}
pub use crate::tools::command_risk::PermissionMode;
#[derive(Deserialize, Clone)]
pub struct TerminalConfig {
#[serde(default = "default_allowed_prefixes")]
pub allowed_prefixes: Vec<String>,
#[serde(default = "default_terminal_web_app_url")]
pub web_app_url: String,
#[serde(default = "default_terminal_bridge_enabled")]
pub bridge_enabled: bool,
#[serde(default = "default_terminal_bridge_ws_url")]
pub daemon_ws_url: String,
#[serde(default)]
pub daemon_connect_token: Option<String>,
#[serde(default)]
pub allow_static_token_fallback: bool,
#[serde(default)]
pub daemon_user_id: Option<u64>,
#[serde(default)]
pub daemon_device_id: Option<String>,
#[serde(default)]
pub daemon_shell: Option<String>,
#[serde(default = "default_initial_timeout_secs")]
pub initial_timeout_secs: u64,
#[serde(default = "default_max_output_chars")]
pub max_output_chars: usize,
#[serde(default)]
pub permission_mode: PermissionMode,
}
impl Default for TerminalConfig {
fn default() -> Self {
Self {
allowed_prefixes: default_allowed_prefixes(),
web_app_url: default_terminal_web_app_url(),
bridge_enabled: default_terminal_bridge_enabled(),
daemon_ws_url: default_terminal_bridge_ws_url(),
daemon_connect_token: None,
allow_static_token_fallback: false,
daemon_user_id: None,
daemon_device_id: None,
daemon_shell: None,
initial_timeout_secs: default_initial_timeout_secs(),
max_output_chars: default_max_output_chars(),
permission_mode: PermissionMode::default(),
}
}
}
impl fmt::Debug for TerminalConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("TerminalConfig")
.field("allowed_prefixes", &self.allowed_prefixes)
.field("web_app_url", &self.web_app_url)
.field("bridge_enabled", &self.bridge_enabled)
.field("daemon_ws_url", &self.daemon_ws_url)
.field(
"daemon_connect_token",
&self.daemon_connect_token.as_ref().map(|v| {
if v.is_empty() {
"\"\""
} else {
"[REDACTED]"
}
}),
)
.field(
"allow_static_token_fallback",
&self.allow_static_token_fallback,
)
.field("daemon_user_id", &self.daemon_user_id)
.field("daemon_device_id", &self.daemon_device_id)
.field("daemon_shell", &self.daemon_shell)
.field("initial_timeout_secs", &self.initial_timeout_secs)
.field("max_output_chars", &self.max_output_chars)
.field("permission_mode", &self.permission_mode)
.finish()
}
}
impl TerminalConfig {
pub fn effective_web_app_url(&self) -> String {
let from_env = std::env::var("AIDAEMON_TERMINAL_WEB_APP_URL")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
from_env.unwrap_or_else(|| self.web_app_url.trim().to_string())
}
pub fn effective_bridge_enabled(&self) -> bool {
let from_env = std::env::var("AIDAEMON_TERMINAL_BRIDGE_ENABLED")
.ok()
.and_then(|v| parse_bool_like(&v));
from_env.unwrap_or(self.bridge_enabled)
}
pub fn effective_daemon_ws_url(&self) -> String {
let from_env = std::env::var("AIDAEMON_TERMINAL_BROKER_URL")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
from_env.unwrap_or_else(|| self.daemon_ws_url.trim().to_string())
}
pub fn effective_daemon_connect_token(&self) -> Option<String> {
let from_env = std::env::var("AIDAEMON_TERMINAL_DAEMON_TOKEN")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
from_env.or_else(|| {
self.daemon_connect_token
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
})
}
pub fn effective_allow_static_token_fallback(&self) -> bool {
let from_env = std::env::var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK")
.ok()
.and_then(|v| parse_bool_like(&v));
from_env.unwrap_or(self.allow_static_token_fallback)
}
pub fn effective_daemon_user_id(&self) -> Option<u64> {
let from_env = std::env::var("AIDAEMON_TERMINAL_USER_ID")
.ok()
.and_then(|v| v.trim().parse::<u64>().ok());
from_env.or(self.daemon_user_id)
}
pub fn effective_daemon_device_id(&self) -> Option<String> {
let from_env = std::env::var("AIDAEMON_TERMINAL_DEVICE_ID")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
from_env.or_else(|| {
self.daemon_device_id
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
})
}
pub fn effective_daemon_shell(&self) -> Option<String> {
let from_env = std::env::var("AIDAEMON_TERMINAL_SHELL")
.ok()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty());
from_env.or_else(|| {
self.daemon_shell
.as_ref()
.map(|v| v.trim().to_string())
.filter(|v| !v.is_empty())
})
}
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct PathAliasConfig {
#[serde(default)]
pub projects: Vec<String>,
}
fn default_initial_timeout_secs() -> u64 {
30
}
fn default_max_output_chars() -> usize {
4000
}
fn parse_bool_like(value: &str) -> Option<bool> {
let normalized = value.trim().to_ascii_lowercase();
match normalized.as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
fn default_terminal_web_app_url() -> String {
"https://terminal.aidaemon.ai".to_string()
}
fn default_terminal_bridge_enabled() -> bool {
true
}
fn default_terminal_bridge_ws_url() -> String {
"wss://terminal.aidaemon.ai/v1/ws/daemon".to_string()
}
fn default_allowed_prefixes() -> Vec<String> {
vec![
"ls".into(),
"cat".into(),
"head".into(),
"tail".into(),
"echo".into(),
"date".into(),
"whoami".into(),
"pwd".into(),
"find".into(),
"wc".into(),
"grep".into(),
"tree".into(),
"file".into(),
"stat".into(),
"uname".into(),
"df".into(),
"du".into(),
"ps".into(),
"which".into(),
"env".into(),
"printenv".into(),
]
}
#[derive(Debug, Deserialize, Clone)]
pub struct WatchdogConfig {
#[serde(default = "default_watchdog_enabled")]
pub enabled: bool,
#[serde(default = "default_watchdog_stale_secs")]
pub stale_threshold_secs: u64,
#[serde(default = "default_llm_call_timeout_secs")]
pub llm_call_timeout_secs: u64,
}
impl Default for WatchdogConfig {
fn default() -> Self {
Self {
enabled: default_watchdog_enabled(),
stale_threshold_secs: default_watchdog_stale_secs(),
llm_call_timeout_secs: default_llm_call_timeout_secs(),
}
}
}
fn default_watchdog_enabled() -> bool {
true
}
fn default_watchdog_stale_secs() -> u64 {
300
}
fn default_llm_call_timeout_secs() -> u64 {
300
}
#[derive(Debug, Deserialize, Clone)]
pub struct DaemonConfig {
#[serde(default = "default_health_port")]
pub health_port: u16,
#[serde(default = "default_health_bind")]
pub health_bind: String,
#[serde(default = "default_dashboard_enabled")]
pub dashboard_enabled: bool,
#[serde(default)]
pub watchdog: WatchdogConfig,
#[serde(default)]
pub queue_policy: QueuePolicyConfig,
}
impl Default for DaemonConfig {
fn default() -> Self {
Self {
health_port: default_health_port(),
health_bind: default_health_bind(),
dashboard_enabled: default_dashboard_enabled(),
watchdog: WatchdogConfig::default(),
queue_policy: QueuePolicyConfig::default(),
}
}
}
fn default_health_port() -> u16 {
8080
}
fn default_health_bind() -> String {
"127.0.0.1".to_string()
}
fn default_dashboard_enabled() -> bool {
true
}
fn default_queue_capacity_approval() -> usize {
16
}
fn default_queue_capacity_media() -> usize {
16
}
fn default_queue_capacity_trigger_events() -> usize {
64
}
fn default_queue_warning_ratio() -> f32 {
0.75
}
fn default_queue_overload_ratio() -> f32 {
0.90
}
fn default_queue_adaptive_shedding() -> bool {
true
}
fn default_queue_fair_trigger_sessions() -> bool {
true
}
fn default_queue_fair_window_secs() -> u64 {
60
}
fn default_queue_fair_max_events_per_session() -> u32 {
4
}
#[derive(Debug, Deserialize, Clone, PartialEq)]
pub struct QueueLanePolicyConfig {
#[serde(default = "default_queue_adaptive_shedding")]
pub adaptive_shedding: bool,
#[serde(default = "default_queue_fair_trigger_sessions")]
pub fair_sessions: bool,
#[serde(default = "default_queue_fair_window_secs")]
pub fair_session_window_secs: u64,
#[serde(default = "default_queue_fair_max_events_per_session")]
pub fair_max_events_per_session: u32,
}
impl Default for QueueLanePolicyConfig {
fn default() -> Self {
Self {
adaptive_shedding: default_queue_adaptive_shedding(),
fair_sessions: default_queue_fair_trigger_sessions(),
fair_session_window_secs: default_queue_fair_window_secs(),
fair_max_events_per_session: default_queue_fair_max_events_per_session(),
}
}
}
impl QueueLanePolicyConfig {
pub fn normalized(&self) -> Self {
let mut lane = self.clone();
lane.fair_session_window_secs = lane.fair_session_window_secs.max(1);
lane.fair_max_events_per_session = lane.fair_max_events_per_session.max(1);
lane
}
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct QueueLanePoliciesConfig {
#[serde(default)]
pub approval: QueueLanePolicyConfig,
#[serde(default)]
pub media: QueueLanePolicyConfig,
#[serde(default)]
pub trigger: QueueLanePolicyConfig,
}
impl QueueLanePoliciesConfig {
pub fn normalized(&self) -> Self {
Self {
approval: self.approval.normalized(),
media: self.media.normalized(),
trigger: self.trigger.normalized(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct QueuePolicyConfig {
#[serde(default = "default_queue_capacity_approval")]
pub approval_capacity: usize,
#[serde(default = "default_queue_capacity_media")]
pub media_capacity: usize,
#[serde(default = "default_queue_capacity_trigger_events")]
pub trigger_event_capacity: usize,
#[serde(default = "default_queue_warning_ratio")]
pub warning_ratio: f32,
#[serde(default = "default_queue_overload_ratio")]
pub overload_ratio: f32,
#[serde(default)]
pub lanes: QueueLanePoliciesConfig,
#[serde(default)]
pub adaptive_shedding: Option<bool>,
#[serde(default)]
pub fair_trigger_sessions: Option<bool>,
#[serde(default)]
pub fair_trigger_session_window_secs: Option<u64>,
#[serde(default)]
pub fair_trigger_max_events_per_session: Option<u32>,
}
impl Default for QueuePolicyConfig {
fn default() -> Self {
Self {
approval_capacity: default_queue_capacity_approval(),
media_capacity: default_queue_capacity_media(),
trigger_event_capacity: default_queue_capacity_trigger_events(),
warning_ratio: default_queue_warning_ratio(),
overload_ratio: default_queue_overload_ratio(),
lanes: QueueLanePoliciesConfig::default(),
adaptive_shedding: None,
fair_trigger_sessions: None,
fair_trigger_session_window_secs: None,
fair_trigger_max_events_per_session: None,
}
}
}
impl QueuePolicyConfig {
pub fn normalized(&self) -> Self {
let mut policy = self.clone();
policy.approval_capacity = policy.approval_capacity.max(1);
policy.media_capacity = policy.media_capacity.max(1);
policy.trigger_event_capacity = policy.trigger_event_capacity.max(1);
policy.warning_ratio = if policy.warning_ratio.is_finite() {
policy.warning_ratio.clamp(0.0, 1.0)
} else {
default_queue_warning_ratio()
};
policy.overload_ratio = if policy.overload_ratio.is_finite() {
policy.overload_ratio.clamp(policy.warning_ratio, 1.0)
} else {
default_queue_overload_ratio().clamp(policy.warning_ratio, 1.0)
};
let default_lane = QueueLanePolicyConfig::default().normalized();
let legacy_lane = QueueLanePolicyConfig {
adaptive_shedding: policy
.adaptive_shedding
.unwrap_or(default_queue_adaptive_shedding()),
fair_sessions: policy
.fair_trigger_sessions
.unwrap_or(default_queue_fair_trigger_sessions()),
fair_session_window_secs: policy
.fair_trigger_session_window_secs
.unwrap_or(default_queue_fair_window_secs()),
fair_max_events_per_session: policy
.fair_trigger_max_events_per_session
.unwrap_or(default_queue_fair_max_events_per_session()),
}
.normalized();
policy.lanes = policy.lanes.normalized();
if policy.lanes.approval == default_lane {
policy.lanes.approval = legacy_lane.clone();
}
if policy.lanes.media == default_lane {
policy.lanes.media = legacy_lane.clone();
}
if policy.lanes.trigger == default_lane {
policy.lanes.trigger = legacy_lane;
}
policy
}
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct TriggersConfig {
pub email: Option<EmailTriggerConfig>,
}
#[derive(Deserialize, Clone)]
pub struct EmailTriggerConfig {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
#[serde(default = "default_folder")]
pub folder: String,
}
impl fmt::Debug for EmailTriggerConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("EmailTriggerConfig")
.field("host", &self.host)
.field("port", &self.port)
.field("username", &self.username)
.field("password", &redact(&self.password))
.field("folder", &self.folder)
.finish()
}
}
fn default_folder() -> String {
"INBOX".to_string()
}
#[derive(Debug, Deserialize, Clone)]
pub struct McpServerConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
#[cfg_attr(not(feature = "browser"), allow(dead_code))]
pub enum SessionIsolation {
Page,
BrowserContext,
}
#[derive(Debug, Deserialize, Clone)]
#[cfg_attr(not(feature = "browser"), allow(dead_code))]
pub struct BrowserConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_headless")]
pub headless: bool,
#[serde(default = "default_screenshot_width")]
pub screenshot_width: u32,
#[serde(default = "default_screenshot_height")]
pub screenshot_height: u32,
pub remote_debugging_port: Option<u16>,
#[serde(default = "default_browser_user_data_dir")]
pub user_data_dir: Option<String>,
pub profile: Option<String>,
#[serde(default)]
pub session_isolation: Option<SessionIsolation>,
#[serde(default = "default_nav_timeout_secs")]
pub nav_timeout_secs: u64,
#[serde(default = "default_element_timeout_secs")]
pub element_timeout_secs: u64,
#[serde(default = "default_action_timeout_secs")]
pub action_timeout_secs: u64,
}
impl Default for BrowserConfig {
fn default() -> Self {
Self {
enabled: false,
headless: default_headless(),
screenshot_width: default_screenshot_width(),
screenshot_height: default_screenshot_height(),
remote_debugging_port: None,
user_data_dir: default_browser_user_data_dir(),
profile: None,
session_isolation: None,
nav_timeout_secs: default_nav_timeout_secs(),
element_timeout_secs: default_element_timeout_secs(),
action_timeout_secs: default_action_timeout_secs(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)]
pub struct ComputerUseConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_cu_screenshot_max_width")]
pub screenshot_max_width: u32,
#[serde(default = "default_cu_screenshot_max_height")]
pub screenshot_max_height: u32,
#[serde(default = "default_cu_screenshot_max_bytes")]
pub screenshot_max_bytes: u64,
#[serde(default = "default_cu_ax_max_depth")]
pub ax_max_depth: u32,
#[serde(default = "default_cu_ax_max_nodes")]
pub ax_max_nodes: u32,
#[serde(default = "default_cu_action_timeout_secs")]
pub action_timeout_secs: u64,
#[serde(default = "default_cu_max_mutating_actions")]
pub max_mutating_actions: u32,
#[serde(default = "default_cu_max_consecutive_observations")]
pub max_consecutive_observations: u32,
#[serde(default = "default_cu_max_wall_clock_secs")]
pub max_wall_clock_secs: u64,
#[serde(default)]
pub always_allowed_apps: Vec<String>,
#[serde(default)]
pub mirror_screenshots_to_channel: bool,
}
fn default_cu_screenshot_max_width() -> u32 {
1280
}
fn default_cu_screenshot_max_height() -> u32 {
800
}
fn default_cu_screenshot_max_bytes() -> u64 {
2_000_000
}
fn default_cu_ax_max_depth() -> u32 {
12
}
fn default_cu_ax_max_nodes() -> u32 {
500
}
fn default_cu_action_timeout_secs() -> u64 {
10
}
fn default_cu_max_mutating_actions() -> u32 {
40
}
fn default_cu_max_consecutive_observations() -> u32 {
3
}
fn default_cu_max_wall_clock_secs() -> u64 {
600
}
impl Default for ComputerUseConfig {
fn default() -> Self {
Self {
enabled: false,
screenshot_max_width: default_cu_screenshot_max_width(),
screenshot_max_height: default_cu_screenshot_max_height(),
screenshot_max_bytes: default_cu_screenshot_max_bytes(),
ax_max_depth: default_cu_ax_max_depth(),
ax_max_nodes: default_cu_ax_max_nodes(),
action_timeout_secs: default_cu_action_timeout_secs(),
max_mutating_actions: default_cu_max_mutating_actions(),
max_consecutive_observations: default_cu_max_consecutive_observations(),
max_wall_clock_secs: default_cu_max_wall_clock_secs(),
always_allowed_apps: Vec::new(),
mirror_screenshots_to_channel: false,
}
}
}
impl ComputerUseConfig {
#[cfg_attr(not(feature = "computer_use"), allow(dead_code))]
pub fn action_timeout(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.action_timeout_secs.clamp(1, 120))
}
}
impl BrowserConfig {
#[cfg_attr(not(feature = "browser"), allow(dead_code))]
pub fn nav_timeout(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.nav_timeout_secs.clamp(1, 120))
}
#[cfg_attr(not(feature = "browser"), allow(dead_code))]
pub fn element_timeout(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.element_timeout_secs.clamp(1, 120))
}
#[cfg_attr(not(feature = "browser"), allow(dead_code))]
pub fn action_timeout(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.action_timeout_secs.clamp(1, 300))
}
#[cfg_attr(not(feature = "browser"), allow(dead_code))]
pub fn resolve_session_isolation(&self) -> Result<SessionIsolation, String> {
let shared_or_attached =
self.user_data_dir.is_some() || self.remote_debugging_port.is_some();
match self.session_isolation {
Some(SessionIsolation::BrowserContext) if shared_or_attached => Err(
"browser.session_isolation = \"browser_context\" requires dedicated ephemeral \
browsing, but a shared persistent profile (user_data_dir) or an attached \
remote-debugging Chrome (remote_debugging_port) is configured. These share a \
single cookie jar and CANNOT provide per-session isolated cookies. Either use \
session_isolation = \"page\" (sessions share cookies), or remove user_data_dir \
and remote_debugging_port to enable real per-session isolation."
.to_string(),
),
Some(mode) => Ok(mode),
None if shared_or_attached => Ok(SessionIsolation::Page),
None => Ok(SessionIsolation::BrowserContext),
}
}
}
fn default_browser_user_data_dir() -> Option<String> {
dirs::home_dir().map(|h| {
h.join(".aidaemon")
.join("chrome-profile")
.to_string_lossy()
.into_owned()
})
}
fn default_headless() -> bool {
true
}
fn default_screenshot_width() -> u32 {
1280
}
fn default_screenshot_height() -> u32 {
720
}
fn default_nav_timeout_secs() -> u64 {
30
}
fn default_element_timeout_secs() -> u64 {
10
}
fn default_action_timeout_secs() -> u64 {
30
}
#[derive(Debug, Deserialize, Clone)]
pub struct SkillsConfig {
#[serde(default = "default_skills_dir")]
pub dir: String,
#[serde(default = "default_skills_enabled")]
pub enabled: bool,
#[serde(default)]
pub registries: Vec<String>,
}
impl Default for SkillsConfig {
fn default() -> Self {
Self {
dir: default_skills_dir(),
enabled: default_skills_enabled(),
registries: Vec::new(),
}
}
}
fn default_skills_dir() -> String {
"skills".to_string()
}
fn default_skills_enabled() -> bool {
true
}
#[derive(Debug, Deserialize, Clone, Default)]
#[serde(tag = "mode", rename_all = "snake_case")]
pub enum IterationLimitConfig {
#[default]
Unlimited,
Soft { threshold: usize, warn_at: usize },
Hard { initial: usize, cap: usize },
}
#[derive(Debug, Deserialize, Clone)]
pub struct SubagentsConfig {
#[serde(default = "default_subagents_enabled")]
pub enabled: bool,
#[serde(default = "default_subagents_max_depth")]
pub max_depth: usize,
#[serde(default = "default_subagents_max_iterations")]
pub max_iterations: usize,
#[serde(default = "default_subagents_max_iterations_cap")]
pub max_iterations_cap: usize,
#[serde(default = "default_subagents_max_response_chars")]
pub max_response_chars: usize,
#[serde(default = "default_subagents_timeout_secs")]
pub timeout_secs: u64,
#[serde(default)]
pub iteration_limit: IterationLimitConfig,
#[serde(default = "default_task_timeout_secs")]
pub task_timeout_secs: Option<u64>,
#[serde(default = "default_task_token_budget")]
pub task_token_budget: Option<u64>,
#[serde(default)]
pub specialists_override_dir: Option<PathBuf>,
}
impl SubagentsConfig {
pub fn effective_iteration_limit(&self) -> IterationLimitConfig {
let legacy_initial_changed = self.max_iterations != default_subagents_max_iterations();
let legacy_cap_changed = self.max_iterations_cap != default_subagents_max_iterations_cap();
match &self.iteration_limit {
IterationLimitConfig::Unlimited if legacy_initial_changed || legacy_cap_changed => {
IterationLimitConfig::Hard {
initial: self.max_iterations,
cap: self.max_iterations_cap,
}
}
other => other.clone(),
}
}
}
impl Default for SubagentsConfig {
fn default() -> Self {
Self {
enabled: default_subagents_enabled(),
max_depth: default_subagents_max_depth(),
max_iterations: default_subagents_max_iterations(),
max_iterations_cap: default_subagents_max_iterations_cap(),
max_response_chars: default_subagents_max_response_chars(),
timeout_secs: default_subagents_timeout_secs(),
iteration_limit: IterationLimitConfig::default(),
task_timeout_secs: default_task_timeout_secs(),
task_token_budget: default_task_token_budget(),
specialists_override_dir: None,
}
}
}
fn default_subagents_enabled() -> bool {
true
}
fn default_subagents_max_depth() -> usize {
3
}
fn default_subagents_max_iterations() -> usize {
10
}
fn default_subagents_max_iterations_cap() -> usize {
25
}
fn default_subagents_max_response_chars() -> usize {
8000
}
fn default_subagents_timeout_secs() -> u64 {
300
}
fn default_task_timeout_secs() -> Option<u64> {
Some(1800) }
fn default_task_token_budget() -> Option<u64> {
Some(500_000)
}
#[derive(Debug, Deserialize, Clone)]
pub struct CliAgentsConfig {
#[serde(default = "default_cli_agents_enabled")]
pub enabled: bool,
#[serde(default = "default_cli_agents_timeout_secs")]
pub timeout_secs: u64,
#[serde(default = "default_cli_agents_max_output_chars")]
pub max_output_chars: usize,
#[serde(default)]
pub tools: HashMap<String, CliToolConfig>,
}
impl Default for CliAgentsConfig {
fn default() -> Self {
Self {
enabled: default_cli_agents_enabled(),
timeout_secs: default_cli_agents_timeout_secs(),
max_output_chars: default_cli_agents_max_output_chars(),
tools: HashMap::new(),
}
}
}
fn default_cli_agents_enabled() -> bool {
false
}
fn default_cli_agents_timeout_secs() -> u64 {
600
}
fn default_cli_agents_max_output_chars() -> usize {
16000
}
#[derive(Debug, Deserialize, Clone)]
pub struct CliToolConfig {
pub command: String,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub description: String,
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub max_output_chars: Option<usize>,
}
#[derive(Deserialize, Clone)]
pub struct SearchConfig {
#[serde(default)]
pub backend: SearchBackendKind,
#[serde(default)]
pub api_key: String,
#[serde(default)]
pub searxng_url: String,
#[serde(default = "default_true")]
pub parallel: bool,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
backend: SearchBackendKind::default(),
api_key: String::new(),
searxng_url: String::new(),
parallel: true,
}
}
}
impl fmt::Debug for SearchConfig {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("SearchConfig")
.field("backend", &self.backend)
.field("api_key", &redact(&self.api_key))
.field("searxng_url", &self.searxng_url)
.field("parallel", &self.parallel)
.finish()
}
}
#[derive(Debug, Deserialize, Clone, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum SearchBackendKind {
#[default]
#[serde(alias = "duckduckgo")]
DuckDuckGo,
Brave,
Searxng,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct HealthConfig {
#[serde(default = "default_health_enabled")]
pub enabled: bool,
#[serde(default = "default_health_tick_interval_secs")]
pub tick_interval_secs: u64,
#[serde(default = "default_health_result_retention_days")]
pub result_retention_days: u32,
#[serde(default)]
pub probes: Vec<HealthProbeConfig>,
}
fn default_health_enabled() -> bool {
false
}
fn default_health_tick_interval_secs() -> u64 {
30
}
fn default_health_result_retention_days() -> u32 {
7
}
#[derive(Debug, Deserialize, Clone)]
pub struct HealthProbeConfig {
pub name: String,
#[serde(rename = "type")]
pub probe_type: String,
pub target: String,
pub schedule: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub config: HealthProbeConfigOptions,
#[serde(default)]
pub consecutive_failures_alert: Option<u32>,
#[serde(default)]
pub latency_threshold_ms: Option<u32>,
#[serde(default)]
pub alert_session_ids: Vec<String>,
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct HealthProbeConfigOptions {
#[serde(default)]
pub timeout_secs: Option<u64>,
#[serde(default)]
pub expected_status: Option<u16>,
#[serde(default)]
pub expected_body: Option<String>,
#[serde(default)]
pub method: Option<String>,
#[serde(default)]
pub headers: Option<std::collections::HashMap<String, String>>,
#[serde(default)]
pub max_age_secs: Option<u64>,
#[serde(default)]
pub expected_exit_code: Option<i32>,
}
#[derive(Debug, Deserialize, Clone, Default, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum UpdateMode {
Enable,
#[default]
CheckOnly,
Disable,
}
#[derive(Debug, Deserialize, Clone)]
pub struct UpdateConfig {
#[serde(default)]
pub mode: UpdateMode,
#[serde(default = "default_update_check_interval_hours")]
pub check_interval_hours: u64,
#[serde(default)]
pub check_at_utc_hour: Option<u8>,
#[serde(default = "default_update_confirmation_timeout_mins")]
pub confirmation_timeout_mins: u64,
}
impl Default for UpdateConfig {
fn default() -> Self {
Self {
mode: UpdateMode::default(),
check_interval_hours: default_update_check_interval_hours(),
check_at_utc_hour: None,
confirmation_timeout_mins: default_update_confirmation_timeout_mins(),
}
}
}
fn default_update_check_interval_hours() -> u64 {
24
}
fn default_update_confirmation_timeout_mins() -> u64 {
60
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct UsersConfig {
#[serde(default)]
pub owner_ids: HashMap<String, Vec<String>>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct FilesConfig {
#[serde(default = "default_files_enabled")]
pub enabled: bool,
#[serde(default = "default_inbox_dir")]
pub inbox_dir: String,
#[serde(default = "default_outbox_dirs")]
pub outbox_dirs: Vec<String>,
#[serde(default = "default_max_file_size_mb")]
pub max_file_size_mb: u64,
#[serde(default = "default_retention_hours")]
pub retention_hours: u64,
#[serde(default = "default_vision_enabled")]
pub vision_enabled: bool,
#[serde(default = "default_max_vision_image_mb")]
pub max_vision_image_mb: u64,
#[serde(default = "default_vision_mime_types")]
pub vision_mime_types: Vec<String>,
#[serde(default = "default_vision_model_patterns")]
pub vision_model_patterns: Vec<String>,
#[serde(default = "default_audio_enabled")]
pub audio_enabled: bool,
#[serde(default = "default_max_audio_mb")]
pub max_audio_mb: u64,
#[serde(default = "default_audio_mime_types")]
pub audio_mime_types: Vec<String>,
#[serde(default = "default_audio_model_patterns")]
pub audio_model_patterns: Vec<String>,
#[serde(default)]
pub stt: SttFilesConfig,
}
#[derive(Debug, Deserialize, Clone)]
pub struct SttFilesConfig {
#[serde(default = "default_stt_enabled")]
pub enabled: bool,
#[serde(default = "default_stt_cli_path")]
pub cli_path: String,
#[serde(default = "default_stt_model_path")]
pub model_path: String,
#[serde(default = "default_stt_ffmpeg_path")]
pub ffmpeg_path: String,
#[serde(default = "default_stt_language")]
pub language: String,
#[serde(default = "default_stt_max_audio_mb")]
pub max_audio_mb: u64,
#[serde(default = "default_stt_timeout_secs")]
pub timeout_secs: u64,
#[serde(default = "default_stt_mime_types")]
pub mime_types: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct VisionConfig {
pub enabled: bool,
pub max_image_bytes: u64,
pub mime_types: Vec<String>,
#[cfg_attr(not(feature = "computer_use"), allow(dead_code))]
pub model_patterns: Vec<String>,
}
impl VisionConfig {
pub fn from_files(files: &FilesConfig) -> Self {
Self {
enabled: files.vision_enabled,
max_image_bytes: files.max_vision_image_mb.saturating_mul(1_048_576),
mime_types: files.vision_mime_types.clone(),
model_patterns: files.vision_model_patterns.clone(),
}
}
#[cfg_attr(not(feature = "computer_use"), allow(dead_code))]
pub fn model_supports_vision(&self, model: &str) -> bool {
if !self.enabled {
return false;
}
let model_lower = model.to_ascii_lowercase();
self.model_patterns
.iter()
.any(|pattern| model_lower.contains(&pattern.to_ascii_lowercase()))
}
pub fn mime_allowed(&self, mime: &str) -> bool {
self.mime_types.iter().any(|allowed| allowed == mime)
}
}
#[derive(Debug, Clone)]
pub struct AudioConfig {
pub enabled: bool,
pub max_audio_bytes: u64,
pub mime_types: Vec<String>,
pub model_patterns: Vec<String>,
}
impl AudioConfig {
pub fn from_files(files: &FilesConfig) -> Self {
Self {
enabled: files.audio_enabled,
max_audio_bytes: files.max_audio_mb.saturating_mul(1_048_576),
mime_types: files.audio_mime_types.clone(),
model_patterns: files.audio_model_patterns.clone(),
}
}
pub fn mime_allowed(&self, mime: &str) -> bool {
self.mime_types.iter().any(|allowed| allowed == mime)
}
pub fn model_supports_audio(&self, model: &str) -> bool {
let model_lower = model.to_ascii_lowercase();
self.model_patterns
.iter()
.any(|pattern| model_lower.contains(&pattern.to_ascii_lowercase()))
}
}
#[derive(Debug, Clone)]
pub struct SttConfig {
pub enabled: bool,
pub cli_path: std::path::PathBuf,
pub model_path: std::path::PathBuf,
pub ffmpeg_path: std::path::PathBuf,
pub language: String,
pub max_audio_bytes: u64,
pub timeout_secs: u64,
pub mime_types: Vec<String>,
}
impl SttConfig {
pub fn from_files(files: &FilesConfig) -> Self {
Self {
enabled: files.stt.enabled,
cli_path: std::path::PathBuf::from(
shellexpand::tilde(&files.stt.cli_path).into_owned(),
),
model_path: std::path::PathBuf::from(
shellexpand::tilde(&files.stt.model_path).into_owned(),
),
ffmpeg_path: std::path::PathBuf::from(
shellexpand::tilde(&files.stt.ffmpeg_path).into_owned(),
),
language: files.stt.language.clone(),
max_audio_bytes: files.stt.max_audio_mb.saturating_mul(1_048_576),
timeout_secs: files.stt.timeout_secs,
mime_types: files.stt.mime_types.clone(),
}
}
pub fn mime_allowed(&self, mime: &str) -> bool {
self.mime_types.iter().any(|allowed| allowed == mime)
}
}
impl Default for SttFilesConfig {
fn default() -> Self {
Self {
enabled: default_stt_enabled(),
cli_path: default_stt_cli_path(),
model_path: default_stt_model_path(),
ffmpeg_path: default_stt_ffmpeg_path(),
language: default_stt_language(),
max_audio_mb: default_stt_max_audio_mb(),
timeout_secs: default_stt_timeout_secs(),
mime_types: default_stt_mime_types(),
}
}
}
impl Default for FilesConfig {
fn default() -> Self {
Self {
enabled: default_files_enabled(),
inbox_dir: default_inbox_dir(),
outbox_dirs: default_outbox_dirs(),
max_file_size_mb: default_max_file_size_mb(),
retention_hours: default_retention_hours(),
vision_enabled: default_vision_enabled(),
max_vision_image_mb: default_max_vision_image_mb(),
vision_mime_types: default_vision_mime_types(),
vision_model_patterns: default_vision_model_patterns(),
audio_enabled: default_audio_enabled(),
max_audio_mb: default_max_audio_mb(),
audio_mime_types: default_audio_mime_types(),
audio_model_patterns: default_audio_model_patterns(),
stt: SttFilesConfig::default(),
}
}
}
fn default_files_enabled() -> bool {
true
}
fn default_inbox_dir() -> String {
"~/.aidaemon/files/inbox".to_string()
}
fn default_outbox_dirs() -> Vec<String> {
vec!["~".to_string()]
}
fn default_max_file_size_mb() -> u64 {
10
}
fn default_retention_hours() -> u64 {
24
}
fn default_vision_enabled() -> bool {
true
}
fn default_max_vision_image_mb() -> u64 {
4
}
fn default_vision_mime_types() -> Vec<String> {
vec![
"image/jpeg".to_string(),
"image/png".to_string(),
"image/gif".to_string(),
"image/webp".to_string(),
]
}
fn default_vision_model_patterns() -> Vec<String> {
vec![
"gpt-4o".to_string(),
"gpt-4".to_string(),
"gpt-5".to_string(),
"o1".to_string(),
"o3".to_string(),
"o4".to_string(),
"gemini".to_string(),
"claude-3".to_string(),
"claude-sonnet".to_string(),
"claude-opus".to_string(),
"claude-haiku".to_string(),
"fable".to_string(),
"gemma".to_string(),
"llava".to_string(),
"qwen-vl".to_string(),
"qwen2-vl".to_string(),
"qwen2.5-vl".to_string(),
"qwen3-vl".to_string(),
"internvl".to_string(),
"vision".to_string(),
"pixtral".to_string(),
"mistral-large".to_string(),
]
}
fn default_audio_enabled() -> bool {
true
}
fn default_max_audio_mb() -> u64 {
10
}
fn default_audio_mime_types() -> Vec<String> {
vec![
"audio/ogg".to_string(),
"audio/mpeg".to_string(),
"audio/mp3".to_string(),
"audio/wav".to_string(),
"audio/flac".to_string(),
"audio/aac".to_string(),
]
}
fn default_audio_model_patterns() -> Vec<String> {
vec![
"audio".to_string(),
"gemini-2".to_string(),
"gemini-3".to_string(),
]
}
fn default_stt_enabled() -> bool {
false
}
fn default_stt_cli_path() -> String {
"/opt/homebrew/bin/whisper-cli".to_string()
}
fn default_stt_model_path() -> String {
"~/models/whisper/ggml-medium.en.bin".to_string()
}
fn default_stt_ffmpeg_path() -> String {
"ffmpeg".to_string()
}
fn default_stt_language() -> String {
"en".to_string()
}
fn default_stt_max_audio_mb() -> u64 {
25
}
fn default_stt_timeout_secs() -> u64 {
120
}
fn default_stt_mime_types() -> Vec<String> {
vec![
"audio/ogg".to_string(),
"audio/mpeg".to_string(),
"audio/mp3".to_string(),
"audio/wav".to_string(),
"audio/flac".to_string(),
"audio/aac".to_string(),
"audio/webm".to_string(),
]
}
#[derive(Debug, Deserialize, Clone, Default)]
pub struct OAuthConfig {
#[allow(dead_code)]
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub callback_url: Option<String>,
#[serde(default)]
pub providers: HashMap<String, OAuthProviderConfig>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct OAuthProviderConfig {
#[serde(default)]
pub display_name: Option<String>,
pub auth_type: String,
pub authorize_url: String,
pub token_url: String,
#[serde(default)]
pub scopes: Vec<String>,
#[serde(default)]
pub allowed_domains: Vec<String>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct PeopleConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_people_auto_extract")]
pub auto_extract: bool,
#[serde(default = "default_people_auto_extract_categories")]
pub auto_extract_categories: Vec<String>,
#[serde(default = "default_people_restricted_categories")]
pub restricted_categories: Vec<String>,
#[serde(default = "default_people_fact_retention_days")]
pub fact_retention_days: u32,
#[serde(default = "default_people_reconnect_reminder_days")]
pub reconnect_reminder_days: u32,
}
impl Default for PeopleConfig {
fn default() -> Self {
Self {
enabled: false,
auto_extract: default_people_auto_extract(),
auto_extract_categories: default_people_auto_extract_categories(),
restricted_categories: default_people_restricted_categories(),
fact_retention_days: default_people_fact_retention_days(),
reconnect_reminder_days: default_people_reconnect_reminder_days(),
}
}
}
fn default_people_auto_extract() -> bool {
true
}
fn default_people_auto_extract_categories() -> Vec<String> {
vec![
"birthday".into(),
"preference".into(),
"interest".into(),
"work".into(),
"family".into(),
"important_date".into(),
]
}
fn default_people_restricted_categories() -> Vec<String> {
vec![
"health".into(),
"finance".into(),
"political".into(),
"religious".into(),
]
}
fn default_people_fact_retention_days() -> u32 {
180
}
fn default_people_reconnect_reminder_days() -> u32 {
30
}
#[derive(Deserialize, Clone, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum HttpAuthType {
Oauth1a,
Bearer,
Header,
Basic,
}
impl fmt::Debug for HttpAuthType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
HttpAuthType::Oauth1a => write!(f, "oauth1a"),
HttpAuthType::Bearer => write!(f, "bearer"),
HttpAuthType::Header => write!(f, "header"),
HttpAuthType::Basic => write!(f, "basic"),
}
}
}
#[derive(Deserialize, Clone)]
pub struct HttpAuthProfile {
pub auth_type: HttpAuthType,
pub allowed_domains: Vec<String>,
#[serde(default)]
pub api_key: Option<String>,
#[serde(default)]
pub api_secret: Option<String>,
#[serde(default)]
pub access_token: Option<String>,
#[serde(default)]
pub access_token_secret: Option<String>,
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub header_name: Option<String>,
#[serde(default)]
pub header_value: Option<String>,
#[serde(default)]
pub username: Option<String>,
#[serde(default)]
pub password: Option<String>,
}
impl fmt::Debug for HttpAuthProfile {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("HttpAuthProfile")
.field("auth_type", &self.auth_type)
.field("allowed_domains", &self.allowed_domains)
.field("api_key", &redact_option(&self.api_key))
.field("api_secret", &redact_option(&self.api_secret))
.field("access_token", &redact_option(&self.access_token))
.field(
"access_token_secret",
&redact_option(&self.access_token_secret),
)
.field("user_id", &self.user_id)
.field("token", &redact_option(&self.token))
.field("header_name", &self.header_name)
.field("header_value", &redact_option(&self.header_value))
.field("username", &self.username)
.field("password", &redact_option(&self.password))
.finish()
}
}
impl HttpAuthProfile {
pub fn credential_values(&self) -> Vec<&str> {
let mut vals = Vec::new();
for v in [
&self.api_key,
&self.api_secret,
&self.access_token,
&self.access_token_secret,
&self.token,
&self.header_value,
&self.password,
]
.into_iter()
.flatten()
{
if !v.is_empty() {
vals.push(v.as_str());
}
}
vals
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct ContextWindowConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_context_budget")]
pub default_budget: usize,
#[serde(default)]
pub model_budgets: HashMap<String, usize>,
#[serde(default = "default_tool_result_chars")]
pub max_tool_result_chars: usize,
#[serde(default)]
pub model_tool_result_chars: HashMap<String, usize>,
#[serde(default = "default_summary_window")]
pub summary_window: usize,
#[serde(default = "default_true")]
pub progressive_facts: bool,
#[serde(default = "default_summarize_threshold")]
pub summarize_threshold: usize,
}
impl Default for ContextWindowConfig {
fn default() -> Self {
Self {
enabled: true,
default_budget: default_context_budget(),
model_budgets: HashMap::new(),
max_tool_result_chars: default_tool_result_chars(),
model_tool_result_chars: HashMap::new(),
summary_window: default_summary_window(),
progressive_facts: true,
summarize_threshold: default_summarize_threshold(),
}
}
}
impl ContextWindowConfig {
pub fn tool_result_chars_for(&self, model: &str) -> usize {
const MIN_TOOL_RESULT_CHARS: usize = 1_000;
if let Some(&chars) = self.model_tool_result_chars.get(model) {
return chars;
}
if let Some(&budget) = self.model_budgets.get(model) {
if let Some(scaled) = self
.max_tool_result_chars
.saturating_mul(budget)
.checked_div(self.default_budget)
{
return scaled.max(MIN_TOOL_RESULT_CHARS);
}
}
self.max_tool_result_chars
}
}
fn default_context_budget() -> usize {
48000
}
fn default_tool_result_chars() -> usize {
4000
}
fn default_summary_window() -> usize {
6
}
fn default_summarize_threshold() -> usize {
12
}
fn default_heartbeat_tick() -> u64 {
30
}
fn default_max_concurrent() -> usize {
3
}
fn default_policy_shadow_mode() -> bool {
true
}
fn default_policy_enforce() -> bool {
true
}
fn default_tool_filter_enforce() -> bool {
true
}
fn default_uncertainty_clarify_enforce() -> bool {
true
}
fn default_context_refresh_enforce() -> bool {
true
}
fn default_learning_evidence_gate_enforce() -> bool {
true
}
fn default_autotune_shadow() -> bool {
true
}
fn default_autotune_enforce() -> bool {
true
}
fn default_uncertainty_threshold() -> f32 {
0.55
}
fn default_trust_tier() -> String {
"auto".to_string()
}
fn default_write_consistency_max_abs_global_delta() -> u64 {
3
}
fn default_write_consistency_max_session_mismatch_count() -> u64 {
0
}
fn default_write_consistency_max_stale_task_starts() -> u64 {
0
}
fn default_write_consistency_max_missing_message_id_events() -> u64 {
0
}
fn default_diagnostics_max_events() -> usize {
200
}
fn default_disabled_tools() -> Vec<String> {
vec![
"git_info".to_string(),
"git_commit".to_string(),
"policy_metrics".to_string(),
"check_environment".to_string(),
"service_status".to_string(),
"project_inspect".to_string(),
"read_channel_history".to_string(),
"tool_trace".to_string(),
]
}
#[derive(Debug, Deserialize, Clone)]
pub struct ToolsConfig {
#[serde(default = "default_disabled_tools")]
pub disabled: Vec<String>,
}
impl Default for ToolsConfig {
fn default() -> Self {
Self {
disabled: default_disabled_tools(),
}
}
}
impl ToolsConfig {
pub fn is_enabled(&self, tool_name: &str) -> bool {
!self.disabled.iter().any(|name| name == tool_name)
}
}
fn default_diagnostics_enabled() -> bool {
false
}
#[derive(Debug, Deserialize, Clone)]
pub struct DiagnosticsConfig {
#[serde(default = "default_diagnostics_enabled")]
pub enabled: bool,
#[serde(default = "default_true")]
pub record_decision_points: bool,
#[serde(default = "default_diagnostics_max_events")]
pub max_events: usize,
#[serde(default)]
pub include_raw_tool_args: bool,
#[serde(default)]
pub harness_eval: DiagnosticsHarnessEvalConfig,
}
#[derive(Debug, Deserialize, Clone)]
#[allow(dead_code)] pub struct DiagnosticsHarnessEvalConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub persist_on_task_end: bool,
#[serde(default)]
pub emit_separate_event: bool,
#[serde(default = "default_harness_weight_routing")]
pub weight_routing: f32,
#[serde(default = "default_harness_weight_progress")]
pub weight_progress: f32,
#[serde(default = "default_harness_weight_quality")]
pub weight_quality: f32,
#[serde(default = "default_harness_weight_cost")]
pub weight_cost: f32,
#[serde(default = "default_harness_cost_tier_cheap")]
pub cost_tier_cheap: f32,
#[serde(default = "default_harness_cost_tier_balanced")]
pub cost_tier_balanced: f32,
#[serde(default = "default_harness_cost_tier_strong")]
pub cost_tier_strong: f32,
#[serde(default = "default_harness_cost_tier_unknown")]
pub cost_tier_unknown: f32,
#[serde(default = "default_harness_warn_overall")]
pub warn_overall_below: f32,
#[serde(default = "default_harness_warn_routing")]
pub warn_routing_below: f32,
}
impl Default for DiagnosticsHarnessEvalConfig {
fn default() -> Self {
Self {
enabled: true,
persist_on_task_end: true,
emit_separate_event: false,
weight_routing: default_harness_weight_routing(),
weight_progress: default_harness_weight_progress(),
weight_quality: default_harness_weight_quality(),
weight_cost: default_harness_weight_cost(),
cost_tier_cheap: default_harness_cost_tier_cheap(),
cost_tier_balanced: default_harness_cost_tier_balanced(),
cost_tier_strong: default_harness_cost_tier_strong(),
cost_tier_unknown: default_harness_cost_tier_unknown(),
warn_overall_below: default_harness_warn_overall(),
warn_routing_below: default_harness_warn_routing(),
}
}
}
fn default_harness_weight_routing() -> f32 {
0.30
}
fn default_harness_weight_progress() -> f32 {
0.25
}
fn default_harness_weight_quality() -> f32 {
0.30
}
fn default_harness_weight_cost() -> f32 {
0.15
}
fn default_harness_cost_tier_cheap() -> f32 {
1.0
}
fn default_harness_cost_tier_balanced() -> f32 {
2.5
}
fn default_harness_cost_tier_strong() -> f32 {
5.0
}
fn default_harness_cost_tier_unknown() -> f32 {
3.0
}
fn default_harness_warn_overall() -> f32 {
0.6
}
fn default_harness_warn_routing() -> f32 {
0.7
}
impl Default for DiagnosticsConfig {
fn default() -> Self {
Self {
enabled: default_diagnostics_enabled(),
record_decision_points: true,
max_events: default_diagnostics_max_events(),
include_raw_tool_args: false,
harness_eval: DiagnosticsHarnessEvalConfig::default(),
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct HeartbeatConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_heartbeat_tick")]
pub tick_interval_secs: u64,
#[serde(default = "default_max_concurrent")]
pub max_concurrent_llm_tasks: usize,
}
impl Default for HeartbeatConfig {
fn default() -> Self {
Self {
enabled: true,
tick_interval_secs: 30,
max_concurrent_llm_tasks: 3,
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct WriteConsistencyConfig {
#[serde(default = "default_write_consistency_max_abs_global_delta")]
pub max_abs_global_delta: u64,
#[serde(default = "default_write_consistency_max_session_mismatch_count")]
pub max_session_mismatch_count: u64,
#[serde(default = "default_write_consistency_max_stale_task_starts")]
pub max_stale_task_starts: u64,
#[serde(default = "default_write_consistency_max_missing_message_id_events")]
pub max_missing_message_id_events: u64,
}
impl Default for WriteConsistencyConfig {
fn default() -> Self {
Self {
max_abs_global_delta: default_write_consistency_max_abs_global_delta(),
max_session_mismatch_count: default_write_consistency_max_session_mismatch_count(),
max_stale_task_starts: default_write_consistency_max_stale_task_starts(),
max_missing_message_id_events: default_write_consistency_max_missing_message_id_events(
),
}
}
}
impl WriteConsistencyConfig {
pub fn thresholds(&self) -> crate::events::WriteConsistencyThresholds {
crate::events::WriteConsistencyThresholds {
max_abs_global_delta: self.max_abs_global_delta,
max_session_mismatch_count: self.max_session_mismatch_count,
max_stale_task_starts: self.max_stale_task_starts,
max_missing_message_id_events: self.max_missing_message_id_events,
}
}
}
#[derive(Debug, Deserialize, Clone)]
pub struct PolicyConfig {
#[serde(default = "default_policy_shadow_mode")]
pub policy_shadow_mode: bool,
#[serde(default = "default_policy_enforce")]
pub policy_enforce: bool,
#[serde(default = "default_tool_filter_enforce")]
pub tool_filter_enforce: bool,
#[serde(default = "default_uncertainty_clarify_enforce")]
pub uncertainty_clarify_enforce: bool,
#[serde(default = "default_context_refresh_enforce")]
pub context_refresh_enforce: bool,
#[serde(default = "default_learning_evidence_gate_enforce")]
pub learning_evidence_gate_enforce: bool,
#[serde(default = "default_autotune_shadow")]
pub autotune_shadow: bool,
#[serde(default = "default_autotune_enforce")]
pub autotune_enforce: bool,
#[serde(default = "default_uncertainty_threshold")]
pub uncertainty_clarify_threshold: f32,
#[serde(default = "default_trust_tier")]
pub trust_tier: String,
#[serde(default)]
pub write_consistency: WriteConsistencyConfig,
}
impl Default for PolicyConfig {
fn default() -> Self {
Self {
policy_shadow_mode: default_policy_shadow_mode(),
policy_enforce: default_policy_enforce(),
tool_filter_enforce: default_tool_filter_enforce(),
uncertainty_clarify_enforce: default_uncertainty_clarify_enforce(),
context_refresh_enforce: default_context_refresh_enforce(),
learning_evidence_gate_enforce: default_learning_evidence_gate_enforce(),
autotune_shadow: default_autotune_shadow(),
autotune_enforce: default_autotune_enforce(),
uncertainty_clarify_threshold: default_uncertainty_threshold(),
trust_tier: default_trust_tier(),
write_consistency: WriteConsistencyConfig::default(),
}
}
}
impl AppConfig {
pub fn load(path: &Path) -> anyhow::Result<Self> {
let content = std::fs::read_to_string(path)?;
let expanded = expand_env_vars(&content)?;
let mut config: AppConfig = toml::from_str(&expanded)?;
config.provider.apply_model_defaults_recursive();
config.daemon.queue_policy = config.daemon.queue_policy.normalized();
config.resolve_secrets()?;
Ok(config)
}
pub fn all_telegram_bots(&self) -> Vec<TelegramBotConfig> {
let mut bots = self.telegram_bots.clone();
if let Some(ref legacy) = self.telegram {
if !legacy.bot_token.is_empty() {
bots.push(TelegramBotConfig {
bot_token: legacy.bot_token.clone(),
allowed_user_ids: legacy.allowed_user_ids.clone(),
webhook: legacy.webhook.clone(),
});
}
}
bots
}
#[cfg(feature = "discord")]
pub fn all_discord_bots(&self) -> Vec<DiscordBotConfig> {
let mut bots = self.discord_bots.clone();
if let Some(ref legacy) = self.discord {
if !legacy.bot_token.is_empty() {
bots.push(DiscordBotConfig {
bot_token: legacy.bot_token.clone(),
allowed_user_ids: legacy.allowed_user_ids.clone(),
guild_id: legacy.guild_id,
});
}
}
bots
}
#[cfg(feature = "slack")]
pub fn all_slack_bots(&self) -> Vec<SlackBotConfig> {
let mut bots = self.slack_bots.clone();
if let Some(ref legacy) = self.slack {
if !legacy.bot_token.is_empty() && !legacy.app_token.is_empty() {
bots.push(SlackBotConfig {
app_token: legacy.app_token.clone(),
bot_token: legacy.bot_token.clone(),
allowed_user_ids: legacy.allowed_user_ids.clone(),
use_threads: legacy.use_threads,
});
}
}
bots
}
fn resolve_secrets(&mut self) -> anyhow::Result<()> {
self.provider.resolve_secrets_recursive(None)?;
if let Some(ref mut telegram) = self.telegram {
if telegram.bot_token == "keychain" {
telegram.bot_token = resolve_from_keychain("bot_token")?;
}
}
for (i, bot) in self.telegram_bots.iter_mut().enumerate() {
if bot.bot_token == "keychain" {
let key = if i == 0 {
"bot_token".to_string()
} else {
format!("telegram_bot_token_{}", i)
};
bot.bot_token = resolve_from_keychain(&key)?;
}
}
if let Some(ref email) = self.triggers.email {
if email.password == "keychain" {
let password = resolve_from_keychain("email_password")?;
let mut email = email.clone();
email.password = password;
self.triggers.email = Some(email);
}
}
if let Some(ref key) = self.state.encryption_key {
if key == "keychain" {
self.state.encryption_key = Some(resolve_from_keychain("encryption_key")?);
}
}
if self.search.api_key == "keychain" {
self.search.api_key = resolve_from_keychain("search_api_key")?;
}
if self.terminal.daemon_connect_token.as_deref() == Some("keychain") {
self.terminal.daemon_connect_token =
Some(resolve_from_keychain("terminal_daemon_connect_token")?);
}
#[cfg(feature = "discord")]
{
if let Some(ref mut discord) = self.discord {
if discord.bot_token == "keychain" {
discord.bot_token = resolve_from_keychain("discord_bot_token")?;
}
}
for (i, bot) in self.discord_bots.iter_mut().enumerate() {
if bot.bot_token == "keychain" {
let key = if i == 0 {
"discord_bot_token".to_string()
} else {
format!("discord_bot_token_{}", i)
};
bot.bot_token = resolve_from_keychain(&key)?;
}
}
}
#[cfg(feature = "slack")]
{
if let Some(ref mut slack) = self.slack {
if slack.app_token == "keychain" {
slack.app_token = resolve_from_keychain("slack_app_token")?;
}
if slack.bot_token == "keychain" {
slack.bot_token = resolve_from_keychain("slack_bot_token")?;
}
}
for (i, bot) in self.slack_bots.iter_mut().enumerate() {
if bot.app_token == "keychain" {
let key = if i == 0 {
"slack_app_token".to_string()
} else {
format!("slack_app_token_{}", i)
};
bot.app_token = resolve_from_keychain(&key)?;
}
if bot.bot_token == "keychain" {
let key = if i == 0 {
"slack_bot_token".to_string()
} else {
format!("slack_bot_token_{}", i)
};
bot.bot_token = resolve_from_keychain(&key)?;
}
}
}
for (name, profile) in self.http_auth.iter_mut() {
let fields: &mut [(&str, &mut Option<String>)] = &mut [
("api_key", &mut profile.api_key),
("api_secret", &mut profile.api_secret),
("access_token", &mut profile.access_token),
("access_token_secret", &mut profile.access_token_secret),
("token", &mut profile.token),
("header_value", &mut profile.header_value),
("password", &mut profile.password),
];
for (field, value) in fields.iter_mut() {
if let Some(ref v) = value {
if v == "keychain" {
let keychain_key = format!("http_auth_{}_{}", name, field);
**value = Some(resolve_from_keychain(&keychain_key)?);
}
}
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use once_cell::sync::Lazy;
#[test]
fn tool_result_chars_for_unknown_model_uses_global_default() {
let config = ContextWindowConfig::default();
assert_eq!(
config.tool_result_chars_for("some-model"),
config.max_tool_result_chars
);
}
#[test]
fn tool_result_chars_for_explicit_override_wins() {
let mut config = ContextWindowConfig::default();
config.model_budgets.insert("gemma-3-4b".to_string(), 8_000);
config
.model_tool_result_chars
.insert("gemma-3-4b".to_string(), 1_500);
assert_eq!(config.tool_result_chars_for("gemma-3-4b"), 1_500);
}
#[test]
fn tool_result_chars_for_scales_down_with_small_model_budget() {
let mut config = ContextWindowConfig::default();
config.model_budgets.insert("gemma-3-4b".to_string(), 8_000);
let chars = config.tool_result_chars_for("gemma-3-4b");
assert!(
chars < config.max_tool_result_chars,
"small budget should shrink the cap, got {chars}"
);
assert!(chars >= 1_000, "cap must not drop below usable floor");
}
#[test]
fn tool_result_chars_for_scales_up_with_large_model_budget() {
let mut config = ContextWindowConfig::default();
config
.model_budgets
.insert("claude-fable-5".to_string(), 192_000);
let chars = config.tool_result_chars_for("claude-fable-5");
assert!(
chars > config.max_tool_result_chars,
"large budget should raise the cap, got {chars}"
);
}
static ENV_EXPANSION_LOCK: Lazy<std::sync::Mutex<()>> = Lazy::new(|| std::sync::Mutex::new(()));
static TERMINAL_WEB_APP_URL_ENV_LOCK: Lazy<std::sync::Mutex<()>> =
Lazy::new(|| std::sync::Mutex::new(()));
static TERMINAL_BRIDGE_ENABLED_ENV_LOCK: Lazy<std::sync::Mutex<()>> =
Lazy::new(|| std::sync::Mutex::new(()));
static TERMINAL_STATIC_FALLBACK_ENV_LOCK: Lazy<std::sync::Mutex<()>> =
Lazy::new(|| std::sync::Mutex::new(()));
static KEYCHAIN_ENV_FILE_LOCK: Lazy<std::sync::Mutex<()>> =
Lazy::new(|| std::sync::Mutex::new(()));
fn restore_env_var(name: &str, old_value: Option<String>) {
if let Some(old) = old_value {
std::env::set_var(name, old);
} else {
std::env::remove_var(name);
}
}
#[test]
fn cli_agents_config_defaults_to_disabled() {
let config: AppConfig = toml::from_str(
r#"
[provider]
api_key = "test"
"#,
)
.unwrap();
assert!(!config.cli_agents.enabled);
}
#[test]
fn health_config_defaults_to_disabled() {
let config: AppConfig = toml::from_str(
r#"
[provider]
api_key = "test"
"#,
)
.unwrap();
assert!(!config.health.enabled);
}
#[test]
fn diagnostics_config_defaults_to_disabled() {
let config: AppConfig = toml::from_str(
r#"
[provider]
api_key = "test"
"#,
)
.unwrap();
assert!(!config.diagnostics.enabled);
assert!(config.diagnostics.record_decision_points);
}
#[test]
fn tools_config_defaults_disable_low_value_base_tools() {
let config = ToolsConfig::default();
for tool in [
"git_info",
"git_commit",
"policy_metrics",
"check_environment",
"service_status",
"project_inspect",
"read_channel_history",
"tool_trace",
] {
assert!(
!config.is_enabled(tool),
"expected {tool} to be disabled by default"
);
}
assert!(config.is_enabled("terminal"));
assert!(config.is_enabled("goal_trace"));
assert!(!config.is_enabled("tool_trace"));
assert!(config.is_enabled("search_files"));
}
#[test]
fn tools_disabled_list_parses_from_config() {
let config: AppConfig = toml::from_str(
r#"
[provider]
api_key = "test"
[tools]
disabled = ["policy_metrics", "service_status"]
"#,
)
.unwrap();
assert_eq!(
config.tools.disabled,
vec!["policy_metrics".to_string(), "service_status".to_string()]
);
assert!(!config.tools.is_enabled("policy_metrics"));
assert!(config.tools.is_enabled("terminal"));
}
#[test]
#[serial_test::serial(env_vars)]
fn expand_env_vars_replaces_set_variable() {
let _guard = ENV_EXPANSION_LOCK.lock().unwrap();
std::env::set_var("AIDAEMON_TEST_VAR", "hello");
let result = expand_env_vars("key = \"${AIDAEMON_TEST_VAR}\"").unwrap();
assert_eq!(result, "key = \"hello\"");
std::env::remove_var("AIDAEMON_TEST_VAR");
}
#[test]
#[serial_test::serial(env_vars)]
fn expand_env_vars_errors_on_missing() {
let _guard = ENV_EXPANSION_LOCK.lock().unwrap();
std::env::remove_var("AIDAEMON_MISSING_VAR");
let result = expand_env_vars("key = \"${AIDAEMON_MISSING_VAR}\"");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("AIDAEMON_MISSING_VAR"));
}
#[test]
fn expand_env_vars_leaves_plain_strings() {
let input = "key = \"sk-hardcoded-value\"";
let result = expand_env_vars(input).unwrap();
assert_eq!(result, input);
}
#[test]
#[serial_test::serial(env_vars)]
fn expand_env_vars_handles_multiple() {
let _guard = ENV_EXPANSION_LOCK.lock().unwrap();
std::env::set_var("AIDAEMON_TEST_A", "aaa");
std::env::set_var("AIDAEMON_TEST_B", "bbb");
let result =
expand_env_vars("a = \"${AIDAEMON_TEST_A}\"\nb = \"${AIDAEMON_TEST_B}\"").unwrap();
assert_eq!(result, "a = \"aaa\"\nb = \"bbb\"");
std::env::remove_var("AIDAEMON_TEST_A");
std::env::remove_var("AIDAEMON_TEST_B");
}
#[test]
fn expand_env_vars_ignores_bare_dollar() {
let input = "path = \"$HOME/something\"";
let result = expand_env_vars(input).unwrap();
assert_eq!(result, input);
}
#[test]
#[serial_test::serial(env_vars)]
fn store_in_keychain_writes_env_file_when_keychain_disabled() {
let _guard = KEYCHAIN_ENV_FILE_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let env_path = tmp.path().join(".env.test");
std::fs::write(&env_path, "EXISTING=1\n").unwrap();
let old_no_keychain = std::env::var("AIDAEMON_NO_KEYCHAIN").ok();
let old_env_file = std::env::var("AIDAEMON_ENV_FILE").ok();
std::env::set_var("AIDAEMON_NO_KEYCHAIN", "1");
std::env::set_var("AIDAEMON_ENV_FILE", env_path.to_string_lossy().to_string());
store_in_keychain("oauth_twitter_access_token", "new token$1")
.expect("write oauth token to env");
let content = std::fs::read_to_string(&env_path).expect("read env file");
assert!(content.contains("EXISTING=1"));
assert!(content.contains("OAUTH_TWITTER_ACCESS_TOKEN=\"new token\\$1\""));
restore_env_var("AIDAEMON_NO_KEYCHAIN", old_no_keychain);
restore_env_var("AIDAEMON_ENV_FILE", old_env_file);
}
#[test]
#[serial_test::serial(env_vars)]
fn delete_from_keychain_removes_env_file_key_when_keychain_disabled() {
let _guard = KEYCHAIN_ENV_FILE_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let env_path = tmp.path().join(".env.test");
std::fs::write(
&env_path,
"KEEP=1\nOAUTH_TWITTER_ACCESS_TOKEN=oldtoken\nANOTHER=2\n",
)
.unwrap();
let old_no_keychain = std::env::var("AIDAEMON_NO_KEYCHAIN").ok();
let old_env_file = std::env::var("AIDAEMON_ENV_FILE").ok();
std::env::set_var("AIDAEMON_NO_KEYCHAIN", "1");
std::env::set_var("AIDAEMON_ENV_FILE", env_path.to_string_lossy().to_string());
delete_from_keychain("oauth_twitter_access_token").expect("delete oauth token from env");
let content = std::fs::read_to_string(&env_path).expect("read env file");
assert!(content.contains("KEEP=1"));
assert!(content.contains("ANOTHER=2"));
assert!(!content.contains("OAUTH_TWITTER_ACCESS_TOKEN"));
restore_env_var("AIDAEMON_NO_KEYCHAIN", old_no_keychain);
restore_env_var("AIDAEMON_ENV_FILE", old_env_file);
}
#[test]
#[serial_test::serial(env_vars)]
fn resolve_from_keychain_reads_env_file_when_keychain_disabled() {
let _guard = KEYCHAIN_ENV_FILE_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let env_path = tmp.path().join(".env.test");
std::fs::write(&env_path, "OAUTH_TWITTER_CLIENT_ID=client123\n").unwrap();
let old_no_keychain = std::env::var("AIDAEMON_NO_KEYCHAIN").ok();
let old_env_file = std::env::var("AIDAEMON_ENV_FILE").ok();
let old_client_id = std::env::var("OAUTH_TWITTER_CLIENT_ID").ok();
std::env::set_var("AIDAEMON_NO_KEYCHAIN", "1");
std::env::set_var("AIDAEMON_ENV_FILE", env_path.to_string_lossy().to_string());
std::env::remove_var("OAUTH_TWITTER_CLIENT_ID");
let value = resolve_from_keychain("oauth_twitter_client_id").expect("resolve client id");
assert_eq!(value, "client123");
restore_env_var("AIDAEMON_NO_KEYCHAIN", old_no_keychain);
restore_env_var("AIDAEMON_ENV_FILE", old_env_file);
restore_env_var("OAUTH_TWITTER_CLIENT_ID", old_client_id);
}
#[test]
#[serial_test::serial(env_vars)]
fn resolve_from_keychain_tolerates_malformed_env_lines() {
let _guard = KEYCHAIN_ENV_FILE_LOCK.lock().unwrap();
let tmp = tempfile::tempdir().unwrap();
let env_path = tmp.path().join(".env.test");
std::fs::write(
&env_path,
"BROKEN=\"unterminated\nOAUTH_TWITTER_CLIENT_ID=client123\n",
)
.unwrap();
let old_no_keychain = std::env::var("AIDAEMON_NO_KEYCHAIN").ok();
let old_env_file = std::env::var("AIDAEMON_ENV_FILE").ok();
let old_client_id = std::env::var("OAUTH_TWITTER_CLIENT_ID").ok();
std::env::set_var("AIDAEMON_NO_KEYCHAIN", "1");
std::env::set_var("AIDAEMON_ENV_FILE", env_path.to_string_lossy().to_string());
std::env::remove_var("OAUTH_TWITTER_CLIENT_ID");
let value = resolve_from_keychain("oauth_twitter_client_id")
.expect("resolve client id despite malformed env line");
assert_eq!(value, "client123");
restore_env_var("AIDAEMON_NO_KEYCHAIN", old_no_keychain);
restore_env_var("AIDAEMON_ENV_FILE", old_env_file);
restore_env_var("OAUTH_TWITTER_CLIENT_ID", old_client_id);
}
#[test]
#[serial_test::serial(env_vars)]
fn resolve_env_file_path_prefers_runtime_resolved_path() {
let _guard = KEYCHAIN_ENV_FILE_LOCK.lock().unwrap();
let old_runtime_env_file = std::env::var(crate::RUNTIME_ENV_FILE_ENV_KEY).ok();
let old_env_file = std::env::var("AIDAEMON_ENV_FILE").ok();
std::env::set_var(crate::RUNTIME_ENV_FILE_ENV_KEY, "/daemon/root/.env");
std::env::set_var("AIDAEMON_ENV_FILE", "/some/other/.env");
assert_eq!(resolve_env_file_path(), PathBuf::from("/daemon/root/.env"));
restore_env_var(crate::RUNTIME_ENV_FILE_ENV_KEY, old_runtime_env_file);
restore_env_var("AIDAEMON_ENV_FILE", old_env_file);
}
#[test]
#[serial_test::serial(env_vars)]
fn expand_env_vars_reports_all_missing() {
let _guard = ENV_EXPANSION_LOCK.lock().unwrap();
std::env::remove_var("AIDAEMON_MISS_X");
std::env::remove_var("AIDAEMON_MISS_Y");
let result = expand_env_vars("a = \"${AIDAEMON_MISS_X}\"\nb = \"${AIDAEMON_MISS_Y}\"");
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("AIDAEMON_MISS_X"));
assert!(msg.contains("AIDAEMON_MISS_Y"));
}
#[test]
#[serial_test::serial(env_vars)]
fn terminal_web_app_url_defaults_to_hosted_terminal() {
let _guard = TERMINAL_WEB_APP_URL_ENV_LOCK.lock().unwrap();
let old = std::env::var("AIDAEMON_TERMINAL_WEB_APP_URL").ok();
std::env::remove_var("AIDAEMON_TERMINAL_WEB_APP_URL");
let cfg = TerminalConfig::default();
assert_eq!(cfg.effective_web_app_url(), "https://terminal.aidaemon.ai");
if let Some(old) = old {
std::env::set_var("AIDAEMON_TERMINAL_WEB_APP_URL", old);
}
}
#[test]
#[serial_test::serial(env_vars)]
fn terminal_web_app_url_env_override_wins() {
let _guard = TERMINAL_WEB_APP_URL_ENV_LOCK.lock().unwrap();
let old = std::env::var("AIDAEMON_TERMINAL_WEB_APP_URL").ok();
std::env::set_var(
"AIDAEMON_TERMINAL_WEB_APP_URL",
"https://terminal.dev.aidaemon.ai",
);
let cfg = TerminalConfig {
web_app_url: "https://terminal.aidaemon.ai".to_string(),
..TerminalConfig::default()
};
assert_eq!(
cfg.effective_web_app_url(),
"https://terminal.dev.aidaemon.ai"
);
if let Some(old) = old {
std::env::set_var("AIDAEMON_TERMINAL_WEB_APP_URL", old);
} else {
std::env::remove_var("AIDAEMON_TERMINAL_WEB_APP_URL");
}
}
#[test]
#[serial_test::serial(env_vars)]
fn terminal_bridge_enabled_defaults_to_true() {
let _guard = TERMINAL_BRIDGE_ENABLED_ENV_LOCK.lock().unwrap();
let old = std::env::var("AIDAEMON_TERMINAL_BRIDGE_ENABLED").ok();
std::env::remove_var("AIDAEMON_TERMINAL_BRIDGE_ENABLED");
let cfg = TerminalConfig::default();
assert!(cfg.effective_bridge_enabled());
if let Some(old) = old {
std::env::set_var("AIDAEMON_TERMINAL_BRIDGE_ENABLED", old);
}
}
#[test]
#[serial_test::serial(env_vars)]
fn terminal_bridge_enabled_env_override_wins() {
let _guard = TERMINAL_BRIDGE_ENABLED_ENV_LOCK.lock().unwrap();
let old = std::env::var("AIDAEMON_TERMINAL_BRIDGE_ENABLED").ok();
std::env::set_var("AIDAEMON_TERMINAL_BRIDGE_ENABLED", "false");
let cfg = TerminalConfig {
bridge_enabled: true,
..TerminalConfig::default()
};
assert!(!cfg.effective_bridge_enabled());
if let Some(old) = old {
std::env::set_var("AIDAEMON_TERMINAL_BRIDGE_ENABLED", old);
} else {
std::env::remove_var("AIDAEMON_TERMINAL_BRIDGE_ENABLED");
}
}
#[test]
#[serial_test::serial(env_vars)]
fn terminal_static_fallback_defaults_to_disabled() {
let _guard = TERMINAL_STATIC_FALLBACK_ENV_LOCK.lock().unwrap();
let old = std::env::var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK").ok();
std::env::remove_var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK");
let cfg = TerminalConfig::default();
assert!(!cfg.effective_allow_static_token_fallback());
if let Some(old) = old {
std::env::set_var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK", old);
}
}
#[test]
#[serial_test::serial(env_vars)]
fn terminal_static_fallback_env_override_wins() {
let _guard = TERMINAL_STATIC_FALLBACK_ENV_LOCK.lock().unwrap();
let old = std::env::var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK").ok();
std::env::set_var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK", "true");
let cfg = TerminalConfig {
allow_static_token_fallback: false,
..TerminalConfig::default()
};
assert!(cfg.effective_allow_static_token_fallback());
if let Some(old) = old {
std::env::set_var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK", old);
} else {
std::env::remove_var("AIDAEMON_TERMINAL_ALLOW_STATIC_FALLBACK");
}
}
#[test]
fn provider_gateway_token_defaults_to_none() {
let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "test-key"
[provider.models]
primary = "gpt-4o"
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
assert_eq!(cfg.provider.gateway_token, None);
}
#[test]
fn provider_gateway_token_parses_when_set() {
let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "test-key"
gateway_token = "cf-gw-token"
[provider.models]
primary = "gpt-4o"
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
assert_eq!(cfg.provider.gateway_token.as_deref(), Some("cf-gw-token"));
}
#[test]
fn provider_extra_headers_parse_when_set() {
let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "test-key"
extra_headers = { "x-app" = "aidaemon", "x-env" = "dev" }
[provider.models]
primary = "gpt-4o"
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
let headers = cfg.provider.extra_headers.expect("extra headers");
assert_eq!(headers.get("x-app"), Some(&"aidaemon".to_string()));
assert_eq!(headers.get("x-env"), Some(&"dev".to_string()));
}
#[test]
fn provider_max_tokens_parses_when_set() {
let toml = r#"
[provider]
kind = "anthropic"
api_key = "test-key"
max_tokens = 32768
[provider.models]
primary = "claude-sonnet-4-20250514"
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
assert_eq!(cfg.provider.max_tokens, Some(32768));
}
#[test]
fn provider_kind_parses_xai_native() {
let toml = r#"
[provider]
kind = "xai_native"
api_key = "test-key"
[provider.models]
primary = "grok-4"
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
assert_eq!(cfg.provider.kind, ProviderKind::XaiNative);
}
#[test]
fn provider_models_support_default_and_fallback_keys() {
let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "test-key"
[provider.models]
default = "kimi-k2.5"
fallback = ["mistral-nemo", "gpt-4o-mini"]
"#;
let mut cfg: AppConfig = toml::from_str(toml).expect("parse app config");
cfg.provider.models.apply_defaults(&cfg.provider.kind);
assert_eq!(cfg.provider.models.default_model, "kimi-k2.5");
assert_eq!(
cfg.provider.models.fallback_models,
vec!["mistral-nemo".to_string(), "gpt-4o-mini".to_string()]
);
assert_eq!(cfg.provider.models.primary, "kimi-k2.5");
}
#[test]
fn provider_models_legacy_keys_derive_default_and_fallback() {
let mut models = ModelsConfig {
default_model: String::new(),
fallback_models: Vec::new(),
primary: "primary-model".to_string(),
fast: "fast-model".to_string(),
smart: "smart-model".to_string(),
};
models.apply_defaults(&ProviderKind::OpenaiCompatible);
assert_eq!(models.default_model, "primary-model");
assert_eq!(
models.fallback_models,
vec!["smart-model".to_string(), "fast-model".to_string()]
);
}
#[test]
fn provider_fallback_configs_parse() {
let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "primary-key"
[provider.models]
default = "primary-model"
fallback = ["primary-backup"]
[[provider.fallbacks]]
kind = "anthropic"
api_key = "secondary-key"
[provider.fallbacks.models]
default = "claude-sonnet-4-20250514"
fallback = ["claude-3-5-haiku-latest"]
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
assert_eq!(cfg.provider.fallbacks.len(), 1);
assert_eq!(cfg.provider.fallbacks[0].kind, ProviderKind::Anthropic);
assert_eq!(cfg.provider.fallbacks[0].api_key, "secondary-key");
assert_eq!(
cfg.provider.fallbacks[0].models.default_model,
"claude-sonnet-4-20250514"
);
}
#[test]
fn provider_failover_alias_still_parses() {
let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "primary-key"
[provider.models]
default = "primary-model"
[[provider.failover]]
kind = "xai_native"
api_key = "secondary-key"
[provider.failover.models]
default = "grok-4"
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
assert_eq!(cfg.provider.fallbacks.len(), 1);
assert_eq!(cfg.provider.fallbacks[0].kind, ProviderKind::XaiNative);
assert_eq!(cfg.provider.fallbacks[0].models.default_model, "grok-4");
}
#[test]
fn provider_fallback_model_defaults_apply_recursively() {
let toml = r#"
[provider]
kind = "openai_compatible"
api_key = "primary-key"
[provider.models]
primary = "primary-model"
fast = "primary-fast"
[[provider.fallbacks]]
kind = "anthropic"
api_key = "secondary-key"
[provider.fallbacks.models]
primary = "claude-sonnet-4-20250514"
smart = "claude-3-5-haiku-latest"
"#;
let mut cfg: AppConfig = toml::from_str(toml).expect("parse app config");
cfg.provider.apply_model_defaults_recursive();
assert_eq!(cfg.provider.models.default_model, "primary-model");
assert_eq!(
cfg.provider.models.fallback_models,
vec!["primary-fast".to_string()]
);
assert_eq!(
cfg.provider.fallbacks[0].models.default_model,
"claude-sonnet-4-20250514"
);
assert_eq!(
cfg.provider.fallbacks[0].models.fallback_models,
vec!["claude-3-5-haiku-latest".to_string()]
);
}
#[test]
fn iteration_limit_default_is_unlimited() {
let config = SubagentsConfig::default();
assert!(matches!(
config.iteration_limit,
IterationLimitConfig::Unlimited
));
}
#[test]
fn iteration_limit_effective_returns_unlimited_when_defaults_unchanged() {
let config = SubagentsConfig::default();
assert!(matches!(
config.effective_iteration_limit(),
IterationLimitConfig::Unlimited
));
}
#[test]
fn iteration_limit_effective_migrates_legacy_config() {
let config = SubagentsConfig {
max_iterations: 15,
max_iterations_cap: 30,
..SubagentsConfig::default()
};
match config.effective_iteration_limit() {
IterationLimitConfig::Hard { initial, cap } => {
assert_eq!(initial, 15);
assert_eq!(cap, 30);
}
_ => panic!("Expected Hard mode migration"),
}
}
#[test]
fn iteration_limit_explicit_soft_preserved() {
let config = SubagentsConfig {
iteration_limit: IterationLimitConfig::Soft {
threshold: 50,
warn_at: 40,
},
max_iterations: 15,
max_iterations_cap: 30,
..SubagentsConfig::default()
};
match config.effective_iteration_limit() {
IterationLimitConfig::Soft { threshold, warn_at } => {
assert_eq!(threshold, 50);
assert_eq!(warn_at, 40);
}
_ => panic!("Expected Soft mode to be preserved"),
}
}
#[test]
fn task_timeout_default_is_30_minutes() {
let config = SubagentsConfig::default();
assert_eq!(config.task_timeout_secs, Some(1800));
}
#[test]
fn write_consistency_policy_defaults_are_applied() {
let policy = PolicyConfig::default();
assert_eq!(policy.write_consistency.max_abs_global_delta, 3);
assert_eq!(policy.write_consistency.max_session_mismatch_count, 0);
assert_eq!(policy.write_consistency.max_stale_task_starts, 0);
assert_eq!(policy.write_consistency.max_missing_message_id_events, 0);
}
#[test]
fn write_consistency_policy_can_be_overridden_in_toml() {
let toml = r#"
[provider]
api_key = "test-key"
kind = "openai_compatible"
[provider.models]
primary = "gpt-4o"
fast = "gpt-4o-mini"
smart = "gpt-4o"
[terminal]
allowed_prefixes = ["ls"]
[policy.write_consistency]
max_abs_global_delta = 7
max_session_mismatch_count = 2
max_stale_task_starts = 1
max_missing_message_id_events = 4
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
assert_eq!(cfg.policy.write_consistency.max_abs_global_delta, 7);
assert_eq!(cfg.policy.write_consistency.max_session_mismatch_count, 2);
assert_eq!(cfg.policy.write_consistency.max_stale_task_starts, 1);
assert_eq!(
cfg.policy.write_consistency.max_missing_message_id_events,
4
);
}
#[test]
fn queue_policy_normalizes_invalid_values() {
let policy = QueuePolicyConfig {
approval_capacity: 0,
media_capacity: 0,
trigger_event_capacity: 0,
warning_ratio: f32::NAN,
overload_ratio: f32::INFINITY,
lanes: QueueLanePoliciesConfig::default(),
adaptive_shedding: Some(true),
fair_trigger_sessions: Some(true),
fair_trigger_session_window_secs: Some(0),
fair_trigger_max_events_per_session: Some(0),
};
let normalized = policy.normalized();
assert_eq!(normalized.approval_capacity, 1);
assert_eq!(normalized.media_capacity, 1);
assert_eq!(normalized.trigger_event_capacity, 1);
assert_eq!(normalized.warning_ratio, 0.75);
assert_eq!(normalized.overload_ratio, 0.90);
assert_eq!(normalized.lanes.approval.fair_session_window_secs, 1);
assert_eq!(normalized.lanes.media.fair_session_window_secs, 1);
assert_eq!(normalized.lanes.trigger.fair_session_window_secs, 1);
assert_eq!(normalized.lanes.approval.fair_max_events_per_session, 1);
assert_eq!(normalized.lanes.media.fair_max_events_per_session, 1);
assert_eq!(normalized.lanes.trigger.fair_max_events_per_session, 1);
}
#[test]
fn queue_policy_legacy_shared_knobs_apply_to_all_lanes() {
let policy = QueuePolicyConfig {
adaptive_shedding: Some(false),
fair_trigger_sessions: Some(false),
fair_trigger_session_window_secs: Some(13),
fair_trigger_max_events_per_session: Some(2),
..QueuePolicyConfig::default()
};
let normalized = policy.normalized();
for lane in [
&normalized.lanes.approval,
&normalized.lanes.media,
&normalized.lanes.trigger,
] {
assert!(!lane.adaptive_shedding);
assert!(!lane.fair_sessions);
assert_eq!(lane.fair_session_window_secs, 13);
assert_eq!(lane.fair_max_events_per_session, 2);
}
}
#[test]
fn queue_policy_lane_specific_overrides_take_precedence() {
let mut policy = QueuePolicyConfig {
adaptive_shedding: Some(false),
fair_trigger_sessions: Some(false),
fair_trigger_session_window_secs: Some(13),
fair_trigger_max_events_per_session: Some(2),
..QueuePolicyConfig::default()
};
policy.lanes.media = QueueLanePolicyConfig {
adaptive_shedding: true,
fair_sessions: true,
fair_session_window_secs: 99,
fair_max_events_per_session: 9,
};
let normalized = policy.normalized();
assert!(!normalized.lanes.approval.adaptive_shedding);
assert!(!normalized.lanes.trigger.adaptive_shedding);
assert_eq!(normalized.lanes.approval.fair_session_window_secs, 13);
assert_eq!(normalized.lanes.trigger.fair_session_window_secs, 13);
assert!(normalized.lanes.media.adaptive_shedding);
assert!(normalized.lanes.media.fair_sessions);
assert_eq!(normalized.lanes.media.fair_session_window_secs, 99);
assert_eq!(normalized.lanes.media.fair_max_events_per_session, 9);
}
#[test]
fn queue_policy_lane_specific_toml_is_supported() {
let toml = r#"
[provider]
api_key = "test-key"
kind = "openai_compatible"
[provider.models]
primary = "gpt-4o"
fast = "gpt-4o-mini"
smart = "gpt-4o"
[terminal]
allowed_prefixes = ["ls"]
[daemon.queue_policy.lanes.approval]
adaptive_shedding = false
fair_sessions = false
fair_session_window_secs = 45
fair_max_events_per_session = 3
[daemon.queue_policy.lanes.media]
adaptive_shedding = true
fair_sessions = true
fair_session_window_secs = 20
fair_max_events_per_session = 2
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
let queue = cfg.daemon.queue_policy.normalized();
assert!(!queue.lanes.approval.adaptive_shedding);
assert_eq!(queue.lanes.approval.fair_session_window_secs, 45);
assert_eq!(queue.lanes.approval.fair_max_events_per_session, 3);
assert!(queue.lanes.media.adaptive_shedding);
assert_eq!(queue.lanes.media.fair_session_window_secs, 20);
assert_eq!(queue.lanes.media.fair_max_events_per_session, 2);
}
#[test]
fn all_telegram_bots_merges_legacy_and_preserves_webhook_config() {
let toml = r#"
[provider]
api_key = "test-key"
kind = "openai_compatible"
[provider.models]
primary = "gpt-4o"
fast = "gpt-4o-mini"
smart = "gpt-4o"
[terminal]
allowed_prefixes = ["ls"]
[[telegram_bots]]
bot_token = "array-token"
allowed_user_ids = [111]
[telegram_bots.webhook]
enabled = true
public_url = "https://array.example.com/hook"
listen_addr = "0.0.0.0:8443"
path = "/array"
max_connections = 42
drop_pending_updates = true
[telegram]
bot_token = "legacy-token"
allowed_user_ids = [222]
[telegram.webhook]
enabled = true
public_url = "https://legacy.example.com/hook"
listen_addr = "127.0.0.1:9443"
path = "/legacy"
max_connections = 21
drop_pending_updates = false
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
let bots = cfg.all_telegram_bots();
assert_eq!(bots.len(), 2);
assert_eq!(bots[0].bot_token, "array-token");
assert_eq!(bots[0].allowed_user_ids, vec![111]);
assert!(bots[0].webhook.enabled);
assert_eq!(
bots[0].webhook.public_url.as_deref(),
Some("https://array.example.com/hook")
);
assert_eq!(bots[0].webhook.listen_addr.as_deref(), Some("0.0.0.0:8443"));
assert_eq!(bots[0].webhook.path.as_deref(), Some("/array"));
assert_eq!(bots[0].webhook.max_connections, Some(42));
assert!(bots[0].webhook.drop_pending_updates);
assert_eq!(bots[1].bot_token, "legacy-token");
assert_eq!(bots[1].allowed_user_ids, vec![222]);
assert!(bots[1].webhook.enabled);
assert_eq!(
bots[1].webhook.public_url.as_deref(),
Some("https://legacy.example.com/hook")
);
assert_eq!(
bots[1].webhook.listen_addr.as_deref(),
Some("127.0.0.1:9443")
);
assert_eq!(bots[1].webhook.path.as_deref(), Some("/legacy"));
assert_eq!(bots[1].webhook.max_connections, Some(21));
assert!(!bots[1].webhook.drop_pending_updates);
}
#[test]
fn telegram_webhook_defaults_to_disabled_when_omitted() {
let toml = r#"
[provider]
api_key = "test-key"
kind = "openai_compatible"
[provider.models]
primary = "gpt-4o"
fast = "gpt-4o-mini"
smart = "gpt-4o"
[terminal]
allowed_prefixes = ["ls"]
[telegram]
bot_token = "legacy-token"
allowed_user_ids = [333]
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
let bots = cfg.all_telegram_bots();
assert_eq!(bots.len(), 1);
assert_eq!(bots[0].bot_token, "legacy-token");
assert!(!bots[0].webhook.enabled);
assert_eq!(bots[0].webhook.public_url, None);
assert_eq!(bots[0].webhook.listen_addr, None);
assert_eq!(bots[0].webhook.path, None);
assert_eq!(bots[0].webhook.max_connections, None);
assert!(!bots[0].webhook.drop_pending_updates);
}
#[cfg(feature = "slack")]
#[test]
fn all_slack_bots_merges_legacy_without_enabled_gate() {
let toml = r#"
[provider]
api_key = "test-key"
kind = "openai_compatible"
[provider.models]
primary = "gpt-4o"
fast = "gpt-4o-mini"
smart = "gpt-4o"
[terminal]
allowed_prefixes = ["ls"]
[slack]
enabled = false
app_token = "xapp-123"
bot_token = "xoxb-456"
allowed_user_ids = ["U123"]
"#;
let cfg: AppConfig = toml::from_str(toml).expect("parse app config");
let bots = cfg.all_slack_bots();
assert_eq!(bots.len(), 1);
assert_eq!(bots[0].app_token, "xapp-123");
assert_eq!(bots[0].bot_token, "xoxb-456");
assert_eq!(bots[0].allowed_user_ids, vec!["U123"]);
}
fn ephemeral_browser_config(isolation: Option<SessionIsolation>) -> BrowserConfig {
BrowserConfig {
enabled: true,
headless: true,
screenshot_width: 1280,
screenshot_height: 720,
remote_debugging_port: None,
user_data_dir: None,
profile: None,
session_isolation: isolation,
..BrowserConfig::default()
}
}
#[test]
fn parse_session_isolation_page() {
let toml = r#"
enabled = true
user_data_dir = "/tmp/profile"
session_isolation = "page"
"#;
let cfg: BrowserConfig = toml::from_str(toml).expect("parse browser config");
assert_eq!(cfg.session_isolation, Some(SessionIsolation::Page));
}
#[test]
fn parse_session_isolation_browser_context() {
let toml = r#"
enabled = true
session_isolation = "browser_context"
"#;
let cfg: BrowserConfig = toml::from_str(toml).expect("parse browser config");
assert_eq!(
cfg.session_isolation,
Some(SessionIsolation::BrowserContext)
);
}
#[test]
fn parse_session_isolation_omitted_is_none() {
let toml = r#"
enabled = true
"#;
let cfg: BrowserConfig = toml::from_str(toml).expect("parse browser config");
assert_eq!(cfg.session_isolation, None);
}
#[test]
fn parse_session_isolation_invalid_value_rejected() {
let toml = r#"
enabled = true
session_isolation = "incognito"
"#;
let result: Result<BrowserConfig, _> = toml::from_str(toml);
assert!(
result.is_err(),
"unknown session_isolation variant should fail to parse"
);
}
#[test]
fn browser_timeout_defaults_are_sane() {
let cfg = BrowserConfig::default();
assert_eq!(cfg.nav_timeout(), std::time::Duration::from_secs(30));
assert_eq!(cfg.element_timeout(), std::time::Duration::from_secs(10));
assert_eq!(cfg.action_timeout(), std::time::Duration::from_secs(30));
}
#[test]
fn browser_timeouts_clamp_out_of_range_values() {
let mut cfg = BrowserConfig::default();
cfg.nav_timeout_secs = 0;
cfg.element_timeout_secs = 0;
cfg.action_timeout_secs = 0;
assert_eq!(cfg.nav_timeout(), std::time::Duration::from_secs(1));
assert_eq!(cfg.element_timeout(), std::time::Duration::from_secs(1));
assert_eq!(cfg.action_timeout(), std::time::Duration::from_secs(1));
cfg.nav_timeout_secs = 1_000_000;
cfg.element_timeout_secs = 1_000_000;
cfg.action_timeout_secs = 1_000_000;
assert_eq!(cfg.nav_timeout(), std::time::Duration::from_secs(120));
assert_eq!(cfg.element_timeout(), std::time::Duration::from_secs(120));
assert_eq!(cfg.action_timeout(), std::time::Duration::from_secs(300));
}
#[test]
fn browser_timeouts_in_range_pass_through() {
let mut cfg = BrowserConfig::default();
cfg.nav_timeout_secs = 45;
cfg.element_timeout_secs = 5;
cfg.action_timeout_secs = 90;
assert_eq!(cfg.nav_timeout(), std::time::Duration::from_secs(45));
assert_eq!(cfg.element_timeout(), std::time::Duration::from_secs(5));
assert_eq!(cfg.action_timeout(), std::time::Duration::from_secs(90));
}
#[test]
fn resolve_auto_ephemeral_is_browser_context() {
let cfg = ephemeral_browser_config(None);
assert_eq!(
cfg.resolve_session_isolation(),
Ok(SessionIsolation::BrowserContext)
);
}
#[test]
fn resolve_auto_with_profile_is_page() {
let mut cfg = ephemeral_browser_config(None);
cfg.user_data_dir = Some("/tmp/profile".to_string());
assert_eq!(cfg.resolve_session_isolation(), Ok(SessionIsolation::Page));
}
#[test]
fn resolve_auto_with_remote_port_is_page() {
let mut cfg = ephemeral_browser_config(None);
cfg.remote_debugging_port = Some(9222);
assert_eq!(cfg.resolve_session_isolation(), Ok(SessionIsolation::Page));
}
#[test]
fn resolve_page_with_profile_is_ok_page() {
let mut cfg = ephemeral_browser_config(Some(SessionIsolation::Page));
cfg.user_data_dir = Some("/tmp/profile".to_string());
assert_eq!(cfg.resolve_session_isolation(), Ok(SessionIsolation::Page));
}
#[test]
fn resolve_browser_context_ephemeral_is_ok() {
let cfg = ephemeral_browser_config(Some(SessionIsolation::BrowserContext));
assert_eq!(
cfg.resolve_session_isolation(),
Ok(SessionIsolation::BrowserContext)
);
}
#[test]
fn resolve_browser_context_with_profile_is_err() {
let mut cfg = ephemeral_browser_config(Some(SessionIsolation::BrowserContext));
cfg.user_data_dir = Some("/tmp/profile".to_string());
let err = cfg
.resolve_session_isolation()
.expect_err("browser_context + persistent profile must be rejected");
assert!(
err.contains("browser_context") && err.contains("user_data_dir"),
"error should explain the incompatibility: {err}"
);
}
#[test]
fn resolve_browser_context_with_remote_port_is_err() {
let mut cfg = ephemeral_browser_config(Some(SessionIsolation::BrowserContext));
cfg.remote_debugging_port = Some(9222);
let err = cfg
.resolve_session_isolation()
.expect_err("browser_context + remote-debugging Chrome must be rejected");
assert!(
err.contains("browser_context") && err.contains("remote_debugging_port"),
"error should explain the incompatibility: {err}"
);
}
#[test]
fn shipped_default_config_resolves_without_error() {
let cfg = BrowserConfig::default();
assert_eq!(cfg.session_isolation, None);
assert_eq!(cfg.resolve_session_isolation(), Ok(SessionIsolation::Page));
}
}