2fe4c3d0bb
Signed-off-by: Kalvin Chau <kalvin@block.xyz>
2098 lines
70 KiB
Rust
2098 lines
70 KiB
Rust
use crate::config::paths::Paths;
|
|
use crate::config::GooseMode;
|
|
use fs2::FileExt;
|
|
use keyring::Entry;
|
|
use once_cell::sync::OnceCell;
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use serde_yaml::Mapping;
|
|
use std::collections::HashMap;
|
|
use std::env;
|
|
use std::fs::OpenOptions;
|
|
use std::io::Write;
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::{Arc, Mutex};
|
|
use thiserror::Error;
|
|
|
|
fn write_secrets_file(path: &Path, content: &str) -> std::io::Result<()> {
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
let mut file = OpenOptions::new()
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(true)
|
|
.mode(0o600)
|
|
.open(path)?;
|
|
|
|
file.write_all(content.as_bytes())
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
{
|
|
std::fs::write(path, content)
|
|
}
|
|
}
|
|
|
|
const KEYRING_SERVICE: &str = "goose";
|
|
const KEYRING_USERNAME: &str = "secrets";
|
|
pub const CONFIG_YAML_NAME: &str = "config.yaml";
|
|
|
|
#[derive(Error, Debug)]
|
|
pub enum ConfigError {
|
|
#[error("Configuration value not found: {0}")]
|
|
NotFound(String),
|
|
#[error("Failed to deserialize value: {0}")]
|
|
DeserializeError(String),
|
|
#[error("Failed to read config file: {0}")]
|
|
FileError(#[from] std::io::Error),
|
|
#[error("Failed to create config directory: {0}")]
|
|
DirectoryError(String),
|
|
#[error("Failed to access keyring: {0}")]
|
|
KeyringError(String),
|
|
#[error("Failed to lock config file: {0}")]
|
|
LockError(String),
|
|
#[error("Secret stored using file-based fallback")]
|
|
FallbackToFileStorage,
|
|
}
|
|
|
|
impl From<serde_json::Error> for ConfigError {
|
|
fn from(err: serde_json::Error) -> Self {
|
|
ConfigError::DeserializeError(err.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<serde_yaml::Error> for ConfigError {
|
|
fn from(err: serde_yaml::Error) -> Self {
|
|
ConfigError::DeserializeError(err.to_string())
|
|
}
|
|
}
|
|
|
|
impl From<keyring::Error> for ConfigError {
|
|
fn from(err: keyring::Error) -> Self {
|
|
ConfigError::KeyringError(err.to_string())
|
|
}
|
|
}
|
|
|
|
/// Configuration management for goose.
|
|
///
|
|
/// This module provides a flexible configuration system that supports:
|
|
/// - Dynamic configuration keys
|
|
/// - Multiple value types through serde deserialization
|
|
/// - Environment variable overrides
|
|
/// - YAML-based configuration file storage
|
|
/// - Hot reloading of configuration changes
|
|
/// - Secure secret storage in system keyring
|
|
///
|
|
/// Configuration values are loaded with the following precedence:
|
|
/// 1. Environment variables (exact key match)
|
|
/// 2. Configuration file (~/.config/goose/config.yaml by default)
|
|
///
|
|
/// Secrets are loaded with the following precedence:
|
|
/// 1. Environment variables (exact key match)
|
|
/// 2. System keyring (which can be disabled with GOOSE_DISABLE_KEYRING)
|
|
/// 3. If the keyring is disabled, secrets are stored in a secrets file
|
|
/// (~/.config/goose/secrets.yaml by default)
|
|
///
|
|
/// # Examples
|
|
///
|
|
/// ```no_run
|
|
/// use goose::config::Config;
|
|
/// use serde::Deserialize;
|
|
///
|
|
/// // Get a string value
|
|
/// let config = Config::global();
|
|
/// let api_key: String = config.get_param("OPENAI_API_KEY").unwrap();
|
|
///
|
|
/// // Get a complex type
|
|
/// #[derive(Deserialize)]
|
|
/// struct ServerConfig {
|
|
/// host: String,
|
|
/// port: u16,
|
|
/// }
|
|
///
|
|
/// let server_config: ServerConfig = config.get_param("server").unwrap();
|
|
/// ```
|
|
///
|
|
/// # Naming Convention
|
|
/// we recommend snake_case for keys, and will convert to UPPERCASE when
|
|
/// checking for environment overrides. e.g. openai_api_key will check for an
|
|
/// environment variable OPENAI_API_KEY
|
|
///
|
|
/// For goose-specific configuration, consider prefixing with "goose_" to avoid conflicts.
|
|
pub struct Config {
|
|
/// Ordered list of config files to load and merge.
|
|
/// Later entries take precedence over earlier ones.
|
|
/// The last entry is where changes will be written.
|
|
config_paths: Vec<PathBuf>,
|
|
secrets: SecretStorage,
|
|
guard: Mutex<()>,
|
|
secrets_cache: Arc<Mutex<Option<HashMap<String, Value>>>>,
|
|
}
|
|
|
|
enum SecretStorage {
|
|
Keyring { service: String },
|
|
File { path: PathBuf },
|
|
}
|
|
|
|
// Global instance
|
|
static GLOBAL_CONFIG: OnceCell<Config> = OnceCell::new();
|
|
|
|
fn system_config_path() -> PathBuf {
|
|
#[cfg(unix)]
|
|
{
|
|
PathBuf::from("/etc/goose/config.yaml")
|
|
}
|
|
#[cfg(windows)]
|
|
{
|
|
env::var("PROGRAMDATA")
|
|
.map(|d| PathBuf::from(d).join("goose").join("config.yaml"))
|
|
.unwrap_or_else(|_| PathBuf::from(r"C:\ProgramData\goose\config.yaml"))
|
|
}
|
|
}
|
|
|
|
fn bundled_defaults_path() -> Option<PathBuf> {
|
|
let exe = std::env::current_exe().ok()?;
|
|
let path = exe.parent()?.join("defaults.yaml");
|
|
if path.exists() {
|
|
Some(path)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
impl Default for Config {
|
|
fn default() -> Self {
|
|
let config_dir = Paths::config_dir();
|
|
let user_config_path = config_dir.join(CONFIG_YAML_NAME);
|
|
|
|
let mut config_paths = vec![system_config_path()];
|
|
if let Some(defaults) = bundled_defaults_path() {
|
|
config_paths.insert(0, defaults);
|
|
}
|
|
config_paths.push(user_config_path.clone());
|
|
|
|
let no_secrets_config = Self {
|
|
config_paths: config_paths.clone(),
|
|
secrets: SecretStorage::File {
|
|
path: Default::default(),
|
|
},
|
|
guard: Mutex::new(()),
|
|
secrets_cache: Arc::new(Mutex::new(None)),
|
|
};
|
|
|
|
let secrets = if env::var("GOOSE_DISABLE_KEYRING").is_ok()
|
|
|| no_secrets_config
|
|
.get_param::<serde_yaml::Value>("GOOSE_DISABLE_KEYRING")
|
|
.is_ok_and(|v| keyring_disabled_value(&v))
|
|
{
|
|
SecretStorage::File {
|
|
path: config_dir.join("secrets.yaml"),
|
|
}
|
|
} else {
|
|
SecretStorage::Keyring {
|
|
service: KEYRING_SERVICE.to_string(),
|
|
}
|
|
};
|
|
Self {
|
|
config_paths,
|
|
secrets,
|
|
guard: Mutex::new(()),
|
|
secrets_cache: Arc::new(Mutex::new(None)),
|
|
}
|
|
}
|
|
}
|
|
|
|
pub trait ConfigValue {
|
|
const KEY: &'static str;
|
|
const DEFAULT: &'static str;
|
|
}
|
|
|
|
macro_rules! config_value {
|
|
($key:ident, $type:ty) => {
|
|
impl Config {
|
|
pastey::paste! {
|
|
pub fn [<get_ $key:lower>](&self) -> Result<$type, ConfigError> {
|
|
self.get_param(stringify!($key))
|
|
}
|
|
}
|
|
pastey::paste! {
|
|
pub fn [<set_ $key:lower>](&self, v: impl Into<$type>) -> Result<(), ConfigError> {
|
|
self.set_param(stringify!($key), &v.into())
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
($key:ident, $inner:ty, $default:expr) => {
|
|
pastey::paste! {
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(transparent)]
|
|
pub struct [<$key:camel>]($inner);
|
|
|
|
impl ConfigValue for [<$key:camel>] {
|
|
const KEY: &'static str = stringify!($key);
|
|
const DEFAULT: &'static str = $default;
|
|
}
|
|
|
|
impl Default for [<$key:camel>] {
|
|
fn default() -> Self {
|
|
[<$key:camel>]($default.into())
|
|
}
|
|
}
|
|
|
|
impl std::ops::Deref for [<$key:camel>] {
|
|
type Target = $inner;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl std::ops::DerefMut for [<$key:camel>] {
|
|
fn deref_mut(&mut self) -> &mut Self::Target {
|
|
&mut self.0
|
|
}
|
|
}
|
|
|
|
impl std::fmt::Display for [<$key:camel>] {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
write!(f, "{:?}", self.0)
|
|
}
|
|
}
|
|
|
|
impl From<$inner> for [<$key:camel>] {
|
|
fn from(value: $inner) -> Self {
|
|
[<$key:camel>](value)
|
|
}
|
|
}
|
|
|
|
impl From<[<$key:camel>]> for $inner {
|
|
fn from(value: [<$key:camel>]) -> $inner {
|
|
value.0
|
|
}
|
|
}
|
|
|
|
config_value!($key, [<$key:camel>]);
|
|
}
|
|
};
|
|
}
|
|
|
|
fn parse_yaml_content(content: &str) -> Result<Mapping, ConfigError> {
|
|
serde_yaml::from_str(content).map_err(|e| e.into())
|
|
}
|
|
|
|
fn keyring_disabled_value(value: &serde_yaml::Value) -> bool {
|
|
value.as_bool().unwrap_or(false) || value.as_str().is_some_and(|s| s == "true" || s == "1")
|
|
}
|
|
|
|
const EXTENSIONS_KEY: &str = "extensions";
|
|
|
|
pub fn merge_config_values(base: &mut Mapping, overlay: Mapping) {
|
|
let extensions_key = serde_yaml::Value::String(EXTENSIONS_KEY.to_string());
|
|
|
|
for (key, overlay_value) in overlay {
|
|
if key == extensions_key {
|
|
let base_ext = base
|
|
.entry(key.clone())
|
|
.or_insert_with(|| serde_yaml::Value::Mapping(Mapping::new()));
|
|
if let (Some(base_map), Some(overlay_map)) =
|
|
(base_ext.as_mapping_mut(), overlay_value.as_mapping())
|
|
{
|
|
merge_extensions(base_map, overlay_map);
|
|
} else {
|
|
base.insert(key, overlay_value);
|
|
}
|
|
} else {
|
|
base.insert(key, overlay_value);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn merge_extensions(base: &mut Mapping, overlay: &Mapping) {
|
|
for (ext_key, overlay_ext) in overlay {
|
|
match base.get_mut(ext_key) {
|
|
Some(base_ext) => {
|
|
if let (Some(base_map), Some(overlay_map)) =
|
|
(base_ext.as_mapping_mut(), overlay_ext.as_mapping())
|
|
{
|
|
for (field_key, field_value) in overlay_map {
|
|
base_map.insert(field_key.clone(), field_value.clone());
|
|
}
|
|
} else {
|
|
*base_ext = overlay_ext.clone();
|
|
}
|
|
}
|
|
None => {
|
|
base.insert(ext_key.clone(), overlay_ext.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read the GOOSE_DISABLE_KEYRING flag from the config file.
|
|
///
|
|
/// Called before Config is fully initialised, so we do a minimal raw read
|
|
/// rather than going through `get_param`. All errors are treated as `false`
|
|
/// (keyring stays enabled) so a missing/malformed file is never fatal here.
|
|
fn keyring_disabled_in_config(config_path: &Path) -> bool {
|
|
std::fs::read_to_string(config_path)
|
|
.ok()
|
|
.and_then(|s| parse_yaml_content(&s).ok())
|
|
.and_then(|m| m.get("GOOSE_DISABLE_KEYRING").map(keyring_disabled_value))
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
impl Config {
|
|
/// Get the global configuration instance.
|
|
///
|
|
/// This will initialize the configuration with the default path (~/.config/goose/config.yaml)
|
|
/// if it hasn't been initialized yet.
|
|
pub fn global() -> &'static Config {
|
|
GLOBAL_CONFIG.get_or_init(Config::default)
|
|
}
|
|
|
|
/// Create a new configuration instance with custom paths
|
|
///
|
|
/// This is primarily useful for testing or for applications that need
|
|
/// to manage multiple configuration files.
|
|
pub fn new<P: AsRef<Path>>(config_path: P, service: &str) -> Result<Self, ConfigError> {
|
|
let config_path = config_path.as_ref().to_path_buf();
|
|
let secrets = if env::var("GOOSE_DISABLE_KEYRING").is_ok()
|
|
|| keyring_disabled_in_config(&config_path)
|
|
{
|
|
let config_dir = config_path
|
|
.parent()
|
|
.map(Path::to_path_buf)
|
|
.unwrap_or_else(Paths::config_dir);
|
|
SecretStorage::File {
|
|
path: config_dir.join("secrets.yaml"),
|
|
}
|
|
} else {
|
|
SecretStorage::Keyring {
|
|
service: service.to_string(),
|
|
}
|
|
};
|
|
Ok(Config {
|
|
config_paths: vec![config_path],
|
|
secrets,
|
|
guard: Mutex::new(()),
|
|
secrets_cache: Arc::new(Mutex::new(None)),
|
|
})
|
|
}
|
|
|
|
/// Create a new configuration instance with custom paths
|
|
///
|
|
/// This is primarily useful for testing or for applications that need
|
|
/// to manage multiple configuration files.
|
|
pub fn new_with_file_secrets<P1: AsRef<Path>, P2: AsRef<Path>>(
|
|
config_path: P1,
|
|
secrets_path: P2,
|
|
) -> Result<Self, ConfigError> {
|
|
Ok(Config {
|
|
config_paths: vec![config_path.as_ref().to_path_buf()],
|
|
secrets: SecretStorage::File {
|
|
path: secrets_path.as_ref().to_path_buf(),
|
|
},
|
|
guard: Mutex::new(()),
|
|
secrets_cache: Arc::new(Mutex::new(None)),
|
|
})
|
|
}
|
|
|
|
pub fn new_with_config_paths<P1: AsRef<Path>>(
|
|
config_paths: Vec<PathBuf>,
|
|
secrets_path: P1,
|
|
) -> Result<Self, ConfigError> {
|
|
Ok(Config {
|
|
config_paths,
|
|
secrets: SecretStorage::File {
|
|
path: secrets_path.as_ref().to_path_buf(),
|
|
},
|
|
guard: Mutex::new(()),
|
|
secrets_cache: Arc::new(Mutex::new(None)),
|
|
})
|
|
}
|
|
|
|
fn write_path(&self) -> &PathBuf {
|
|
self.config_paths
|
|
.last()
|
|
.expect("config_paths must not be empty")
|
|
}
|
|
|
|
pub fn exists(&self) -> bool {
|
|
self.config_paths.iter().any(|p| p.exists())
|
|
}
|
|
|
|
pub fn clear(&self) -> Result<(), ConfigError> {
|
|
Ok(std::fs::remove_file(self.write_path())?)
|
|
}
|
|
|
|
pub fn path(&self) -> String {
|
|
self.write_path().to_string_lossy().to_string()
|
|
}
|
|
|
|
/// Load only the writable config file for read-modify-write operations.
|
|
/// Returns an empty mapping if the file doesn't exist or can't be parsed.
|
|
fn load_write_config(&self) -> Result<Mapping, ConfigError> {
|
|
if !self.write_path().exists() {
|
|
return Ok(Mapping::new());
|
|
}
|
|
let content = std::fs::read_to_string(self.write_path())?;
|
|
let mut values = parse_yaml_content(&content).unwrap_or_else(|e| {
|
|
tracing::warn!(
|
|
"Config file {:?} is corrupt: {}. Starting fresh.",
|
|
self.write_path(),
|
|
e
|
|
);
|
|
Mapping::new()
|
|
});
|
|
|
|
if crate::config::migrations::run_migrations(&mut values) {
|
|
if let Err(e) = self.save_values(&values) {
|
|
tracing::warn!("Failed to save migrated config: {}", e);
|
|
}
|
|
}
|
|
|
|
Ok(values)
|
|
}
|
|
|
|
fn load(&self) -> Result<Mapping, ConfigError> {
|
|
let mut merged = Mapping::new();
|
|
|
|
for path in &self.config_paths {
|
|
if !path.exists() {
|
|
continue;
|
|
}
|
|
match std::fs::read_to_string(path)
|
|
.map_err(ConfigError::from)
|
|
.and_then(|content| parse_yaml_content(&content))
|
|
{
|
|
Ok(layer) => {
|
|
tracing::debug!("Loading config from: {:?}", path);
|
|
merge_config_values(&mut merged, layer);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Failed to load config {:?}: {}. Skipping.", path, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
crate::config::migrations::run_migrations(&mut merged);
|
|
|
|
Ok(merged)
|
|
}
|
|
|
|
pub fn all_values(&self) -> Result<HashMap<String, Value>, ConfigError> {
|
|
let config_values = self.load()?;
|
|
Ok(HashMap::from_iter(config_values.into_iter().filter_map(
|
|
|(k, v)| {
|
|
k.as_str()
|
|
.map(|k| k.to_string())
|
|
.zip(serde_json::to_value(v).ok())
|
|
},
|
|
)))
|
|
}
|
|
|
|
fn config_write_target_path(&self) -> Result<PathBuf, ConfigError> {
|
|
let mut path = self.write_path().clone();
|
|
|
|
// Follow symlinks so we update the target file without replacing the link itself.
|
|
const MAX_SYMLINK_HOPS: usize = 1;
|
|
let mut hops = 0usize;
|
|
loop {
|
|
match std::fs::symlink_metadata(&path) {
|
|
Ok(meta) if meta.file_type().is_symlink() => {
|
|
if hops >= MAX_SYMLINK_HOPS {
|
|
return Err(std::io::Error::new(
|
|
std::io::ErrorKind::InvalidInput,
|
|
format!(
|
|
"Too many symlink levels (or a cycle) while resolving config path: {:?}",
|
|
self.write_path()
|
|
),
|
|
)
|
|
.into());
|
|
}
|
|
hops += 1;
|
|
|
|
let link = std::fs::read_link(&path)?;
|
|
path = if link.is_absolute() {
|
|
link
|
|
} else {
|
|
path.parent().unwrap_or_else(|| Path::new(".")).join(link)
|
|
};
|
|
}
|
|
Ok(_) => return Ok(path),
|
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(path),
|
|
Err(e) => return Err(e.into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn save_values(&self, values: &Mapping) -> Result<(), ConfigError> {
|
|
let target_path = self.config_write_target_path()?;
|
|
|
|
// Convert to YAML for storage
|
|
let yaml_value = serde_yaml::to_string(values)?;
|
|
|
|
if let Some(parent) = target_path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.map_err(|e| ConfigError::DirectoryError(e.to_string()))?;
|
|
}
|
|
|
|
// Write to a temporary file first for atomic operation
|
|
let temp_path = target_path.with_extension("tmp");
|
|
|
|
{
|
|
let mut file = OpenOptions::new()
|
|
.write(true)
|
|
.create(true)
|
|
.truncate(true)
|
|
.open(&temp_path)?;
|
|
|
|
// Acquire an exclusive lock
|
|
file.lock_exclusive()
|
|
.map_err(|e| ConfigError::LockError(e.to_string()))?;
|
|
|
|
// Write the contents using the same file handle
|
|
file.write_all(yaml_value.as_bytes())?;
|
|
file.sync_all()?;
|
|
|
|
// Unlock is handled automatically when file is dropped
|
|
}
|
|
|
|
// Atomically replace the original file
|
|
std::fs::rename(&temp_path, &target_path)?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn initialize_if_empty(&self, values: Mapping) -> Result<(), ConfigError> {
|
|
let _guard = self.guard.lock().unwrap();
|
|
if !self.exists() {
|
|
self.save_values(&values)
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
pub fn all_secrets(&self) -> Result<HashMap<String, Value>, ConfigError> {
|
|
let mut cache = self.secrets_cache.lock().unwrap();
|
|
|
|
let values = if let Some(ref cached_secrets) = *cache {
|
|
cached_secrets.clone()
|
|
} else {
|
|
tracing::debug!("secrets cache miss, fetching from storage");
|
|
|
|
let loaded = match &self.secrets {
|
|
SecretStorage::Keyring { service } => {
|
|
let result =
|
|
self.handle_keyring_operation(|entry| entry.get_password(), service, None);
|
|
|
|
match result {
|
|
Ok(content) => {
|
|
let values: HashMap<String, Value> = serde_json::from_str(&content)?;
|
|
values
|
|
}
|
|
Err(ConfigError::FallbackToFileStorage) => {
|
|
self.fallback_to_file_storage()?
|
|
}
|
|
Err(ConfigError::KeyringError(msg))
|
|
if msg.contains("No entry found")
|
|
|| msg.contains("No matching entry found") =>
|
|
{
|
|
HashMap::new()
|
|
}
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
SecretStorage::File { path } => self.read_secrets_from_file(path)?,
|
|
};
|
|
|
|
*cache = Some(loaded.clone());
|
|
loaded
|
|
};
|
|
|
|
Ok(values)
|
|
}
|
|
|
|
/// Parse an environment variable value into a JSON Value.
|
|
///
|
|
/// This function tries to intelligently parse environment variable values:
|
|
/// 1. First attempts JSON parsing (for structured data)
|
|
/// 2. If that fails, tries primitive type parsing for common cases
|
|
/// 3. Falls back to string if nothing else works
|
|
fn parse_env_value(val: &str) -> Result<Value, ConfigError> {
|
|
// First try JSON parsing - this handles quoted strings, objects, arrays, etc.
|
|
if let Ok(json_value) = serde_json::from_str(val) {
|
|
return Ok(json_value);
|
|
}
|
|
|
|
let trimmed = val.trim();
|
|
|
|
match trimmed.to_lowercase().as_str() {
|
|
"true" => return Ok(Value::Bool(true)),
|
|
"false" => return Ok(Value::Bool(false)),
|
|
_ => {}
|
|
}
|
|
|
|
if let Ok(int_val) = trimmed.parse::<i64>() {
|
|
return Ok(Value::Number(int_val.into()));
|
|
}
|
|
|
|
if let Ok(float_val) = trimmed.parse::<f64>() {
|
|
if let Some(num) = serde_json::Number::from_f64(float_val) {
|
|
return Ok(Value::Number(num));
|
|
}
|
|
}
|
|
|
|
Ok(Value::String(val.to_string()))
|
|
}
|
|
|
|
// check all possible places for a parameter
|
|
pub fn get(&self, key: &str, is_secret: bool) -> Result<Value, ConfigError> {
|
|
if is_secret {
|
|
self.get_secret(key)
|
|
} else {
|
|
self.get_param(key)
|
|
}
|
|
}
|
|
|
|
// save a parameter in the appropriate location based on if it's secret or not
|
|
pub fn set<V>(&self, key: &str, value: &V, is_secret: bool) -> Result<(), ConfigError>
|
|
where
|
|
V: Serialize,
|
|
{
|
|
if is_secret {
|
|
self.set_secret(key, value)
|
|
} else {
|
|
self.set_param(key, value)
|
|
}
|
|
}
|
|
|
|
/// Get a configuration value (non-secret).
|
|
///
|
|
/// This will attempt to get the value from (in order):
|
|
/// 1. Environment variable with the uppercase key name
|
|
/// 2. Merged config from all config paths (system → user → local)
|
|
///
|
|
/// The value will be deserialized into the requested type. This works with
|
|
/// both simple types (String, i32, etc.) and complex types that implement
|
|
/// serde::Deserialize.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns a ConfigError if:
|
|
/// - The key doesn't exist in any of the above sources
|
|
/// - The value cannot be deserialized into the requested type
|
|
/// - There is an error reading the config file
|
|
pub fn get_param<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Result<T, ConfigError> {
|
|
let env_key = key.to_uppercase();
|
|
if let Ok(val) = env::var(&env_key) {
|
|
let value = Self::parse_env_value(&val)?;
|
|
return Ok(serde_json::from_value(value)?);
|
|
}
|
|
|
|
let values = self.load()?;
|
|
values
|
|
.get(key)
|
|
.ok_or_else(|| ConfigError::NotFound(key.to_string()))
|
|
.and_then(|v| Ok(serde_yaml::from_value(v.clone())?))
|
|
}
|
|
|
|
/// Set a configuration value in the config file (non-secret).
|
|
///
|
|
/// This will immediately write the value to the config file. The value
|
|
/// can be any type that can be serialized to JSON/YAML.
|
|
///
|
|
/// Note that this does not affect environment variables - those can only
|
|
/// be set through the system environment.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns a ConfigError if:
|
|
/// - There is an error reading or writing the config file
|
|
/// - There is an error serializing the value
|
|
pub fn set_param<V: Serialize>(&self, key: &str, value: V) -> Result<(), ConfigError> {
|
|
let _guard = self.guard.lock().unwrap();
|
|
let mut values = self.load_write_config()?;
|
|
values.insert(serde_yaml::to_value(key)?, serde_yaml::to_value(value)?);
|
|
self.save_values(&values)
|
|
}
|
|
|
|
/// Set multiple configuration values in the config file with one read and one write.
|
|
pub fn set_param_values(&self, updates: &[(String, Value)]) -> Result<(), ConfigError> {
|
|
if updates.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let _guard = self.guard.lock().unwrap();
|
|
let mut values = self.load_write_config()?;
|
|
for (key, value) in updates {
|
|
values.insert(serde_yaml::to_value(key)?, serde_yaml::to_value(value)?);
|
|
}
|
|
self.save_values(&values)
|
|
}
|
|
|
|
/// Delete a configuration value in the config file.
|
|
///
|
|
/// This will immediately write the value to the config file. The value
|
|
/// can be any type that can be serialized to JSON/YAML.
|
|
///
|
|
/// Note that this does not affect environment variables - those can only
|
|
/// be set through the system environment.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns a ConfigError if:
|
|
/// - There is an error reading or writing the config file
|
|
/// - There is an error serializing the value
|
|
pub fn delete(&self, key: &str) -> Result<(), ConfigError> {
|
|
// Lock before reading to prevent race condition.
|
|
let _guard = self.guard.lock().unwrap();
|
|
|
|
let mut values = self.load_write_config()?;
|
|
values.shift_remove(key);
|
|
|
|
self.save_values(&values)
|
|
}
|
|
|
|
/// Get a secret value.
|
|
///
|
|
/// This will attempt to get the value from:
|
|
/// 1. Environment variable with the exact key name
|
|
/// 2. System keyring
|
|
///
|
|
/// The value will be deserialized into the requested type. This works with
|
|
/// both simple types (String, i32, etc.) and complex types that implement
|
|
/// serde::Deserialize.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns a ConfigError if:
|
|
/// - The key doesn't exist in either environment or keyring
|
|
/// - The value cannot be deserialized into the requested type
|
|
/// - There is an error accessing the keyring
|
|
pub fn get_secret<T: for<'de> Deserialize<'de>>(&self, key: &str) -> Result<T, ConfigError> {
|
|
// First check environment variables (convert to uppercase)
|
|
let env_key = key.to_uppercase();
|
|
if let Ok(val) = env::var(&env_key) {
|
|
let value = Self::parse_env_value(&val)?;
|
|
return Ok(serde_json::from_value(value)?);
|
|
}
|
|
|
|
// Then check keyring
|
|
let values = self.all_secrets()?;
|
|
values
|
|
.get(key)
|
|
.ok_or_else(|| ConfigError::NotFound(key.to_string()))
|
|
.and_then(|v| Ok(serde_json::from_value(v.clone())?))
|
|
}
|
|
|
|
/// Get secrets. If primary is in env, use env for all keys. Otherwise, use secret storage.
|
|
pub fn get_secrets(
|
|
&self,
|
|
primary: &str,
|
|
maybe_secret: &[&str],
|
|
) -> Result<HashMap<String, String>, ConfigError> {
|
|
let use_env = env::var(primary.to_uppercase()).is_ok();
|
|
let get_value = |key: &str| -> Result<String, ConfigError> {
|
|
if use_env {
|
|
env::var(key.to_uppercase()).map_err(|_| ConfigError::NotFound(key.to_string()))
|
|
} else {
|
|
self.get_secret(key)
|
|
}
|
|
};
|
|
|
|
let mut result = HashMap::new();
|
|
result.insert(primary.to_string(), get_value(primary)?);
|
|
for &key in maybe_secret {
|
|
if let Ok(v) = get_value(key) {
|
|
result.insert(key.to_string(), v);
|
|
}
|
|
}
|
|
Ok(result)
|
|
}
|
|
|
|
fn write_all_secrets(&self, values: &HashMap<String, Value>) -> Result<(), ConfigError> {
|
|
match &self.secrets {
|
|
SecretStorage::Keyring { service } => {
|
|
let json_value = serde_json::to_string(values)?;
|
|
match self.handle_keyring_operation(
|
|
|entry| entry.set_password(&json_value),
|
|
service,
|
|
Some(values),
|
|
) {
|
|
Ok(_) => {}
|
|
Err(ConfigError::FallbackToFileStorage) => {}
|
|
Err(e) => return Err(e),
|
|
}
|
|
}
|
|
SecretStorage::File { path } => {
|
|
let yaml_value = serde_yaml::to_string(values)?;
|
|
write_secrets_file(path, &yaml_value)?;
|
|
}
|
|
}
|
|
|
|
self.invalidate_secrets_cache();
|
|
Ok(())
|
|
}
|
|
|
|
fn mutate_secrets(
|
|
&self,
|
|
mutate: impl FnOnce(&mut HashMap<String, Value>),
|
|
) -> Result<(), ConfigError> {
|
|
let _guard = self.guard.lock().unwrap();
|
|
let mut values = self.all_secrets()?;
|
|
mutate(&mut values);
|
|
self.write_all_secrets(&values)
|
|
}
|
|
|
|
/// Set a secret value in the system keyring.
|
|
///
|
|
/// This will store the value in a single JSON object in the system keyring,
|
|
/// alongside any other secrets. The value can be any type that can be
|
|
/// serialized to JSON.
|
|
///
|
|
/// Note that this does not affect environment variables - those can only
|
|
/// be set through the system environment.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns a ConfigError if:
|
|
/// - There is an error accessing the keyring
|
|
/// - There is an error serializing the value
|
|
pub fn set_secret<V>(&self, key: &str, value: &V) -> Result<(), ConfigError>
|
|
where
|
|
V: Serialize,
|
|
{
|
|
let value = serde_json::to_value(value)?;
|
|
self.mutate_secrets(|values| {
|
|
values.insert(key.to_string(), value);
|
|
})
|
|
}
|
|
|
|
/// Set multiple secret values with one storage read and one storage write.
|
|
///
|
|
/// This is intended for provider setup flows that save several fields at once.
|
|
/// It keeps keychain access batched while preserving the same storage format as
|
|
/// `set_secret`.
|
|
pub fn set_secret_values(&self, updates: &[(String, Value)]) -> Result<(), ConfigError> {
|
|
if updates.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
self.mutate_secrets(|values| {
|
|
for (key, value) in updates {
|
|
values.insert(key.clone(), value.clone());
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Delete a secret from the system keyring.
|
|
///
|
|
/// This will remove the specified key from the JSON object in the system keyring.
|
|
/// Other secrets will remain unchanged.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns a ConfigError if:
|
|
/// - There is an error accessing the keyring
|
|
/// - There is an error serializing the remaining values
|
|
pub fn delete_secret(&self, key: &str) -> Result<(), ConfigError> {
|
|
self.mutate_secrets(|values| {
|
|
values.remove(key);
|
|
})
|
|
}
|
|
|
|
/// Delete multiple secret values with one storage read and one storage write.
|
|
pub fn delete_secret_values(&self, keys: &[String]) -> Result<(), ConfigError> {
|
|
if keys.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
self.mutate_secrets(|values| {
|
|
for key in keys {
|
|
values.remove(key);
|
|
}
|
|
})
|
|
}
|
|
|
|
/// Read secrets from a YAML file
|
|
fn read_secrets_from_file(&self, path: &Path) -> Result<HashMap<String, Value>, ConfigError> {
|
|
if path.exists() {
|
|
let file_content = std::fs::read_to_string(path)?;
|
|
let yaml_value: serde_yaml::Value = serde_yaml::from_str(&file_content)?;
|
|
let json_value: Value = serde_json::to_value(yaml_value)?;
|
|
match json_value {
|
|
Value::Object(map) => Ok(map.into_iter().collect()),
|
|
_ => Ok(HashMap::new()),
|
|
}
|
|
} else {
|
|
Ok(HashMap::new())
|
|
}
|
|
}
|
|
|
|
/// Get the path to the secrets storage file
|
|
fn secrets_file_path() -> PathBuf {
|
|
Paths::config_dir().join("secrets.yaml")
|
|
}
|
|
|
|
/// Fall back to file storage when keyring is unavailable
|
|
fn fallback_to_file_storage(&self) -> Result<HashMap<String, Value>, ConfigError> {
|
|
let path = Self::secrets_file_path();
|
|
self.read_secrets_from_file(&path)
|
|
}
|
|
|
|
/// Write secrets to file storage (used for fallback)
|
|
fn write_secrets_to_file(&self, values: &HashMap<String, Value>) -> Result<(), ConfigError> {
|
|
std::fs::create_dir_all(Paths::config_dir())?;
|
|
let path = Self::secrets_file_path();
|
|
let yaml_value = serde_yaml::to_string(values)?;
|
|
write_secrets_file(&path, &yaml_value)?;
|
|
Ok(())
|
|
}
|
|
|
|
pub fn invalidate_secrets_cache(&self) {
|
|
let mut cache = self.secrets_cache.lock().unwrap();
|
|
*cache = None;
|
|
}
|
|
|
|
/// Check if an error string indicates a keyring availability issue that should trigger fallback
|
|
fn is_keyring_availability_error(&self, error_str: &str) -> bool {
|
|
let lower = error_str.to_lowercase();
|
|
lower.contains("keyring")
|
|
|| lower.contains("dbus")
|
|
|| lower.contains("org.freedesktop.secrets")
|
|
|| lower.contains("platform secure storage")
|
|
|| lower.contains("no secret service")
|
|
}
|
|
|
|
/// Get a keyring entry for the specified service
|
|
fn get_keyring_entry(service: &str) -> Result<keyring::Entry, keyring::Error> {
|
|
Entry::new(service, KEYRING_USERNAME)
|
|
}
|
|
|
|
/// Handle keyring errors with automatic fallback to file storage
|
|
fn handle_keyring_fallback_error<T>(
|
|
&self,
|
|
keyring_err: &keyring::Error,
|
|
fallback_values: Option<&HashMap<String, Value>>,
|
|
) -> Result<T, ConfigError> {
|
|
if self.is_keyring_availability_error(&keyring_err.to_string()) {
|
|
std::env::set_var("GOOSE_DISABLE_KEYRING", "1");
|
|
tracing::warn!("Keyring unavailable. Using file storage for secrets.");
|
|
|
|
if let Some(values) = fallback_values {
|
|
self.write_secrets_to_file(values)?;
|
|
Err(ConfigError::FallbackToFileStorage)
|
|
} else {
|
|
Err(ConfigError::FallbackToFileStorage)
|
|
}
|
|
} else {
|
|
Err(ConfigError::KeyringError(keyring_err.to_string()))
|
|
}
|
|
}
|
|
|
|
/// Handle keyring operation with automatic fallback to file storage
|
|
fn handle_keyring_operation<T>(
|
|
&self,
|
|
operation: impl FnOnce(keyring::Entry) -> Result<T, keyring::Error>,
|
|
service: &str,
|
|
fallback_values: Option<&HashMap<String, Value>>,
|
|
) -> Result<T, ConfigError> {
|
|
// Try to get the keyring entry and perform the operation
|
|
let entry = match Self::get_keyring_entry(service) {
|
|
Ok(entry) => entry,
|
|
Err(keyring_err) => {
|
|
return self.handle_keyring_fallback_error(&keyring_err, fallback_values);
|
|
}
|
|
};
|
|
|
|
// Perform the operation
|
|
match operation(entry) {
|
|
Ok(result) => Ok(result),
|
|
Err(keyring_err) => self.handle_keyring_fallback_error(&keyring_err, fallback_values),
|
|
}
|
|
}
|
|
}
|
|
|
|
config_value!(CLAUDE_CODE_COMMAND, String, "claude");
|
|
config_value!(GEMINI_CLI_COMMAND, String, "gemini");
|
|
config_value!(CURSOR_AGENT_COMMAND, String, "cursor-agent");
|
|
config_value!(CODEX_COMMAND, String, "codex");
|
|
config_value!(CODEX_REASONING_EFFORT, String, "high");
|
|
config_value!(CODEX_ENABLE_SKILLS, String, "true");
|
|
config_value!(CODEX_SKIP_GIT_CHECK, String, "false");
|
|
config_value!(CHATGPT_CODEX_REASONING_EFFORT, String, "medium");
|
|
|
|
config_value!(GOOSE_SEARCH_PATHS, Vec<String>);
|
|
config_value!(GOOSE_MODE, GooseMode);
|
|
config_value!(GOOSE_PROVIDER, String);
|
|
config_value!(GOOSE_MODEL, String);
|
|
config_value!(GOOSE_PROMPT_EDITOR, Option<String>);
|
|
config_value!(GOOSE_PROMPT_EDITOR_ALWAYS, Option<bool>);
|
|
config_value!(GOOSE_MAX_ACTIVE_AGENTS, usize);
|
|
config_value!(GOOSE_DISABLE_SESSION_NAMING, bool);
|
|
config_value!(GEMINI3_THINKING_LEVEL, String);
|
|
config_value!(CLAUDE_THINKING_TYPE, String);
|
|
config_value!(CLAUDE_THINKING_EFFORT, String);
|
|
config_value!(CLAUDE_THINKING_BUDGET, i32);
|
|
config_value!(GOOSE_DEFAULT_EXTENSION_TIMEOUT, u64);
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use serial_test::serial;
|
|
use tempfile::{NamedTempFile, TempDir};
|
|
#[test]
|
|
fn test_basic_config() -> Result<(), ConfigError> {
|
|
let config = new_test_config();
|
|
|
|
// Set a simple string value
|
|
config.set_param("test_key", "test_value")?;
|
|
|
|
// Test simple string retrieval
|
|
let value: String = config.get_param("test_key")?;
|
|
assert_eq!(value, "test_value");
|
|
|
|
// Test with environment variable override
|
|
std::env::set_var("TEST_KEY", "env_value");
|
|
let value: String = config.get_param("test_key")?;
|
|
assert_eq!(value, "env_value");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_complex_type() -> Result<(), ConfigError> {
|
|
#[derive(Deserialize, Debug, PartialEq)]
|
|
struct TestStruct {
|
|
field1: String,
|
|
field2: i32,
|
|
}
|
|
|
|
let config = new_test_config();
|
|
|
|
// Set a complex value
|
|
config.set_param(
|
|
"complex_key",
|
|
serde_json::json!({
|
|
"field1": "hello",
|
|
"field2": 42
|
|
}),
|
|
)?;
|
|
|
|
let value: TestStruct = config.get_param("complex_key")?;
|
|
assert_eq!(value.field1, "hello");
|
|
assert_eq!(value.field2, 42);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_missing_value() {
|
|
let config = new_test_config();
|
|
|
|
let result: Result<String, ConfigError> = config.get_param("nonexistent_key");
|
|
assert!(matches!(result, Err(ConfigError::NotFound(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn test_yaml_formatting() -> Result<(), ConfigError> {
|
|
let config_file = NamedTempFile::new().unwrap();
|
|
let secrets_file = NamedTempFile::new().unwrap();
|
|
let config = Config::new_with_file_secrets(config_file.path(), secrets_file.path())?;
|
|
|
|
config.set_param("key1", "value1")?;
|
|
config.set_param("key2", 42)?;
|
|
|
|
// Read the file directly to check YAML formatting
|
|
let content = std::fs::read_to_string(config_file.path())?;
|
|
assert!(content.contains("key1: value1"));
|
|
assert!(content.contains("key2: 42"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_value_management() -> Result<(), ConfigError> {
|
|
let config = new_test_config();
|
|
|
|
config.set_param("test_key", "test_value")?;
|
|
config.set_param("another_key", 42)?;
|
|
config.set_param("third_key", true)?;
|
|
|
|
let _values = config.load()?;
|
|
|
|
let result: Result<String, ConfigError> = config.get_param("key");
|
|
assert!(matches!(result, Err(ConfigError::NotFound(_))));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_file_based_secrets_management() -> Result<(), ConfigError> {
|
|
let config = new_test_config();
|
|
|
|
config.set_secret("key", &"value")?;
|
|
|
|
let value: String = config.get_secret("key")?;
|
|
assert_eq!(value, "value");
|
|
|
|
config.delete_secret("key")?;
|
|
|
|
let result: Result<String, ConfigError> = config.get_secret("key");
|
|
assert!(matches!(result, Err(ConfigError::NotFound(_))));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn test_secret_management() -> Result<(), ConfigError> {
|
|
let config = new_test_config();
|
|
|
|
// Test setting and getting a simple secret
|
|
config.set_secret("api_key", &Value::String("secret123".to_string()))?;
|
|
let value: String = config.get_secret("api_key")?;
|
|
assert_eq!(value, "secret123");
|
|
|
|
// Test environment variable override
|
|
std::env::set_var("API_KEY", "env_secret");
|
|
let value: String = config.get_secret("api_key")?;
|
|
assert_eq!(value, "env_secret");
|
|
std::env::remove_var("API_KEY");
|
|
|
|
// Test deleting a secret
|
|
config.delete_secret("api_key")?;
|
|
let result: Result<String, ConfigError> = config.get_secret("api_key");
|
|
assert!(matches!(result, Err(ConfigError::NotFound(_))));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_multiple_secrets() -> Result<(), ConfigError> {
|
|
let config = new_test_config();
|
|
|
|
// Set multiple secrets
|
|
config.set_secret("key1", &Value::String("secret1".to_string()))?;
|
|
config.set_secret("key2", &Value::String("secret2".to_string()))?;
|
|
|
|
// Verify both exist
|
|
let value1: String = config.get_secret("key1")?;
|
|
let value2: String = config.get_secret("key2")?;
|
|
assert_eq!(value1, "secret1");
|
|
assert_eq!(value2, "secret2");
|
|
|
|
// Delete one secret
|
|
config.delete_secret("key1")?;
|
|
|
|
// Verify key1 is gone but key2 remains
|
|
let result1: Result<String, ConfigError> = config.get_secret("key1");
|
|
let value2: String = config.get_secret("key2")?;
|
|
assert!(matches!(result1, Err(ConfigError::NotFound(_))));
|
|
assert_eq!(value2, "secret2");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_concurrent_writes() -> Result<(), ConfigError> {
|
|
use std::sync::{Arc, Barrier, Mutex};
|
|
use std::thread;
|
|
|
|
let config = Arc::new(new_test_config());
|
|
let barrier = Arc::new(Barrier::new(3)); // For 3 concurrent threads
|
|
let values = Arc::new(Mutex::new(Mapping::new()));
|
|
let mut handles = vec![];
|
|
|
|
// Initialize with empty values
|
|
config.save_values(&Default::default())?;
|
|
|
|
// Spawn 3 threads that will try to write simultaneously
|
|
for i in 0..3 {
|
|
let config = Arc::clone(&config);
|
|
let barrier = Arc::clone(&barrier);
|
|
let values = Arc::clone(&values);
|
|
let handle = thread::spawn(move || -> Result<(), ConfigError> {
|
|
// Wait for all threads to reach this point
|
|
barrier.wait();
|
|
|
|
// Get the lock and update values
|
|
let mut values = values.lock().unwrap();
|
|
values.insert(
|
|
serde_yaml::to_value(format!("key{}", i)).unwrap(),
|
|
serde_yaml::to_value(format!("value{}", i)).unwrap(),
|
|
);
|
|
|
|
// Write all values
|
|
config.save_values(&values)?;
|
|
Ok(())
|
|
});
|
|
handles.push(handle);
|
|
}
|
|
|
|
// Wait for all threads to complete
|
|
for handle in handles {
|
|
handle.join().unwrap()?;
|
|
}
|
|
|
|
// Verify all values were written correctly
|
|
let final_values = config.all_values()?;
|
|
|
|
// Print the final values for debugging
|
|
println!("Final values: {:?}", final_values);
|
|
|
|
// Check that our 3 keys are present (migrations may add additional keys like "extensions")
|
|
for i in 0..3 {
|
|
let key = format!("key{}", i);
|
|
let value = format!("value{}", i);
|
|
assert!(
|
|
final_values.contains_key(&key),
|
|
"Missing key {} in final values",
|
|
key
|
|
);
|
|
assert_eq!(
|
|
final_values.get(&key).unwrap(),
|
|
&Value::String(value),
|
|
"Incorrect value for key {}",
|
|
key
|
|
);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(unix)]
|
|
fn test_write_follows_symlink() -> Result<(), ConfigError> {
|
|
use std::os::unix::fs as unix_fs;
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
let target_path = dir.path().join("real_config.yaml");
|
|
let symlink_path = dir.path().join("config.yaml");
|
|
|
|
std::fs::write(&target_path, "{}\n")?;
|
|
unix_fs::symlink(&target_path, &symlink_path)?;
|
|
|
|
let secrets_file = NamedTempFile::new().unwrap();
|
|
let config = Config::new_with_file_secrets(&symlink_path, secrets_file.path())?;
|
|
|
|
config.set_param("key1", "value1")?;
|
|
|
|
let meta = std::fs::symlink_metadata(&symlink_path)?;
|
|
assert!(
|
|
meta.file_type().is_symlink(),
|
|
"config path should remain a symlink"
|
|
);
|
|
|
|
let content = std::fs::read_to_string(&symlink_path)?;
|
|
assert!(content.contains("key1: value1"));
|
|
|
|
let content = std::fs::read_to_string(&target_path)?;
|
|
assert!(content.contains("key1: value1"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(unix)]
|
|
fn test_write_fails_on_long_symlink_chain() -> Result<(), ConfigError> {
|
|
use std::os::unix::fs as unix_fs;
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
let target_path = dir.path().join("real_config.yaml");
|
|
std::fs::write(&target_path, "{}\n")?;
|
|
|
|
// config.yaml -> link1.yaml -> real_config.yaml
|
|
// We only allow following one symlink hop. If there's another symlink, we should fail
|
|
// rather than overwrite the intermediate symlink.
|
|
let config_symlink = dir.path().join("config.yaml");
|
|
let link1 = dir.path().join("link1.yaml");
|
|
unix_fs::symlink(&target_path, &link1)?;
|
|
unix_fs::symlink(&link1, &config_symlink)?;
|
|
|
|
let secrets_file = NamedTempFile::new().unwrap();
|
|
let config = Config::new_with_file_secrets(&config_symlink, secrets_file.path())?;
|
|
|
|
let err = config.set_param("key1", "value1").unwrap_err();
|
|
assert!(
|
|
err.to_string().contains("Too many symlink levels"),
|
|
"unexpected error: {err}"
|
|
);
|
|
|
|
let meta = std::fs::symlink_metadata(&config_symlink)?;
|
|
assert!(
|
|
meta.file_type().is_symlink(),
|
|
"config path should remain a symlink"
|
|
);
|
|
let meta = std::fs::symlink_metadata(&link1)?;
|
|
assert!(
|
|
meta.file_type().is_symlink(),
|
|
"intermediate link should remain a symlink"
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_corrupt_config_skipped_on_read() -> Result<(), ConfigError> {
|
|
let config_file = NamedTempFile::new().unwrap();
|
|
let secrets_file = NamedTempFile::new().unwrap();
|
|
let config = Config::new_with_file_secrets(config_file.path(), secrets_file.path())?;
|
|
|
|
std::fs::write(config_file.path(), "invalid: yaml: content: [unclosed")?;
|
|
|
|
// Reads skip corrupt files gracefully
|
|
let values = config.all_values()?;
|
|
assert!(values.is_empty() || !values.contains_key("key1"));
|
|
|
|
// A write starts fresh (corrupt content is discarded)
|
|
config.set_param("recovery_key", "value")?;
|
|
let reloaded = config.all_values()?;
|
|
assert!(reloaded.contains_key("recovery_key"));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_missing_config_created_on_write() -> Result<(), ConfigError> {
|
|
let config_file = NamedTempFile::new().unwrap();
|
|
let secrets_file = NamedTempFile::new().unwrap();
|
|
let config_path = config_file.path().to_path_buf();
|
|
let config = Config::new_with_file_secrets(&config_path, secrets_file.path())?;
|
|
|
|
std::fs::remove_file(&config_path)?;
|
|
assert!(!config_path.exists());
|
|
|
|
// Reads return empty when file is missing
|
|
let values = config.all_values()?;
|
|
assert!(values.is_empty() || !values.contains_key("key1"));
|
|
|
|
// A write creates the file
|
|
config.set_param("new_key", "new_value")?;
|
|
assert!(config_path.exists());
|
|
|
|
let file_content = std::fs::read_to_string(&config_path)?;
|
|
let parsed: serde_yaml::Value = serde_yaml::from_str(&file_content)?;
|
|
assert!(parsed.is_mapping());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_atomic_write_prevents_corruption() -> Result<(), ConfigError> {
|
|
let config_file = NamedTempFile::new().unwrap();
|
|
let secrets_file = NamedTempFile::new().unwrap();
|
|
let config = Config::new_with_file_secrets(config_file.path(), secrets_file.path())?;
|
|
|
|
// Set initial values
|
|
config.set_param("key1", "value1")?;
|
|
|
|
// Verify the config file exists and is valid
|
|
assert!(config_file.path().exists());
|
|
let content = std::fs::read_to_string(config_file.path())?;
|
|
assert!(serde_yaml::from_str::<serde_yaml::Value>(&content).is_ok());
|
|
|
|
// The temp file should not exist after successful write
|
|
let temp_path = config_file.path().with_extension("tmp");
|
|
assert!(!temp_path.exists(), "Temporary file should be cleaned up");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_env_var_parsing_strings() -> Result<(), ConfigError> {
|
|
// Test unquoted strings
|
|
let value = Config::parse_env_value("ANTHROPIC")?;
|
|
assert_eq!(value, Value::String("ANTHROPIC".to_string()));
|
|
|
|
// Test strings with spaces
|
|
let value = Config::parse_env_value("hello world")?;
|
|
assert_eq!(value, Value::String("hello world".to_string()));
|
|
|
|
// Test JSON quoted strings
|
|
let value = Config::parse_env_value("\"ANTHROPIC\"")?;
|
|
assert_eq!(value, Value::String("ANTHROPIC".to_string()));
|
|
|
|
// Test empty string
|
|
let value = Config::parse_env_value("")?;
|
|
assert_eq!(value, Value::String("".to_string()));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_env_var_parsing_numbers() -> Result<(), ConfigError> {
|
|
// Test integers
|
|
let value = Config::parse_env_value("42")?;
|
|
assert_eq!(value, Value::Number(42.into()));
|
|
|
|
let value = Config::parse_env_value("-123")?;
|
|
assert_eq!(value, Value::Number((-123).into()));
|
|
|
|
// Test floats
|
|
let value = Config::parse_env_value("3.41")?;
|
|
assert!(matches!(value, Value::Number(_)));
|
|
if let Value::Number(n) = value {
|
|
assert_eq!(n.as_f64().unwrap(), 3.41);
|
|
}
|
|
|
|
let value = Config::parse_env_value("0.01")?;
|
|
assert!(matches!(value, Value::Number(_)));
|
|
if let Value::Number(n) = value {
|
|
assert_eq!(n.as_f64().unwrap(), 0.01);
|
|
}
|
|
|
|
// Test zero
|
|
let value = Config::parse_env_value("0")?;
|
|
assert_eq!(value, Value::Number(0.into()));
|
|
|
|
let value = Config::parse_env_value("0.0")?;
|
|
assert!(matches!(value, Value::Number(_)));
|
|
if let Value::Number(n) = value {
|
|
assert_eq!(n.as_f64().unwrap(), 0.0);
|
|
}
|
|
|
|
// Test numbers starting with decimal point
|
|
let value = Config::parse_env_value(".5")?;
|
|
assert!(matches!(value, Value::Number(_)));
|
|
if let Value::Number(n) = value {
|
|
assert_eq!(n.as_f64().unwrap(), 0.5);
|
|
}
|
|
|
|
let value = Config::parse_env_value(".00001")?;
|
|
assert!(matches!(value, Value::Number(_)));
|
|
if let Value::Number(n) = value {
|
|
assert_eq!(n.as_f64().unwrap(), 0.00001);
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_env_var_parsing_booleans() -> Result<(), ConfigError> {
|
|
// Test true variants
|
|
let value = Config::parse_env_value("true")?;
|
|
assert_eq!(value, Value::Bool(true));
|
|
|
|
let value = Config::parse_env_value("True")?;
|
|
assert_eq!(value, Value::Bool(true));
|
|
|
|
let value = Config::parse_env_value("TRUE")?;
|
|
assert_eq!(value, Value::Bool(true));
|
|
|
|
// Test false variants
|
|
let value = Config::parse_env_value("false")?;
|
|
assert_eq!(value, Value::Bool(false));
|
|
|
|
let value = Config::parse_env_value("False")?;
|
|
assert_eq!(value, Value::Bool(false));
|
|
|
|
let value = Config::parse_env_value("FALSE")?;
|
|
assert_eq!(value, Value::Bool(false));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_env_var_parsing_json() -> Result<(), ConfigError> {
|
|
// Test JSON objects
|
|
let value = Config::parse_env_value("{\"host\": \"localhost\", \"port\": 8080}")?;
|
|
assert!(matches!(value, Value::Object(_)));
|
|
if let Value::Object(obj) = value {
|
|
assert_eq!(
|
|
obj.get("host"),
|
|
Some(&Value::String("localhost".to_string()))
|
|
);
|
|
assert_eq!(obj.get("port"), Some(&Value::Number(8080.into())));
|
|
}
|
|
|
|
// Test JSON arrays
|
|
let value = Config::parse_env_value("[1, 2, 3]")?;
|
|
assert!(matches!(value, Value::Array(_)));
|
|
if let Value::Array(arr) = value {
|
|
assert_eq!(arr.len(), 3);
|
|
assert_eq!(arr[0], Value::Number(1.into()));
|
|
assert_eq!(arr[1], Value::Number(2.into()));
|
|
assert_eq!(arr[2], Value::Number(3.into()));
|
|
}
|
|
|
|
// Test JSON null
|
|
let value = Config::parse_env_value("null")?;
|
|
assert_eq!(value, Value::Null);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_env_var_parsing_edge_cases() -> Result<(), ConfigError> {
|
|
// Test whitespace handling
|
|
let value = Config::parse_env_value(" 42 ")?;
|
|
assert_eq!(value, Value::Number(42.into()));
|
|
|
|
let value = Config::parse_env_value(" true ")?;
|
|
assert_eq!(value, Value::Bool(true));
|
|
|
|
// Test strings that look like numbers but aren't
|
|
let value = Config::parse_env_value("123abc")?;
|
|
assert_eq!(value, Value::String("123abc".to_string()));
|
|
|
|
let value = Config::parse_env_value("abc123")?;
|
|
assert_eq!(value, Value::String("abc123".to_string()));
|
|
|
|
// Test strings that look like booleans but aren't
|
|
let value = Config::parse_env_value("truthy")?;
|
|
assert_eq!(value, Value::String("truthy".to_string()));
|
|
|
|
let value = Config::parse_env_value("falsy")?;
|
|
assert_eq!(value, Value::String("falsy".to_string()));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_env_var_parsing_numeric_edge_cases() -> Result<(), ConfigError> {
|
|
// Test leading zeros (should be treated as integers, not octal)
|
|
let value = Config::parse_env_value("007")?;
|
|
assert_eq!(value, Value::Number(7.into()));
|
|
|
|
// Test large numbers
|
|
let value = Config::parse_env_value("9223372036854775807")?; // i64::MAX
|
|
assert_eq!(value, Value::Number(9223372036854775807i64.into()));
|
|
|
|
// Test scientific notation (JSON parsing should handle this correctly)
|
|
let value = Config::parse_env_value("1e10")?;
|
|
assert!(matches!(value, Value::Number(_)));
|
|
if let Value::Number(n) = value {
|
|
assert_eq!(n.as_f64().unwrap(), 1e10);
|
|
}
|
|
|
|
// Test infinity (should be treated as string)
|
|
let value = Config::parse_env_value("inf")?;
|
|
assert_eq!(value, Value::String("inf".to_string()));
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_env_var_with_config_integration() -> Result<(), ConfigError> {
|
|
let config = new_test_config();
|
|
|
|
// Test string environment variable (the original issue case)
|
|
std::env::set_var("PROVIDER", "ANTHROPIC");
|
|
let value: String = config.get_param("provider")?;
|
|
assert_eq!(value, "ANTHROPIC");
|
|
|
|
// Test number environment variable
|
|
std::env::set_var("PORT", "8080");
|
|
let value: i32 = config.get_param("port")?;
|
|
assert_eq!(value, 8080);
|
|
|
|
// Test boolean environment variable
|
|
std::env::set_var("ENABLED", "true");
|
|
let value: bool = config.get_param("enabled")?;
|
|
assert!(value);
|
|
|
|
// Test JSON object environment variable
|
|
std::env::set_var("CONFIG", "{\"debug\": true, \"level\": 5}");
|
|
#[derive(Deserialize, Debug, PartialEq)]
|
|
struct TestConfig {
|
|
debug: bool,
|
|
level: i32,
|
|
}
|
|
let value: TestConfig = config.get_param("config")?;
|
|
assert!(value.debug);
|
|
assert_eq!(value.level, 5);
|
|
|
|
// Clean up
|
|
std::env::remove_var("PROVIDER");
|
|
std::env::remove_var("PORT");
|
|
std::env::remove_var("ENABLED");
|
|
std::env::remove_var("CONFIG");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_env_var_precedence_over_config_file() -> Result<(), ConfigError> {
|
|
let config = new_test_config();
|
|
|
|
// Set value in config file
|
|
config.set_param("test_precedence", "file_value")?;
|
|
|
|
// Verify file value is returned when no env var
|
|
let value: String = config.get_param("test_precedence")?;
|
|
assert_eq!(value, "file_value");
|
|
|
|
// Set environment variable
|
|
std::env::set_var("TEST_PRECEDENCE", "env_value");
|
|
|
|
// Environment variable should take precedence
|
|
let value: String = config.get_param("test_precedence")?;
|
|
assert_eq!(value, "env_value");
|
|
|
|
// Clean up
|
|
std::env::remove_var("TEST_PRECEDENCE");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn get_secrets_primary_from_env_uses_env_for_secondary() {
|
|
let _guard = env_lock::lock_env([
|
|
("TEST_PRIMARY", Some("primary_env")),
|
|
("TEST_SECONDARY", Some("secondary_env")),
|
|
]);
|
|
let config = new_test_config();
|
|
let secrets = config
|
|
.get_secrets("TEST_PRIMARY", &["TEST_SECONDARY"])
|
|
.unwrap();
|
|
|
|
assert_eq!(secrets["TEST_PRIMARY"], "primary_env");
|
|
assert_eq!(secrets["TEST_SECONDARY"], "secondary_env");
|
|
}
|
|
|
|
#[test]
|
|
fn get_secrets_primary_from_secret_uses_secret_for_secondary() {
|
|
let _guard = env_lock::lock_env([("TEST_PRIMARY", None::<&str>), ("TEST_SECONDARY", None)]);
|
|
let config = new_test_config();
|
|
config
|
|
.set_secret("TEST_PRIMARY", &"primary_secret")
|
|
.unwrap();
|
|
config
|
|
.set_secret("TEST_SECONDARY", &"secondary_secret")
|
|
.unwrap();
|
|
|
|
let secrets = config
|
|
.get_secrets("TEST_PRIMARY", &["TEST_SECONDARY"])
|
|
.unwrap();
|
|
|
|
assert_eq!(secrets["TEST_PRIMARY"], "primary_secret");
|
|
assert_eq!(secrets["TEST_SECONDARY"], "secondary_secret");
|
|
}
|
|
|
|
#[test]
|
|
fn get_secrets_primary_missing_returns_error() {
|
|
let _guard = env_lock::lock_env([("TEST_PRIMARY", None::<&str>)]);
|
|
let config = new_test_config();
|
|
|
|
let result = config.get_secrets("TEST_PRIMARY", &[]);
|
|
|
|
assert!(matches!(result, Err(ConfigError::NotFound(_))));
|
|
}
|
|
|
|
fn new_test_config() -> Config {
|
|
let config_file = NamedTempFile::new().unwrap();
|
|
let secrets_file = NamedTempFile::new().unwrap();
|
|
Config::new_with_file_secrets(config_file.path(), secrets_file.path()).unwrap()
|
|
}
|
|
|
|
/// Create a test config where `base_content` is a lower-priority layer
|
|
/// and the actual writable config is a separate (initially empty) file.
|
|
fn new_test_config_with_base(base_content: &str) -> (Config, NamedTempFile) {
|
|
let base_file = NamedTempFile::new().unwrap();
|
|
let config_file = NamedTempFile::new().unwrap();
|
|
let secrets_file = NamedTempFile::new().unwrap();
|
|
std::fs::write(base_file.path(), base_content).unwrap();
|
|
let config = Config::new_with_config_paths(
|
|
vec![
|
|
base_file.path().to_path_buf(),
|
|
config_file.path().to_path_buf(),
|
|
],
|
|
secrets_file.path(),
|
|
)
|
|
.unwrap();
|
|
(config, base_file)
|
|
}
|
|
|
|
#[test]
|
|
fn test_defaults_fallback_when_key_not_in_config() -> Result<(), ConfigError> {
|
|
let (config, _defaults) =
|
|
new_test_config_with_base("SECURITY_PROMPT_ENABLED: true\nsome_key: default_val");
|
|
|
|
// Key only in defaults → returns defaults value
|
|
let value: bool = config.get_param("SECURITY_PROMPT_ENABLED")?;
|
|
assert!(value);
|
|
|
|
let value: String = config.get_param("some_key")?;
|
|
assert_eq!(value, "default_val");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[serial]
|
|
fn test_full_precedence_env_over_config_over_defaults() -> Result<(), ConfigError> {
|
|
let (config, _defaults) = new_test_config_with_base("my_key: from_defaults");
|
|
|
|
// Only defaults → returns defaults
|
|
let value: String = config.get_param("my_key")?;
|
|
assert_eq!(value, "from_defaults");
|
|
|
|
// Config file overrides defaults
|
|
config.set_param("my_key", "from_config")?;
|
|
let value: String = config.get_param("my_key")?;
|
|
assert_eq!(value, "from_config");
|
|
|
|
// Env var overrides config file (and defaults)
|
|
std::env::set_var("MY_KEY", "from_env");
|
|
let value: String = config.get_param("my_key")?;
|
|
assert_eq!(value, "from_env");
|
|
std::env::remove_var("MY_KEY");
|
|
|
|
// After removing env var, config file value is back
|
|
let value: String = config.get_param("my_key")?;
|
|
assert_eq!(value, "from_config");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_missing_key_returns_not_found() {
|
|
let config = new_test_config();
|
|
|
|
let result: Result<String, ConfigError> = config.get_param("nonexistent_key");
|
|
assert!(matches!(result, Err(ConfigError::NotFound(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn test_lower_priority_values_not_persisted_on_write() -> Result<(), ConfigError> {
|
|
let (config, _base) = new_test_config_with_base("base_key: base_value");
|
|
|
|
// Read a value from the base layer (should work)
|
|
let value: String = config.get_param("base_key")?;
|
|
assert_eq!(value, "base_value");
|
|
|
|
// Write a different key to the user config
|
|
config.set_param("user_key", "user_value")?;
|
|
|
|
// Read user config file directly - should NOT contain base_key
|
|
let config_path = PathBuf::from(config.path());
|
|
let file_content = std::fs::read_to_string(&config_path)?;
|
|
assert!(
|
|
!file_content.contains("base_key"),
|
|
"Base layer values should not be persisted to user config on write"
|
|
);
|
|
assert!(
|
|
file_content.contains("user_key"),
|
|
"User's key should be in config file"
|
|
);
|
|
|
|
// But reading via get_param should still return the base value
|
|
let value: String = config.get_param("base_key")?;
|
|
assert_eq!(value, "base_value");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
#[cfg(unix)]
|
|
fn test_secrets_file_created_with_restricted_permissions() -> Result<(), ConfigError> {
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
let dir = TempDir::new().unwrap();
|
|
let config_file = NamedTempFile::new().unwrap();
|
|
let secrets_path = dir.path().join("secrets.yaml");
|
|
|
|
let config = Config::new_with_file_secrets(config_file.path(), &secrets_path)?;
|
|
config.set_secret("key", &"value")?;
|
|
|
|
let mode = std::fs::metadata(&secrets_path)?.permissions().mode() & 0o777;
|
|
assert_eq!(mode, 0o600);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_config_values_basic_override() {
|
|
let mut base = Mapping::new();
|
|
base.insert(
|
|
serde_yaml::Value::String("key1".into()),
|
|
serde_yaml::Value::String("base_value".into()),
|
|
);
|
|
base.insert(
|
|
serde_yaml::Value::String("key2".into()),
|
|
serde_yaml::Value::String("keep_me".into()),
|
|
);
|
|
|
|
let mut overlay = Mapping::new();
|
|
overlay.insert(
|
|
serde_yaml::Value::String("key1".into()),
|
|
serde_yaml::Value::String("overlay_value".into()),
|
|
);
|
|
overlay.insert(
|
|
serde_yaml::Value::String("key3".into()),
|
|
serde_yaml::Value::String("new_value".into()),
|
|
);
|
|
|
|
merge_config_values(&mut base, overlay);
|
|
|
|
assert_eq!(base.get("key1").unwrap().as_str().unwrap(), "overlay_value");
|
|
assert_eq!(base.get("key2").unwrap().as_str().unwrap(), "keep_me");
|
|
assert_eq!(base.get("key3").unwrap().as_str().unwrap(), "new_value");
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_extensions_append_new() {
|
|
let mut base = Mapping::new();
|
|
let mut base_ext = Mapping::new();
|
|
let mut ext_a = Mapping::new();
|
|
ext_a.insert(
|
|
serde_yaml::Value::String("enabled".into()),
|
|
serde_yaml::Value::Bool(true),
|
|
);
|
|
ext_a.insert(
|
|
serde_yaml::Value::String("type".into()),
|
|
serde_yaml::Value::String("builtin".into()),
|
|
);
|
|
base_ext.insert(
|
|
serde_yaml::Value::String("ext_a".into()),
|
|
serde_yaml::Value::Mapping(ext_a),
|
|
);
|
|
base.insert(
|
|
serde_yaml::Value::String("extensions".into()),
|
|
serde_yaml::Value::Mapping(base_ext),
|
|
);
|
|
|
|
let mut overlay = Mapping::new();
|
|
let mut overlay_ext = Mapping::new();
|
|
let mut ext_b = Mapping::new();
|
|
ext_b.insert(
|
|
serde_yaml::Value::String("enabled".into()),
|
|
serde_yaml::Value::Bool(true),
|
|
);
|
|
ext_b.insert(
|
|
serde_yaml::Value::String("type".into()),
|
|
serde_yaml::Value::String("stdio".into()),
|
|
);
|
|
overlay_ext.insert(
|
|
serde_yaml::Value::String("ext_b".into()),
|
|
serde_yaml::Value::Mapping(ext_b),
|
|
);
|
|
overlay.insert(
|
|
serde_yaml::Value::String("extensions".into()),
|
|
serde_yaml::Value::Mapping(overlay_ext),
|
|
);
|
|
|
|
merge_config_values(&mut base, overlay);
|
|
|
|
let extensions = base.get("extensions").unwrap().as_mapping().unwrap();
|
|
assert!(extensions.contains_key("ext_a"));
|
|
assert!(extensions.contains_key("ext_b"));
|
|
// ext_a should be unchanged
|
|
let a = extensions.get("ext_a").unwrap().as_mapping().unwrap();
|
|
assert!(a.get("enabled").unwrap().as_bool().unwrap());
|
|
}
|
|
|
|
#[test]
|
|
fn test_merge_extensions_partial_override() {
|
|
// Base has ext_a enabled with several fields
|
|
let mut base = Mapping::new();
|
|
let mut base_ext = Mapping::new();
|
|
let mut ext_a = Mapping::new();
|
|
ext_a.insert(
|
|
serde_yaml::Value::String("enabled".into()),
|
|
serde_yaml::Value::Bool(true),
|
|
);
|
|
ext_a.insert(
|
|
serde_yaml::Value::String("type".into()),
|
|
serde_yaml::Value::String("builtin".into()),
|
|
);
|
|
ext_a.insert(
|
|
serde_yaml::Value::String("name".into()),
|
|
serde_yaml::Value::String("My Extension".into()),
|
|
);
|
|
base_ext.insert(
|
|
serde_yaml::Value::String("my_ext".into()),
|
|
serde_yaml::Value::Mapping(ext_a),
|
|
);
|
|
base.insert(
|
|
serde_yaml::Value::String("extensions".into()),
|
|
serde_yaml::Value::Mapping(base_ext),
|
|
);
|
|
|
|
// Overlay just disables it with a partial entry
|
|
let mut overlay = Mapping::new();
|
|
let mut overlay_ext = Mapping::new();
|
|
let mut ext_override = Mapping::new();
|
|
ext_override.insert(
|
|
serde_yaml::Value::String("enabled".into()),
|
|
serde_yaml::Value::Bool(false),
|
|
);
|
|
overlay_ext.insert(
|
|
serde_yaml::Value::String("my_ext".into()),
|
|
serde_yaml::Value::Mapping(ext_override),
|
|
);
|
|
overlay.insert(
|
|
serde_yaml::Value::String("extensions".into()),
|
|
serde_yaml::Value::Mapping(overlay_ext),
|
|
);
|
|
|
|
merge_config_values(&mut base, overlay);
|
|
|
|
let extensions = base.get("extensions").unwrap().as_mapping().unwrap();
|
|
let my_ext = extensions.get("my_ext").unwrap().as_mapping().unwrap();
|
|
|
|
// enabled should be overridden to false
|
|
assert!(!my_ext.get("enabled").unwrap().as_bool().unwrap());
|
|
// Other fields should be preserved
|
|
assert_eq!(my_ext.get("type").unwrap().as_str().unwrap(), "builtin");
|
|
assert_eq!(
|
|
my_ext.get("name").unwrap().as_str().unwrap(),
|
|
"My Extension"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_multi_path_config_loading() -> Result<(), ConfigError> {
|
|
let base_file = NamedTempFile::new().unwrap();
|
|
let user_file = NamedTempFile::new().unwrap();
|
|
let secrets_file = NamedTempFile::new().unwrap();
|
|
|
|
// Base (system) config
|
|
std::fs::write(
|
|
base_file.path(),
|
|
"GOOSE_PROVIDER: openai\nGOOSE_MODEL: gpt-4\n",
|
|
)
|
|
.unwrap();
|
|
|
|
// User config overrides model
|
|
std::fs::write(user_file.path(), "GOOSE_MODEL: gpt-4o\n").unwrap();
|
|
|
|
let config = Config::new_with_config_paths(
|
|
vec![
|
|
base_file.path().to_path_buf(),
|
|
user_file.path().to_path_buf(),
|
|
],
|
|
secrets_file.path(),
|
|
)?;
|
|
|
|
// GOOSE_MODEL should be overridden by later config
|
|
let model: String = config.get_param("GOOSE_MODEL")?;
|
|
assert_eq!(model, "gpt-4o");
|
|
|
|
// GOOSE_PROVIDER should still come from base
|
|
let provider: String = config.get_param("GOOSE_PROVIDER")?;
|
|
assert_eq!(provider, "openai");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_extension_merge_across_configs() -> Result<(), ConfigError> {
|
|
let base_file = NamedTempFile::new().unwrap();
|
|
let local_file = NamedTempFile::new().unwrap();
|
|
let secrets_file = NamedTempFile::new().unwrap();
|
|
|
|
// System config (lower priority) has developer extension enabled
|
|
std::fs::write(
|
|
base_file.path(),
|
|
r#"
|
|
extensions:
|
|
developer:
|
|
enabled: true
|
|
type: builtin
|
|
name: Developer
|
|
description: "Core developer tools"
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// User config (higher priority / write target) has already migrated, then disables
|
|
// developer and adds a new extension.
|
|
std::fs::write(
|
|
local_file.path(),
|
|
r#"
|
|
extensions_on_demand_migration: true
|
|
extensions:
|
|
developer:
|
|
enabled: false
|
|
my_custom_ext:
|
|
enabled: true
|
|
type: stdio
|
|
name: MyCustom
|
|
cmd: /usr/bin/my-ext
|
|
"#,
|
|
)
|
|
.unwrap();
|
|
|
|
// local_file is last = write target and highest priority
|
|
let config = Config::new_with_config_paths(
|
|
vec![
|
|
base_file.path().to_path_buf(),
|
|
local_file.path().to_path_buf(),
|
|
],
|
|
secrets_file.path(),
|
|
)?;
|
|
|
|
let values = config.load()?;
|
|
let extensions = values.get("extensions").unwrap().as_mapping().unwrap();
|
|
|
|
// developer should be disabled (user config overrides system after migration)
|
|
let dev = extensions.get("developer").unwrap().as_mapping().unwrap();
|
|
assert!(!dev.get("enabled").unwrap().as_bool().unwrap());
|
|
// Fields from the system config should be preserved via merge
|
|
assert!(dev.get("name").is_some());
|
|
|
|
// my_custom_ext should be present from user config
|
|
let custom = extensions
|
|
.get("my_custom_ext")
|
|
.unwrap()
|
|
.as_mapping()
|
|
.unwrap();
|
|
assert!(custom.get("enabled").unwrap().as_bool().unwrap());
|
|
assert_eq!(custom.get("type").unwrap().as_str().unwrap(), "stdio");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_three_config_layers_ordered() -> Result<(), ConfigError> {
|
|
let system_file = NamedTempFile::new().unwrap();
|
|
let user_file = NamedTempFile::new().unwrap();
|
|
let local_file = NamedTempFile::new().unwrap();
|
|
let secrets_file = NamedTempFile::new().unwrap();
|
|
|
|
std::fs::write(system_file.path(), "key: system\n").unwrap();
|
|
std::fs::write(user_file.path(), "key: user\n").unwrap();
|
|
std::fs::write(local_file.path(), "key: local\n").unwrap();
|
|
|
|
let config = Config::new_with_config_paths(
|
|
vec![
|
|
system_file.path().to_path_buf(),
|
|
user_file.path().to_path_buf(),
|
|
local_file.path().to_path_buf(),
|
|
],
|
|
secrets_file.path(),
|
|
)?;
|
|
|
|
let value: String = config.get_param("key")?;
|
|
assert_eq!(value, "local");
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_missing_config_path_is_skipped() -> Result<(), ConfigError> {
|
|
let config_file = NamedTempFile::new().unwrap();
|
|
let secrets_file = NamedTempFile::new().unwrap();
|
|
|
|
std::fs::write(config_file.path(), "key: base\n").unwrap();
|
|
|
|
let config = Config::new_with_config_paths(
|
|
vec![
|
|
PathBuf::from("/tmp/nonexistent_goose_config.yaml"),
|
|
config_file.path().to_path_buf(),
|
|
],
|
|
secrets_file.path(),
|
|
)?;
|
|
|
|
let value: String = config.get_param("key")?;
|
|
assert_eq!(value, "base");
|
|
|
|
Ok(())
|
|
}
|
|
}
|