use std::collections::HashMap;
use std::io::Write;
use std::path::Path;
use agentic_core::McpServerEntry;
use agentic_core::config::CONFIG_FILE_NAME;
use agentic_core::error::Error;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct WebSearchFileConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub base_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub api_key_env: Option<String>,
}
impl WebSearchFileConfig {
fn is_empty(&self) -> bool {
self.base_url.is_none() && self.api_key_env.is_none()
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct McpFileConfig {
#[serde(skip_serializing_if = "Vec::is_empty")]
pub allowed_hosts: Vec<String>,
}
impl McpFileConfig {
fn is_empty(&self) -> bool {
self.allowed_hosts.is_empty()
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct MessagesGatewayFileConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub tool_aliases: Option<String>,
}
impl MessagesGatewayFileConfig {
fn is_empty(&self) -> bool {
self.tool_aliases.is_none()
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
#[serde(default, deny_unknown_fields)]
pub(crate) struct FileConfig {
#[serde(skip_serializing_if = "Option::is_none")]
pub llm_api_base: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub database_url: Option<String>,
#[serde(skip_serializing_if = "WebSearchFileConfig::is_empty")]
pub web_search: WebSearchFileConfig,
#[serde(skip_serializing_if = "McpFileConfig::is_empty")]
pub mcp: McpFileConfig,
#[serde(skip_serializing_if = "MessagesGatewayFileConfig::is_empty")]
pub messages_gateway: MessagesGatewayFileConfig,
#[serde(skip_serializing_if = "HashMap::is_empty")]
pub mcp_servers: HashMap<String, McpServerEntry>,
}
fn is_unwritable_home(error: &std::io::Error) -> bool {
matches!(
error.kind(),
std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem
) || error.raw_os_error() == Some(30) }
impl FileConfig {
pub(crate) fn load(home: &Path) -> Result<Option<Self>, Error> {
let path = home.join(CONFIG_FILE_NAME);
let contents = match std::fs::read_to_string(&path) {
Ok(contents) => contents,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => {
return Err(Error::Config(format!(
"failed to read configuration file {}: {error}",
path.display()
)));
}
};
let config = toml::from_str::<Self>(&contents).map_err(|error| {
Error::Config(format!(
"failed to parse configuration file {}: {error}",
path.display()
))
})?;
config.validate(&path)?;
Ok(Some(config))
}
pub(crate) fn create_or_load(self, home: &Path) -> Result<Self, Error> {
let path = home.join(CONFIG_FILE_NAME);
let body = toml::to_string_pretty(&self)
.map_err(|error| Error::Config(format!("failed to serialize default configuration: {error}")))?;
let contents = format!(
"# Generated by agentic-server from the first invocation with a resolved LLM base URL.\n\
# CLI arguments and process environment variables take precedence.\n\
# Secret values are read from referenced environment variables and are not written here.\n\n{body}"
);
let mut file = match tempfile::Builder::new().prefix(".agentic-config-").tempfile_in(home) {
Ok(file) => file,
Err(error) if is_unwritable_home(&error) => {
tracing::warn!(
home = %home.display(),
error = %error,
"Agentic API home is not writable; continuing with the generated configuration in memory"
);
return Ok(self);
}
Err(error) => {
return Err(Error::Config(format!(
"failed to create temporary configuration file: {error}"
)));
}
};
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
file.as_file()
.set_permissions(std::fs::Permissions::from_mode(0o640))
.map_err(|error| Error::Config(format!("failed to set configuration file permissions: {error}")))?;
}
if let Err(error) = file.write_all(contents.as_bytes()) {
return Err(Error::Config(format!(
"failed to write temporary configuration file: {error}"
)));
}
file.as_file()
.sync_all()
.map_err(|error| Error::Config(format!("failed to sync temporary configuration file: {error}")))?;
if let Err(error) = file.persist_noclobber(&path) {
if error.error.kind() == std::io::ErrorKind::AlreadyExists {
return Self::load(home)?.ok_or_else(|| {
Error::Config(format!(
"configuration file {} disappeared while it was being loaded",
path.display()
))
});
}
return Err(Error::Config(format!(
"failed to install configuration file {}: {}",
path.display(),
error.error
)));
}
#[cfg(unix)]
std::fs::File::open(home)
.and_then(|directory| directory.sync_all())
.map_err(|error| Error::Config(format!("failed to sync configuration directory: {error}")))?;
Ok(self)
}
fn validate(&self, path: &Path) -> Result<(), Error> {
if self
.web_search
.api_key_env
.as_deref()
.is_some_and(|name| name.trim().is_empty())
{
return Err(Error::Config(format!(
"configuration file {} contains an empty web_search.api_key_env",
path.display()
)));
}
if let Some(host) = self.mcp.allowed_hosts.iter().find(|host| host.trim().is_empty()) {
return Err(Error::Config(format!(
"configuration file {} contains an empty MCP allowed host: {host:?}",
path.display()
)));
}
if let Some(label) = self.mcp_servers.keys().find(|label| label.trim().is_empty()) {
return Err(Error::Config(format!(
"configuration file {} contains an empty MCP server label: {label:?}",
path.display()
)));
}
for (label, server) in &self.mcp_servers {
if let Some(allowed_tools) = server.allowed_tools() {
if allowed_tools.is_empty() {
return Err(Error::Config(format!(
"configuration file {} contains an empty allowed_tools list for MCP server {label:?}",
path.display()
)));
}
if let Some(tool) = allowed_tools.iter().find(|tool| tool.trim().is_empty()) {
return Err(Error::Config(format!(
"configuration file {} contains an empty allowed tool for MCP server {label:?}: {tool:?}",
path.display()
)));
}
}
if server.require_approval().is_some_and(|policy| policy != "never") {
return Err(Error::Config(format!(
"configuration file {} sets unsupported require_approval for MCP server {label:?}; only 'never' is supported",
path.display()
)));
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use std::fs;
use agentic_core::McpServerEntry;
use tempfile::tempdir;
use super::{FileConfig, McpFileConfig, WebSearchFileConfig};
#[test]
fn missing_config_file_uses_defaults() {
let home = tempdir().expect("temp home");
let config = FileConfig::load(home.path()).expect("missing config is optional");
assert!(config.is_none());
}
#[test]
fn creates_secret_free_default_config_from_runtime_settings() {
let home = tempdir().expect("temp home");
let defaults = FileConfig {
llm_api_base: Some("http://127.0.0.1:5050".to_owned()),
web_search: WebSearchFileConfig {
base_url: Some("https://api.ydc-index.io".to_owned()),
api_key_env: Some("YOU_API_KEY".to_owned()),
},
mcp: McpFileConfig {
allowed_hosts: vec!["mcp.example.com".to_owned()],
},
..FileConfig::default()
};
let config = defaults.create_or_load(home.path()).expect("create config");
let contents = fs::read_to_string(home.path().join("config.toml")).expect("read generated config");
assert_eq!(config.llm_api_base.as_deref(), Some("http://127.0.0.1:5050"));
assert!(contents.contains("llm_api_base = \"http://127.0.0.1:5050\""));
assert!(contents.contains("[web_search]"));
assert!(contents.contains("api_key_env = \"YOU_API_KEY\""));
assert!(contents.contains("allowed_hosts = [\"mcp.example.com\"]"));
assert!(!contents.contains("YOU_API_KEY ="));
assert!(!contents.contains("[mcp_servers]"));
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(home.path().join("config.toml"))
.expect("config metadata")
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o640);
}
}
#[test]
fn loads_typed_settings_and_mcp_servers() {
let home = tempdir().expect("temp home");
let fixture = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/typed-config.toml");
fs::copy(fixture, home.path().join("config.toml")).expect("copy typed config fixture");
let config = FileConfig::load(home.path())
.expect("load config")
.expect("existing config");
assert_eq!(config.llm_api_base.as_deref(), Some("http://127.0.0.1:8000/v1"));
assert_eq!(config.database_url.as_deref(), Some("sqlite:///tmp/agentic.db"));
assert_eq!(config.web_search.api_key_env.as_deref(), Some("YOU_API_KEY"));
assert_eq!(config.mcp.allowed_hosts, vec!["mcp.example.com"]);
assert!(matches!(config.mcp_servers["remote"], McpServerEntry::Http { .. }));
assert_eq!(
config.mcp_servers["remote"].allowed_tools(),
Some(["say_hello".to_owned(), "sum".to_owned()].as_slice())
);
assert_eq!(config.mcp_servers["remote"].require_approval(), Some("never"));
}
#[test]
fn rejects_generic_environment_table() {
let home = tempdir().expect("temp home");
fs::write(home.path().join("config.toml"), "[env]\nYOU_API_KEY = \"secret\"\n").expect("write config");
let error = FileConfig::load(home.path()).expect_err("generic env table must fail");
assert!(error.to_string().contains("unknown field"));
}
#[test]
fn rejects_unknown_top_level_fields() {
let home = tempdir().expect("temp home");
fs::write(home.path().join("config.toml"), "unknown = true\n").expect("write config");
let error = FileConfig::load(home.path()).expect_err("unknown field must fail");
assert!(error.to_string().contains("unknown field"));
}
#[test]
fn rejects_empty_api_key_environment_name() {
let home = tempdir().expect("temp home");
fs::write(home.path().join("config.toml"), "[web_search]\napi_key_env = \" \"\n").expect("write config");
let error = FileConfig::load(home.path()).expect_err("empty environment name must fail");
assert!(error.to_string().contains("empty web_search.api_key_env"));
}
#[test]
fn rejects_mcp_entries_with_multiple_transports() {
let home = tempdir().expect("temp home");
fs::write(
home.path().join("config.toml"),
"[mcp_servers.invalid]\ncommand = \"one\"\nurl = \"http://localhost:8000/mcp\"\n",
)
.expect("write config");
let error = FileConfig::load(home.path()).expect_err("ambiguous transport must fail");
assert!(error.to_string().contains("did not match any variant"));
}
#[test]
fn rejects_unsupported_configured_mcp_approval_policy() {
let home = tempdir().expect("temp home");
fs::write(
home.path().join("config.toml"),
"[mcp_servers.remote]\nurl = \"https://mcp.example.com/mcp\"\nrequire_approval = \"always\"\n",
)
.expect("write config");
let error = FileConfig::load(home.path()).expect_err("unsupported approval policy must fail");
assert!(error.to_string().contains("only 'never' is supported"));
}
#[test]
fn existing_config_is_never_overwritten() {
let home = tempdir().expect("temp home");
fs::write(
home.path().join("config.toml"),
"llm_api_base = \"http://existing:8000\"\n",
)
.expect("write config");
let defaults = FileConfig {
llm_api_base: Some("http://replacement:9000".to_owned()),
..FileConfig::default()
};
let config = defaults.create_or_load(home.path()).expect("load existing config");
let contents = fs::read_to_string(home.path().join("config.toml")).expect("read config");
assert_eq!(config.llm_api_base.as_deref(), Some("http://existing:8000"));
assert!(!contents.contains("replacement"));
}
#[cfg(unix)]
#[test]
fn create_or_load_continues_when_home_is_not_writable() {
use std::os::unix::fs::PermissionsExt;
let home = tempfile::tempdir().expect("tempdir");
fs::set_permissions(home.path(), fs::Permissions::from_mode(0o555)).expect("make home read-only");
if fs::File::create(home.path().join("probe")).is_ok() {
return;
}
let defaults = FileConfig {
llm_api_base: Some("http://127.0.0.1:5050".to_owned()),
..FileConfig::default()
};
let config = defaults
.create_or_load(home.path())
.expect("an unwritable home must not fail startup");
assert_eq!(config.llm_api_base.as_deref(), Some("http://127.0.0.1:5050"));
assert!(!home.path().join("config.toml").exists());
fs::set_permissions(home.path(), fs::Permissions::from_mode(0o755)).expect("restore permissions");
}
}