Merge origin/main into feat/gemini-cli-oauth and resolve conflicts

This commit is contained in:
Artem
2026-03-09 15:20:57 +03:00
386 changed files with 58680 additions and 5643 deletions
+57
View File
@@ -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
View File
@@ -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
View File
@@ -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"));
}
}
+7 -2
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+198 -10
View File
@@ -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
View File
@@ -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"
);
}
}
}
+79
View File
@@ -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))
}
}
+212
View File
@@ -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());
}
}