mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-30 16:19:21 +00:00
Merge origin/main into feat/gemini-cli-oauth and resolve conflicts
This commit is contained in:
@@ -27,9 +27,32 @@ pub struct AgentConfig {
|
||||
pub max_tool_iterations: usize,
|
||||
/// When true, skip tool approval checks entirely. For benchmarks/CI.
|
||||
pub auto_approve_tools: bool,
|
||||
/// Default timezone for new sessions (IANA name, e.g. "America/New_York").
|
||||
pub default_timezone: String,
|
||||
}
|
||||
|
||||
impl AgentConfig {
|
||||
/// Create a test-friendly config without reading env vars.
|
||||
#[cfg(feature = "libsql")]
|
||||
pub fn for_testing() -> Self {
|
||||
Self {
|
||||
name: "test-rig".to_string(),
|
||||
max_parallel_jobs: 1,
|
||||
job_timeout: Duration::from_secs(30),
|
||||
stuck_threshold: Duration::from_secs(300),
|
||||
repair_check_interval: Duration::from_secs(3600),
|
||||
max_repair_attempts: 0,
|
||||
use_planning: false,
|
||||
session_idle_timeout: Duration::from_secs(3600),
|
||||
allow_local_tools: true,
|
||||
max_cost_per_day_cents: None,
|
||||
max_actions_per_hour: None,
|
||||
max_tool_iterations: 10,
|
||||
auto_approve_tools: true,
|
||||
default_timezone: "UTC".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
name: parse_optional_env("AGENT_NAME", settings.agent.name.clone())?,
|
||||
@@ -69,6 +92,40 @@ impl AgentConfig {
|
||||
"AGENT_AUTO_APPROVE_TOOLS",
|
||||
settings.agent.auto_approve_tools,
|
||||
)?,
|
||||
default_timezone: {
|
||||
let tz: String = parse_optional_env(
|
||||
"DEFAULT_TIMEZONE",
|
||||
settings.agent.default_timezone.clone(),
|
||||
)?;
|
||||
if crate::timezone::parse_timezone(&tz).is_none() {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "DEFAULT_TIMEZONE".into(),
|
||||
message: format!("invalid IANA timezone: '{tz}'"),
|
||||
});
|
||||
}
|
||||
tz
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_timezone_rejects_invalid() {
|
||||
let mut settings = Settings::default();
|
||||
settings.agent.default_timezone = "Fake/Zone".to_string();
|
||||
|
||||
let result = AgentConfig::resolve(&settings);
|
||||
assert!(result.is_err(), "invalid IANA timezone should be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_timezone_accepts_valid() {
|
||||
let settings = Settings::default(); // default is "UTC"
|
||||
let config = AgentConfig::resolve(&settings).expect("resolve");
|
||||
assert_eq!(config.default_timezone, "UTC");
|
||||
}
|
||||
}
|
||||
|
||||
+177
-10
@@ -1,3 +1,4 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use secrecy::SecretString;
|
||||
@@ -18,8 +19,9 @@ pub struct ChannelsConfig {
|
||||
pub wasm_channels_dir: std::path::PathBuf,
|
||||
/// Whether WASM channels are enabled.
|
||||
pub wasm_channels_enabled: bool,
|
||||
/// Telegram owner user ID. When set, the bot only responds to this user.
|
||||
pub telegram_owner_id: Option<i64>,
|
||||
/// Per-channel owner user IDs. When set, the channel only responds to this user.
|
||||
/// Key: channel name (e.g., "telegram"), Value: owner user ID.
|
||||
pub wasm_channel_owner_ids: HashMap<String, i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -180,14 +182,20 @@ impl ChannelsConfig {
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(default_channels_dir),
|
||||
wasm_channels_enabled: parse_bool_env("WASM_CHANNELS_ENABLED", true)?,
|
||||
telegram_owner_id: optional_env("TELEGRAM_OWNER_ID")?
|
||||
.map(|s| s.parse())
|
||||
.transpose()
|
||||
.map_err(|e: std::num::ParseIntError| ConfigError::InvalidValue {
|
||||
key: "TELEGRAM_OWNER_ID".to_string(),
|
||||
message: format!("must be an integer: {e}"),
|
||||
})?
|
||||
.or(settings.channels.telegram_owner_id),
|
||||
wasm_channel_owner_ids: {
|
||||
let mut ids = settings.channels.wasm_channel_owner_ids.clone();
|
||||
// Backwards compat: TELEGRAM_OWNER_ID env var
|
||||
if let Some(id_str) = optional_env("TELEGRAM_OWNER_ID")? {
|
||||
let id: i64 = id_str.parse().map_err(|e: std::num::ParseIntError| {
|
||||
ConfigError::InvalidValue {
|
||||
key: "TELEGRAM_OWNER_ID".to_string(),
|
||||
message: format!("must be an integer: {e}"),
|
||||
}
|
||||
})?;
|
||||
ids.insert("telegram".to_string(), id);
|
||||
}
|
||||
ids
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -196,3 +204,162 @@ impl ChannelsConfig {
|
||||
fn default_channels_dir() -> PathBuf {
|
||||
ironclaw_base_dir().join("channels")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::channels::*;
|
||||
|
||||
#[test]
|
||||
fn cli_config_fields() {
|
||||
let cfg = CliConfig { enabled: true };
|
||||
assert!(cfg.enabled);
|
||||
|
||||
let disabled = CliConfig { enabled: false };
|
||||
assert!(!disabled.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_config_fields() {
|
||||
let cfg = HttpConfig {
|
||||
host: "0.0.0.0".to_string(),
|
||||
port: 8080,
|
||||
webhook_secret: None,
|
||||
user_id: "http".to_string(),
|
||||
};
|
||||
assert_eq!(cfg.host, "0.0.0.0");
|
||||
assert_eq!(cfg.port, 8080);
|
||||
assert!(cfg.webhook_secret.is_none());
|
||||
assert_eq!(cfg.user_id, "http");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn http_config_with_secret() {
|
||||
let cfg = HttpConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 9090,
|
||||
webhook_secret: Some(secrecy::SecretString::from("s3cret".to_string())),
|
||||
user_id: "webhook-bot".to_string(),
|
||||
};
|
||||
assert!(cfg.webhook_secret.is_some());
|
||||
assert_eq!(cfg.port, 9090);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_config_fields() {
|
||||
let cfg = GatewayConfig {
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 3000,
|
||||
auth_token: Some("tok-abc".to_string()),
|
||||
user_id: "default".to_string(),
|
||||
};
|
||||
assert_eq!(cfg.host, "127.0.0.1");
|
||||
assert_eq!(cfg.port, 3000);
|
||||
assert_eq!(cfg.auth_token.as_deref(), Some("tok-abc"));
|
||||
assert_eq!(cfg.user_id, "default");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gateway_config_no_auth_token() {
|
||||
let cfg = GatewayConfig {
|
||||
host: "0.0.0.0".to_string(),
|
||||
port: 3001,
|
||||
auth_token: None,
|
||||
user_id: "anon".to_string(),
|
||||
};
|
||||
assert!(cfg.auth_token.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_config_fields_and_defaults() {
|
||||
let cfg = SignalConfig {
|
||||
http_url: "http://127.0.0.1:8080".to_string(),
|
||||
account: "+1234567890".to_string(),
|
||||
allow_from: vec!["+1234567890".to_string()],
|
||||
allow_from_groups: vec![],
|
||||
dm_policy: "pairing".to_string(),
|
||||
group_policy: "allowlist".to_string(),
|
||||
group_allow_from: vec![],
|
||||
ignore_attachments: false,
|
||||
ignore_stories: true,
|
||||
};
|
||||
assert_eq!(cfg.http_url, "http://127.0.0.1:8080");
|
||||
assert_eq!(cfg.account, "+1234567890");
|
||||
assert_eq!(cfg.allow_from, vec!["+1234567890"]);
|
||||
assert!(cfg.allow_from_groups.is_empty());
|
||||
assert_eq!(cfg.dm_policy, "pairing");
|
||||
assert_eq!(cfg.group_policy, "allowlist");
|
||||
assert!(cfg.group_allow_from.is_empty());
|
||||
assert!(!cfg.ignore_attachments);
|
||||
assert!(cfg.ignore_stories);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn signal_config_open_policies() {
|
||||
let cfg = SignalConfig {
|
||||
http_url: "http://localhost:7583".to_string(),
|
||||
account: "+0000000000".to_string(),
|
||||
allow_from: vec!["*".to_string()],
|
||||
allow_from_groups: vec!["*".to_string()],
|
||||
dm_policy: "open".to_string(),
|
||||
group_policy: "open".to_string(),
|
||||
group_allow_from: vec![],
|
||||
ignore_attachments: true,
|
||||
ignore_stories: false,
|
||||
};
|
||||
assert_eq!(cfg.allow_from, vec!["*"]);
|
||||
assert_eq!(cfg.allow_from_groups, vec!["*"]);
|
||||
assert_eq!(cfg.dm_policy, "open");
|
||||
assert_eq!(cfg.group_policy, "open");
|
||||
assert!(cfg.ignore_attachments);
|
||||
assert!(!cfg.ignore_stories);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_config_fields() {
|
||||
let cfg = ChannelsConfig {
|
||||
cli: CliConfig { enabled: true },
|
||||
http: None,
|
||||
gateway: None,
|
||||
signal: None,
|
||||
wasm_channels_dir: PathBuf::from("/tmp/channels"),
|
||||
wasm_channels_enabled: true,
|
||||
wasm_channel_owner_ids: HashMap::new(),
|
||||
};
|
||||
assert!(cfg.cli.enabled);
|
||||
assert!(cfg.http.is_none());
|
||||
assert!(cfg.gateway.is_none());
|
||||
assert!(cfg.signal.is_none());
|
||||
assert_eq!(cfg.wasm_channels_dir, PathBuf::from("/tmp/channels"));
|
||||
assert!(cfg.wasm_channels_enabled);
|
||||
assert!(cfg.wasm_channel_owner_ids.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn channels_config_with_owner_ids() {
|
||||
let mut ids = HashMap::new();
|
||||
ids.insert("telegram".to_string(), 12345_i64);
|
||||
ids.insert("slack".to_string(), 67890_i64);
|
||||
|
||||
let cfg = ChannelsConfig {
|
||||
cli: CliConfig { enabled: false },
|
||||
http: None,
|
||||
gateway: None,
|
||||
signal: None,
|
||||
wasm_channels_dir: PathBuf::from("/opt/channels"),
|
||||
wasm_channels_enabled: false,
|
||||
wasm_channel_owner_ids: ids,
|
||||
};
|
||||
assert_eq!(cfg.wasm_channel_owner_ids.get("telegram"), Some(&12345));
|
||||
assert_eq!(cfg.wasm_channel_owner_ids.get("slack"), Some(&67890));
|
||||
assert!(!cfg.wasm_channels_enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_channels_dir_ends_with_channels() {
|
||||
let dir = default_channels_dir();
|
||||
assert!(
|
||||
dir.ends_with("channels"),
|
||||
"expected path ending in 'channels', got: {dir:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+102
-1
@@ -1,4 +1,4 @@
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_optional_env};
|
||||
use crate::config::helpers::{optional_env, parse_bool_env, parse_option_env, parse_optional_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
@@ -13,6 +13,12 @@ pub struct HeartbeatConfig {
|
||||
pub notify_channel: Option<String>,
|
||||
/// User ID to notify on heartbeat findings.
|
||||
pub notify_user: Option<String>,
|
||||
/// Hour (0-23) when quiet hours start.
|
||||
pub quiet_hours_start: Option<u32>,
|
||||
/// Hour (0-23) when quiet hours end.
|
||||
pub quiet_hours_end: Option<u32>,
|
||||
/// Timezone for quiet hours evaluation (IANA name).
|
||||
pub timezone: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for HeartbeatConfig {
|
||||
@@ -22,6 +28,9 @@ impl Default for HeartbeatConfig {
|
||||
interval_secs: 1800, // 30 minutes
|
||||
notify_channel: None,
|
||||
notify_user: None,
|
||||
quiet_hours_start: None,
|
||||
quiet_hours_end: None,
|
||||
timezone: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +47,98 @@ impl HeartbeatConfig {
|
||||
.or_else(|| settings.heartbeat.notify_channel.clone()),
|
||||
notify_user: optional_env("HEARTBEAT_NOTIFY_USER")?
|
||||
.or_else(|| settings.heartbeat.notify_user.clone()),
|
||||
quiet_hours_start: parse_option_env::<u32>("HEARTBEAT_QUIET_START")?
|
||||
.or(settings.heartbeat.quiet_hours_start)
|
||||
.map(|h| {
|
||||
if h > 23 {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "HEARTBEAT_QUIET_START".into(),
|
||||
message: "must be 0-23".into(),
|
||||
});
|
||||
}
|
||||
Ok(h)
|
||||
})
|
||||
.transpose()?,
|
||||
quiet_hours_end: parse_option_env::<u32>("HEARTBEAT_QUIET_END")?
|
||||
.or(settings.heartbeat.quiet_hours_end)
|
||||
.map(|h| {
|
||||
if h > 23 {
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "HEARTBEAT_QUIET_END".into(),
|
||||
message: "must be 0-23".into(),
|
||||
});
|
||||
}
|
||||
Ok(h)
|
||||
})
|
||||
.transpose()?,
|
||||
timezone: {
|
||||
let tz = optional_env("HEARTBEAT_TIMEZONE")?
|
||||
.or_else(|| settings.heartbeat.timezone.clone());
|
||||
if let Some(ref tz_str) = tz
|
||||
&& crate::timezone::parse_timezone(tz_str).is_none()
|
||||
{
|
||||
return Err(ConfigError::InvalidValue {
|
||||
key: "HEARTBEAT_TIMEZONE".into(),
|
||||
message: format!("invalid IANA timezone: '{tz_str}'"),
|
||||
});
|
||||
}
|
||||
tz
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_quiet_hours_settings_fallback() {
|
||||
// When env vars are not set, settings values should be used
|
||||
let mut settings = Settings::default();
|
||||
settings.heartbeat.quiet_hours_start = Some(22);
|
||||
settings.heartbeat.quiet_hours_end = Some(6);
|
||||
|
||||
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
||||
assert_eq!(config.quiet_hours_start, Some(22));
|
||||
assert_eq!(config.quiet_hours_end, Some(6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quiet_hours_rejects_invalid_hour() {
|
||||
let mut settings = Settings::default();
|
||||
settings.heartbeat.quiet_hours_start = Some(24);
|
||||
|
||||
let result = HeartbeatConfig::resolve(&settings);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_quiet_hours_accepts_boundary_values() {
|
||||
let mut settings = Settings::default();
|
||||
settings.heartbeat.quiet_hours_start = Some(0);
|
||||
settings.heartbeat.quiet_hours_end = Some(23);
|
||||
|
||||
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
||||
assert_eq!(config.quiet_hours_start, Some(0));
|
||||
assert_eq!(config.quiet_hours_end, Some(23));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heartbeat_timezone_rejects_invalid() {
|
||||
let mut settings = Settings::default();
|
||||
settings.heartbeat.timezone = Some("Fake/Zone".to_string());
|
||||
|
||||
let result = HeartbeatConfig::resolve(&settings);
|
||||
assert!(result.is_err(), "invalid IANA timezone should be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_heartbeat_timezone_accepts_valid() {
|
||||
let mut settings = Settings::default();
|
||||
settings.heartbeat.timezone = Some("America/New_York".to_string());
|
||||
|
||||
let config = HeartbeatConfig::resolve(&settings).expect("resolve");
|
||||
assert_eq!(config.timezone.as_deref(), Some("America/New_York"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,8 +25,13 @@ pub(crate) fn optional_env(key: &str) -> Result<Option<String>, ConfigError> {
|
||||
}
|
||||
|
||||
// Fall back to thread-safe overlay (secrets injected from DB)
|
||||
if let Some(val) = INJECTED_VARS.get().and_then(|map| map.get(key)) {
|
||||
return Ok(Some(val.clone()));
|
||||
if let Some(val) = INJECTED_VARS
|
||||
.lock()
|
||||
.unwrap_or_else(|p| p.into_inner())
|
||||
.get(key)
|
||||
.cloned()
|
||||
{
|
||||
return Ok(Some(val));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
|
||||
+13
-5
@@ -10,8 +10,10 @@ use crate::error::ConfigError;
|
||||
pub struct HygieneConfig {
|
||||
/// Whether hygiene is enabled. Env: `MEMORY_HYGIENE_ENABLED` (default: true).
|
||||
pub enabled: bool,
|
||||
/// Days before `daily/` documents are deleted. Env: `MEMORY_HYGIENE_RETENTION_DAYS` (default: 30).
|
||||
pub retention_days: u32,
|
||||
/// Days before `daily/` documents are deleted. Env: `MEMORY_HYGIENE_DAILY_RETENTION_DAYS` (default: 30).
|
||||
pub daily_retention_days: u32,
|
||||
/// Days before `conversations/` documents are deleted. Env: `MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS` (default: 7).
|
||||
pub conversation_retention_days: u32,
|
||||
/// Minimum hours between hygiene passes. Env: `MEMORY_HYGIENE_CADENCE_HOURS` (default: 12).
|
||||
pub cadence_hours: u32,
|
||||
}
|
||||
@@ -20,7 +22,8 @@ impl Default for HygieneConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: true,
|
||||
retention_days: 30,
|
||||
daily_retention_days: 30,
|
||||
conversation_retention_days: 7,
|
||||
cadence_hours: 12,
|
||||
}
|
||||
}
|
||||
@@ -30,7 +33,11 @@ impl HygieneConfig {
|
||||
pub(crate) fn resolve() -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
enabled: parse_bool_env("MEMORY_HYGIENE_ENABLED", true)?,
|
||||
retention_days: parse_optional_env("MEMORY_HYGIENE_RETENTION_DAYS", 30)?,
|
||||
daily_retention_days: parse_optional_env("MEMORY_HYGIENE_DAILY_RETENTION_DAYS", 30)?,
|
||||
conversation_retention_days: parse_optional_env(
|
||||
"MEMORY_HYGIENE_CONVERSATION_RETENTION_DAYS",
|
||||
7,
|
||||
)?,
|
||||
cadence_hours: parse_optional_env("MEMORY_HYGIENE_CADENCE_HOURS", 12)?,
|
||||
})
|
||||
}
|
||||
@@ -40,7 +47,8 @@ impl HygieneConfig {
|
||||
pub fn to_workspace_config(&self) -> crate::workspace::hygiene::HygieneConfig {
|
||||
crate::workspace::hygiene::HygieneConfig {
|
||||
enabled: self.enabled,
|
||||
retention_days: self.retention_days,
|
||||
daily_retention_days: self.daily_retention_days,
|
||||
conversation_retention_days: self.conversation_retention_days,
|
||||
cadence_hours: self.cadence_hours,
|
||||
state_dir: ironclaw_base_dir(),
|
||||
}
|
||||
|
||||
+754
-256
File diff suppressed because it is too large
Load Diff
+198
-10
@@ -13,17 +13,18 @@ mod embeddings;
|
||||
mod heartbeat;
|
||||
pub(crate) mod helpers;
|
||||
mod hygiene;
|
||||
mod llm;
|
||||
pub(crate) mod llm;
|
||||
mod routines;
|
||||
mod safety;
|
||||
mod sandbox;
|
||||
mod secrets;
|
||||
mod skills;
|
||||
mod transcription;
|
||||
mod tunnel;
|
||||
mod wasm;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::OnceLock;
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
@@ -37,23 +38,30 @@ pub use self::embeddings::EmbeddingsConfig;
|
||||
pub use self::heartbeat::HeartbeatConfig;
|
||||
pub use self::hygiene::HygieneConfig;
|
||||
pub use self::llm::{
|
||||
AnthropicDirectConfig, GeminiOauthConfig, LlmBackend, LlmConfig, NearAiConfig, OllamaConfig,
|
||||
OpenAiCompatibleConfig, OpenAiDirectConfig, TinfoilConfig,
|
||||
BedrockConfig, CacheRetention, GeminiOauthConfig, LlmConfig, NearAiConfig,
|
||||
RegistryProviderConfig,
|
||||
};
|
||||
pub use self::routines::RoutineConfig;
|
||||
pub use self::safety::SafetyConfig;
|
||||
pub use self::sandbox::{ClaudeCodeConfig, SandboxModeConfig};
|
||||
pub use self::secrets::SecretsConfig;
|
||||
pub use self::skills::SkillsConfig;
|
||||
pub use self::transcription::TranscriptionConfig;
|
||||
pub use self::tunnel::TunnelConfig;
|
||||
pub use self::wasm::WasmConfig;
|
||||
pub use crate::llm::session::SessionConfig;
|
||||
|
||||
/// Thread-safe overlay for injected env vars (secrets loaded from DB).
|
||||
///
|
||||
/// Used by `inject_llm_keys_from_secrets()` to make API keys available to
|
||||
/// `optional_env()` without unsafe `set_var` calls. `optional_env()` checks
|
||||
/// real env vars first, then falls back to this overlay.
|
||||
static INJECTED_VARS: OnceLock<HashMap<String, String>> = OnceLock::new();
|
||||
///
|
||||
/// Uses `Mutex<HashMap>` instead of `OnceLock` so that both
|
||||
/// `inject_os_credentials()` and `inject_llm_keys_from_secrets()` can merge
|
||||
/// their data. Whichever runs first initialises the map; the second merges in.
|
||||
static INJECTED_VARS: LazyLock<Mutex<HashMap<String, String>>> =
|
||||
LazyLock::new(|| Mutex::new(HashMap::new()));
|
||||
|
||||
/// Main configuration for the agent.
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -74,10 +82,83 @@ pub struct Config {
|
||||
pub sandbox: SandboxModeConfig,
|
||||
pub claude_code: ClaudeCodeConfig,
|
||||
pub skills: SkillsConfig,
|
||||
pub transcription: TranscriptionConfig,
|
||||
pub observability: crate::observability::ObservabilityConfig,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Create a full Config for integration tests without reading env vars.
|
||||
///
|
||||
/// Requires the `libsql` feature. Sets up:
|
||||
/// - libSQL database at the given path
|
||||
/// - WASM and embeddings disabled
|
||||
/// - Skills enabled with the given directories
|
||||
/// - Heartbeat, routines, sandbox, builder all disabled
|
||||
/// - Safety with injection check off, 100k output limit
|
||||
#[cfg(feature = "libsql")]
|
||||
pub fn for_testing(
|
||||
libsql_path: std::path::PathBuf,
|
||||
skills_dir: std::path::PathBuf,
|
||||
installed_skills_dir: std::path::PathBuf,
|
||||
) -> Self {
|
||||
Self {
|
||||
database: DatabaseConfig {
|
||||
backend: DatabaseBackend::LibSql,
|
||||
url: secrecy::SecretString::from("unused://test".to_string()),
|
||||
pool_size: 1,
|
||||
ssl_mode: SslMode::Disable,
|
||||
libsql_path: Some(libsql_path),
|
||||
libsql_url: None,
|
||||
libsql_auth_token: None,
|
||||
},
|
||||
llm: LlmConfig::for_testing(),
|
||||
embeddings: EmbeddingsConfig::default(),
|
||||
tunnel: TunnelConfig::default(),
|
||||
channels: ChannelsConfig {
|
||||
cli: CliConfig { enabled: false },
|
||||
http: None,
|
||||
gateway: None,
|
||||
signal: None,
|
||||
wasm_channels_dir: std::env::temp_dir().join("ironclaw-test-channels"),
|
||||
wasm_channels_enabled: false,
|
||||
wasm_channel_owner_ids: HashMap::new(),
|
||||
},
|
||||
agent: AgentConfig::for_testing(),
|
||||
safety: SafetyConfig {
|
||||
max_output_length: 100_000,
|
||||
injection_check_enabled: false,
|
||||
},
|
||||
wasm: WasmConfig {
|
||||
enabled: false,
|
||||
..WasmConfig::default()
|
||||
},
|
||||
secrets: SecretsConfig::default(),
|
||||
builder: BuilderModeConfig {
|
||||
enabled: false,
|
||||
..BuilderModeConfig::default()
|
||||
},
|
||||
heartbeat: HeartbeatConfig::default(),
|
||||
hygiene: HygieneConfig::default(),
|
||||
routines: RoutineConfig {
|
||||
enabled: false,
|
||||
..RoutineConfig::default()
|
||||
},
|
||||
sandbox: SandboxModeConfig {
|
||||
enabled: false,
|
||||
..SandboxModeConfig::default()
|
||||
},
|
||||
claude_code: ClaudeCodeConfig::default(),
|
||||
skills: SkillsConfig {
|
||||
enabled: true,
|
||||
local_dir: skills_dir,
|
||||
installed_dir: installed_skills_dir,
|
||||
..SkillsConfig::default()
|
||||
},
|
||||
transcription: TranscriptionConfig::default(),
|
||||
observability: crate::observability::ObservabilityConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load configuration from environment variables and the database.
|
||||
///
|
||||
/// Priority: env var > TOML config file > DB settings > default.
|
||||
@@ -179,6 +260,32 @@ impl Config {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Re-resolve only the LLM config after credential injection.
|
||||
///
|
||||
/// Called by `AppBuilder::init_secrets()` after injecting API keys into
|
||||
/// the env overlay. Only rebuilds `self.llm` — all other config fields
|
||||
/// are unaffected, preserving values from the initial config load (or
|
||||
/// from `Config::for_testing()` in test mode).
|
||||
pub async fn re_resolve_llm(
|
||||
&mut self,
|
||||
store: Option<&(dyn crate::db::SettingsStore + Sync)>,
|
||||
user_id: &str,
|
||||
toml_path: Option<&std::path::Path>,
|
||||
) -> Result<(), ConfigError> {
|
||||
let settings = if let Some(store) = store {
|
||||
let mut s = match store.get_all_settings(user_id).await {
|
||||
Ok(map) => Settings::from_db_map(&map),
|
||||
Err(_) => Settings::default(),
|
||||
};
|
||||
Self::apply_toml_overlay(&mut s, toml_path)?;
|
||||
s
|
||||
} else {
|
||||
Settings::default()
|
||||
};
|
||||
self.llm = LlmConfig::resolve(&settings)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Build config from settings (shared by from_env and from_db).
|
||||
async fn build(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
Ok(Self {
|
||||
@@ -198,6 +305,7 @@ impl Config {
|
||||
sandbox: SandboxModeConfig::resolve()?,
|
||||
claude_code: ClaudeCodeConfig::resolve()?,
|
||||
skills: SkillsConfig::resolve()?,
|
||||
transcription: TranscriptionConfig::resolve(settings)?,
|
||||
observability: crate::observability::ObservabilityConfig {
|
||||
backend: std::env::var("OBSERVABILITY_BACKEND").unwrap_or_else(|_| "none".into()),
|
||||
},
|
||||
@@ -211,17 +319,40 @@ impl Config {
|
||||
/// env-var-first resolution in `LlmConfig::resolve()`. Keys in the overlay
|
||||
/// are read by `optional_env()` before falling back to `std::env::var()`,
|
||||
/// so explicit env vars always win.
|
||||
///
|
||||
/// Also loads tokens from OS credential stores (macOS Keychain, Linux
|
||||
/// credentials files) which don't require the secrets DB.
|
||||
pub async fn inject_llm_keys_from_secrets(
|
||||
secrets: &dyn crate::secrets::SecretsStore,
|
||||
user_id: &str,
|
||||
) {
|
||||
let mappings = [
|
||||
("llm_openai_api_key", "OPENAI_API_KEY"),
|
||||
("llm_anthropic_api_key", "ANTHROPIC_API_KEY"),
|
||||
("llm_compatible_api_key", "LLM_API_KEY"),
|
||||
// Static mappings for well-known providers.
|
||||
// The registry's setup hints define secret_name -> env_var mappings,
|
||||
// so new providers added to providers.json get injection automatically.
|
||||
let mut mappings: Vec<(&str, &str)> = vec![
|
||||
("llm_nearai_api_key", "NEARAI_API_KEY"),
|
||||
("llm_anthropic_oauth_token", "ANTHROPIC_OAUTH_TOKEN"),
|
||||
];
|
||||
|
||||
// Dynamically discover secret->env mappings from the provider registry.
|
||||
// Uses selectable() which deduplicates user overrides correctly.
|
||||
let registry = crate::llm::ProviderRegistry::load();
|
||||
let dynamic_mappings: Vec<(String, String)> = registry
|
||||
.selectable()
|
||||
.iter()
|
||||
.filter_map(|def| {
|
||||
def.api_key_env.as_ref().and_then(|env_var| {
|
||||
def.setup
|
||||
.as_ref()
|
||||
.and_then(|s| s.secret_name())
|
||||
.map(|secret_name| (secret_name.to_string(), env_var.clone()))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
for (secret, env_var) in &dynamic_mappings {
|
||||
mappings.push((secret, env_var));
|
||||
}
|
||||
|
||||
let mut injected = HashMap::new();
|
||||
|
||||
for (secret_name, env_var) in mappings {
|
||||
@@ -240,5 +371,62 @@ pub async fn inject_llm_keys_from_secrets(
|
||||
}
|
||||
}
|
||||
|
||||
let _ = INJECTED_VARS.set(injected);
|
||||
inject_os_credential_store_tokens(&mut injected);
|
||||
|
||||
merge_injected_vars(injected);
|
||||
}
|
||||
|
||||
/// Load tokens from OS credential stores (no DB required).
|
||||
///
|
||||
/// Called unconditionally during startup — even when the encrypted secrets DB
|
||||
/// is unavailable (no master key, no DB connection). This ensures OAuth tokens
|
||||
/// from `claude login` (macOS Keychain / Linux credentials.json)
|
||||
/// are available for config resolution.
|
||||
pub fn inject_os_credentials() {
|
||||
let mut injected = HashMap::new();
|
||||
inject_os_credential_store_tokens(&mut injected);
|
||||
merge_injected_vars(injected);
|
||||
}
|
||||
|
||||
/// Merge new entries into the global injected-vars overlay.
|
||||
///
|
||||
/// New keys are inserted; existing keys are overwritten (later callers win,
|
||||
/// e.g. fresh OS credential store tokens override stale DB copies).
|
||||
fn merge_injected_vars(new_entries: HashMap<String, String>) {
|
||||
if new_entries.is_empty() {
|
||||
return;
|
||||
}
|
||||
match INJECTED_VARS.lock() {
|
||||
Ok(mut map) => map.extend(new_entries),
|
||||
Err(poisoned) => poisoned.into_inner().extend(new_entries),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inject a single key-value pair into the overlay.
|
||||
///
|
||||
/// Used by the setup wizard to make credentials available to `optional_env()`
|
||||
/// without calling `unsafe { std::env::set_var }`.
|
||||
pub fn inject_single_var(key: &str, value: &str) {
|
||||
match INJECTED_VARS.lock() {
|
||||
Ok(mut map) => {
|
||||
map.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
Err(poisoned) => {
|
||||
poisoned
|
||||
.into_inner()
|
||||
.insert(key.to_string(), value.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shared helper: extract tokens from OS credential stores into the overlay map.
|
||||
fn inject_os_credential_store_tokens(injected: &mut HashMap<String, String>) {
|
||||
// Try the OS credential store for a fresh Anthropic OAuth token.
|
||||
// Tokens from `claude login` expire in 8-12h, so the DB copy may be stale.
|
||||
// A fresh extraction from macOS Keychain / Linux credentials.json wins
|
||||
// over the (possibly expired) copy stored in the encrypted secrets DB.
|
||||
if let Some(fresh) = crate::config::ClaudeCodeConfig::extract_oauth_token() {
|
||||
injected.insert("ANTHROPIC_OAUTH_TOKEN".to_string(), fresh);
|
||||
tracing::debug!("Refreshed ANTHROPIC_OAUTH_TOKEN from OS credential store");
|
||||
}
|
||||
}
|
||||
|
||||
+215
-3
@@ -233,7 +233,219 @@ impl ClaudeCodeConfig {
|
||||
/// Expected shape: `{"claudeAiOauth": {"accessToken": "sk-ant-oat01-..."}}`
|
||||
fn parse_oauth_access_token(json: &str) -> Option<String> {
|
||||
let creds: serde_json::Value = serde_json::from_str(json).ok()?;
|
||||
creds["claudeAiOauth"]["accessToken"]
|
||||
.as_str()
|
||||
.map(String::from)
|
||||
let token = creds["claudeAiOauth"]["accessToken"].as_str()?;
|
||||
// Validate that the token looks like a real OAuth token before using it.
|
||||
// Claude CLI tokens start with "sk-ant-oat".
|
||||
if !token.starts_with("sk-ant-oat") {
|
||||
tracing::debug!("Ignoring credential store token with unexpected prefix");
|
||||
return None;
|
||||
}
|
||||
Some(token.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::sandbox::*;
|
||||
|
||||
// ── SandboxModeConfig defaults ──────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn sandbox_mode_config_default_values() {
|
||||
let cfg = SandboxModeConfig::default();
|
||||
assert!(cfg.enabled);
|
||||
assert_eq!(cfg.policy, "readonly");
|
||||
assert_eq!(cfg.timeout_secs, 120);
|
||||
assert_eq!(cfg.memory_limit_mb, 2048);
|
||||
assert_eq!(cfg.cpu_shares, 1024);
|
||||
assert_eq!(cfg.image, "ironclaw-worker:latest");
|
||||
assert!(cfg.auto_pull_image);
|
||||
assert!(cfg.extra_allowed_domains.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_mode_config_custom_values() {
|
||||
let cfg = SandboxModeConfig {
|
||||
enabled: false,
|
||||
policy: "full_access".to_string(),
|
||||
timeout_secs: 600,
|
||||
memory_limit_mb: 4096,
|
||||
cpu_shares: 512,
|
||||
image: "custom-worker:v2".to_string(),
|
||||
auto_pull_image: false,
|
||||
extra_allowed_domains: vec!["example.com".to_string()],
|
||||
};
|
||||
assert!(!cfg.enabled);
|
||||
assert_eq!(cfg.policy, "full_access");
|
||||
assert_eq!(cfg.timeout_secs, 600);
|
||||
assert_eq!(cfg.memory_limit_mb, 4096);
|
||||
assert_eq!(cfg.cpu_shares, 512);
|
||||
assert_eq!(cfg.image, "custom-worker:v2");
|
||||
assert!(!cfg.auto_pull_image);
|
||||
assert_eq!(cfg.extra_allowed_domains, vec!["example.com"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_mode_to_sandbox_config_propagates_fields() {
|
||||
let mode = SandboxModeConfig {
|
||||
enabled: true,
|
||||
policy: "workspace_write".to_string(),
|
||||
timeout_secs: 300,
|
||||
memory_limit_mb: 1024,
|
||||
cpu_shares: 2048,
|
||||
image: "test:latest".to_string(),
|
||||
auto_pull_image: false,
|
||||
extra_allowed_domains: vec!["custom.example.com".to_string()],
|
||||
};
|
||||
let sc = mode.to_sandbox_config();
|
||||
assert!(sc.enabled);
|
||||
assert_eq!(sc.policy, crate::sandbox::SandboxPolicy::WorkspaceWrite);
|
||||
assert_eq!(sc.timeout, std::time::Duration::from_secs(300));
|
||||
assert_eq!(sc.memory_limit_mb, 1024);
|
||||
assert_eq!(sc.cpu_shares, 2048);
|
||||
assert_eq!(sc.image, "test:latest");
|
||||
assert!(!sc.auto_pull_image);
|
||||
// extra domain should be in the allowlist
|
||||
assert!(
|
||||
sc.network_allowlist
|
||||
.contains(&"custom.example.com".to_string()),
|
||||
"expected custom domain in allowlist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_mode_to_sandbox_config_invalid_policy_falls_back_to_readonly() {
|
||||
let mode = SandboxModeConfig {
|
||||
policy: "garbage_value".to_string(),
|
||||
..SandboxModeConfig::default()
|
||||
};
|
||||
let sc = mode.to_sandbox_config();
|
||||
assert_eq!(sc.policy, crate::sandbox::SandboxPolicy::ReadOnly);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sandbox_mode_to_sandbox_config_includes_default_allowlist() {
|
||||
let mode = SandboxModeConfig::default();
|
||||
let sc = mode.to_sandbox_config();
|
||||
// The default allowlist from sandbox module should be non-empty
|
||||
assert!(
|
||||
!sc.network_allowlist.is_empty(),
|
||||
"default allowlist should not be empty"
|
||||
);
|
||||
}
|
||||
|
||||
// ── ClaudeCodeConfig defaults ───────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn claude_code_config_default_values() {
|
||||
let cfg = ClaudeCodeConfig::default();
|
||||
assert!(!cfg.enabled);
|
||||
assert_eq!(cfg.model, "sonnet");
|
||||
assert_eq!(cfg.max_turns, 50);
|
||||
assert_eq!(cfg.memory_limit_mb, 4096);
|
||||
assert!(cfg.config_dir.ends_with(".claude"));
|
||||
// Should have all the standard tools
|
||||
assert!(!cfg.allowed_tools.is_empty());
|
||||
assert!(cfg.allowed_tools.contains(&"Bash(*)".to_string()));
|
||||
assert!(cfg.allowed_tools.contains(&"Read(*)".to_string()));
|
||||
assert!(cfg.allowed_tools.contains(&"Edit(*)".to_string()));
|
||||
assert!(cfg.allowed_tools.contains(&"Write(*)".to_string()));
|
||||
assert!(cfg.allowed_tools.contains(&"Grep(*)".to_string()));
|
||||
assert!(cfg.allowed_tools.contains(&"WebFetch(*)".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_code_config_custom_values() {
|
||||
let cfg = ClaudeCodeConfig {
|
||||
enabled: true,
|
||||
config_dir: std::path::PathBuf::from("/opt/claude"),
|
||||
model: "opus".to_string(),
|
||||
max_turns: 100,
|
||||
memory_limit_mb: 8192,
|
||||
allowed_tools: vec!["Read(*)".to_string(), "Bash(*)".to_string()],
|
||||
};
|
||||
assert!(cfg.enabled);
|
||||
assert_eq!(cfg.config_dir, std::path::PathBuf::from("/opt/claude"));
|
||||
assert_eq!(cfg.model, "opus");
|
||||
assert_eq!(cfg.max_turns, 100);
|
||||
assert_eq!(cfg.memory_limit_mb, 8192);
|
||||
assert_eq!(cfg.allowed_tools.len(), 2);
|
||||
}
|
||||
|
||||
// ── parse_oauth_access_token ────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_valid() {
|
||||
let json = r#"{"claudeAiOauth": {"accessToken": "sk-ant-oat01-fake"}}"#;
|
||||
let token = parse_oauth_access_token(json);
|
||||
assert_eq!(token, Some("sk-ant-oat01-fake".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_missing_access_token() {
|
||||
let json = r#"{"claudeAiOauth": {}}"#;
|
||||
assert_eq!(parse_oauth_access_token(json), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_missing_oauth_key() {
|
||||
let json = r#"{"someOtherKey": {"accessToken": "tok"}}"#;
|
||||
assert_eq!(parse_oauth_access_token(json), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_invalid_json() {
|
||||
assert_eq!(parse_oauth_access_token("not json at all"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_empty_string() {
|
||||
assert_eq!(parse_oauth_access_token(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_nested_extra_fields() {
|
||||
let json = r#"{
|
||||
"claudeAiOauth": {
|
||||
"accessToken": "sk-ant-oat01-real-token",
|
||||
"refreshToken": "rt-abc",
|
||||
"expiresAt": 1700000000
|
||||
}
|
||||
}"#;
|
||||
assert_eq!(
|
||||
parse_oauth_access_token(json),
|
||||
Some("sk-ant-oat01-real-token".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_access_token_is_not_string() {
|
||||
let json = r#"{"claudeAiOauth": {"accessToken": 12345}}"#;
|
||||
assert_eq!(parse_oauth_access_token(json), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_oauth_token_rejects_invalid_prefix() {
|
||||
let json = r#"{"claudeAiOauth": {"accessToken": "not-an-oauth-token"}}"#;
|
||||
assert_eq!(parse_oauth_access_token(json), None);
|
||||
}
|
||||
|
||||
// ── default_claude_code_allowed_tools ───────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_allowed_tools_has_expected_count() {
|
||||
let tools = default_claude_code_allowed_tools();
|
||||
// 10 tools: Read, Write, Edit, Glob, Grep, NotebookEdit, Bash, Task, WebFetch, WebSearch
|
||||
assert_eq!(tools.len(), 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_allowed_tools_all_have_glob_pattern() {
|
||||
let tools = default_claude_code_allowed_tools();
|
||||
for tool in &tools {
|
||||
assert!(
|
||||
tool.ends_with("(*)"),
|
||||
"tool '{tool}' should end with '(*)' glob pattern"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
use secrecy::SecretString;
|
||||
|
||||
use crate::config::helpers::{optional_env, parse_bool_env};
|
||||
use crate::error::ConfigError;
|
||||
use crate::settings::Settings;
|
||||
|
||||
/// Transcription pipeline configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TranscriptionConfig {
|
||||
/// Whether audio transcription is enabled.
|
||||
pub enabled: bool,
|
||||
/// Provider: "openai" (default).
|
||||
pub provider: String,
|
||||
/// OpenAI API key (reuses OPENAI_API_KEY).
|
||||
pub openai_api_key: Option<SecretString>,
|
||||
/// Model to use (default: "whisper-1").
|
||||
pub model: String,
|
||||
/// Base URL override for the transcription API.
|
||||
pub base_url: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for TranscriptionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
enabled: false,
|
||||
provider: "openai".to_string(),
|
||||
openai_api_key: None,
|
||||
model: "whisper-1".to_string(),
|
||||
base_url: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptionConfig {
|
||||
pub(crate) fn resolve(settings: &Settings) -> Result<Self, ConfigError> {
|
||||
let enabled = parse_bool_env(
|
||||
"TRANSCRIPTION_ENABLED",
|
||||
settings.transcription.as_ref().is_some_and(|t| t.enabled),
|
||||
)?;
|
||||
|
||||
let provider =
|
||||
optional_env("TRANSCRIPTION_PROVIDER")?.unwrap_or_else(|| "openai".to_string());
|
||||
|
||||
let openai_api_key = optional_env("OPENAI_API_KEY")?.map(SecretString::from);
|
||||
|
||||
let model = optional_env("TRANSCRIPTION_MODEL")?.unwrap_or_else(|| "whisper-1".to_string());
|
||||
|
||||
let base_url = optional_env("TRANSCRIPTION_BASE_URL")?;
|
||||
|
||||
Ok(Self {
|
||||
enabled,
|
||||
provider,
|
||||
openai_api_key,
|
||||
model,
|
||||
base_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Create the transcription provider if enabled and configured.
|
||||
pub fn create_provider(&self) -> Option<Box<dyn crate::transcription::TranscriptionProvider>> {
|
||||
if !self.enabled {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Currently only OpenAI Whisper is supported; more providers can be
|
||||
// added here with a match on self.provider.
|
||||
let api_key = self.openai_api_key.as_ref()?;
|
||||
tracing::info!(model = %self.model, "Audio transcription enabled via OpenAI Whisper");
|
||||
|
||||
let mut provider = crate::transcription::OpenAiWhisperProvider::new(api_key.clone())
|
||||
.with_model(&self.model);
|
||||
|
||||
if let Some(ref base_url) = self.base_url {
|
||||
provider = provider.with_base_url(base_url);
|
||||
}
|
||||
|
||||
Some(Box::new(provider))
|
||||
}
|
||||
}
|
||||
@@ -104,3 +104,215 @@ impl TunnelConfig {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::config::tunnel::TunnelConfig;
|
||||
use crate::tunnel::{
|
||||
CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TailscaleTunnelConfig,
|
||||
TunnelProviderConfig,
|
||||
};
|
||||
|
||||
// ── Default ─────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn default_is_disabled() {
|
||||
let cfg = TunnelConfig::default();
|
||||
assert!(cfg.public_url.is_none());
|
||||
assert!(cfg.provider.is_none());
|
||||
assert!(!cfg.is_enabled());
|
||||
}
|
||||
|
||||
// ── is_enabled ──────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn is_enabled_with_static_url() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://tunnel.example.com".to_string()),
|
||||
provider: None,
|
||||
};
|
||||
assert!(cfg.is_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_enabled_with_provider() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: None,
|
||||
provider: Some(TunnelProviderConfig {
|
||||
provider: "cloudflare".to_string(),
|
||||
cloudflare: Some(CloudflareTunnelConfig {
|
||||
token: "cf-tok".to_string(),
|
||||
}),
|
||||
tailscale: None,
|
||||
ngrok: None,
|
||||
custom: None,
|
||||
}),
|
||||
};
|
||||
assert!(cfg.is_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_enabled_with_both() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://example.com".to_string()),
|
||||
provider: Some(TunnelProviderConfig {
|
||||
provider: "ngrok".to_string(),
|
||||
cloudflare: None,
|
||||
tailscale: None,
|
||||
ngrok: Some(NgrokTunnelConfig {
|
||||
auth_token: "ngrok-tok".to_string(),
|
||||
domain: None,
|
||||
}),
|
||||
custom: None,
|
||||
}),
|
||||
};
|
||||
assert!(cfg.is_enabled());
|
||||
}
|
||||
|
||||
// ── webhook_url ─────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn webhook_url_none_when_no_public_url() {
|
||||
let cfg = TunnelConfig::default();
|
||||
assert!(cfg.webhook_url("/hook").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_basic() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://abc.ngrok.io".to_string()),
|
||||
provider: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.webhook_url("/webhook/telegram"),
|
||||
Some("https://abc.ngrok.io/webhook/telegram".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_trims_trailing_slash_on_base() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://abc.ngrok.io/".to_string()),
|
||||
provider: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.webhook_url("/hook"),
|
||||
Some("https://abc.ngrok.io/hook".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_trims_leading_slash_on_path() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://abc.ngrok.io".to_string()),
|
||||
provider: None,
|
||||
};
|
||||
// Path without leading slash should also work
|
||||
assert_eq!(
|
||||
cfg.webhook_url("hook"),
|
||||
Some("https://abc.ngrok.io/hook".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_double_slash_normalization() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://abc.ngrok.io/".to_string()),
|
||||
provider: None,
|
||||
};
|
||||
// Both base trailing and path leading slashes trimmed
|
||||
assert_eq!(
|
||||
cfg.webhook_url("/api/webhook"),
|
||||
Some("https://abc.ngrok.io/api/webhook".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn webhook_url_empty_path() {
|
||||
let cfg = TunnelConfig {
|
||||
public_url: Some("https://abc.ngrok.io".to_string()),
|
||||
provider: None,
|
||||
};
|
||||
assert_eq!(
|
||||
cfg.webhook_url(""),
|
||||
Some("https://abc.ngrok.io/".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
// ── TunnelProviderConfig field coverage ─────────────────────────
|
||||
|
||||
#[test]
|
||||
fn provider_config_cloudflare() {
|
||||
let p = TunnelProviderConfig {
|
||||
provider: "cloudflare".to_string(),
|
||||
cloudflare: Some(CloudflareTunnelConfig {
|
||||
token: "cf-secret".to_string(),
|
||||
}),
|
||||
tailscale: None,
|
||||
ngrok: None,
|
||||
custom: None,
|
||||
};
|
||||
assert_eq!(p.provider, "cloudflare");
|
||||
assert_eq!(p.cloudflare.as_ref().unwrap().token, "cf-secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_tailscale() {
|
||||
let ts = TailscaleTunnelConfig {
|
||||
funnel: true,
|
||||
hostname: Some("my-host".to_string()),
|
||||
};
|
||||
assert!(ts.funnel);
|
||||
assert_eq!(ts.hostname.as_deref(), Some("my-host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_tailscale_defaults() {
|
||||
let ts = TailscaleTunnelConfig::default();
|
||||
assert!(!ts.funnel);
|
||||
assert!(ts.hostname.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_ngrok() {
|
||||
let ng = NgrokTunnelConfig {
|
||||
auth_token: "ng-tok".to_string(),
|
||||
domain: Some("custom.ngrok.dev".to_string()),
|
||||
};
|
||||
assert_eq!(ng.auth_token, "ng-tok");
|
||||
assert_eq!(ng.domain.as_deref(), Some("custom.ngrok.dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_ngrok_defaults() {
|
||||
let ng = NgrokTunnelConfig::default();
|
||||
assert!(ng.auth_token.is_empty());
|
||||
assert!(ng.domain.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_custom() {
|
||||
let c = CustomTunnelConfig {
|
||||
start_command: "bore local {port}".to_string(),
|
||||
health_url: Some("http://localhost:8080/health".to_string()),
|
||||
url_pattern: Some("https://bore.pub".to_string()),
|
||||
};
|
||||
assert_eq!(c.start_command, "bore local {port}");
|
||||
assert!(c.health_url.is_some());
|
||||
assert!(c.url_pattern.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_config_custom_defaults() {
|
||||
let c = CustomTunnelConfig::default();
|
||||
assert!(c.start_command.is_empty());
|
||||
assert!(c.health_url.is_none());
|
||||
assert!(c.url_pattern.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cloudflare_config_defaults() {
|
||||
let cf = CloudflareTunnelConfig::default();
|
||||
assert!(cf.token.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user