//! TOML configuration parsing, secret resolution, and validation.
use std::{
collections::HashSet,
env, fmt, fs,
path::{Path, PathBuf},
};
use serde::Deserialize;
use url::Url;
use crate::errors::ConfigurationError;
/// Placeholder in `persona.generic_error` replaced with a safe error code.
pub const ERROR_CODE_PLACEHOLDER: &str = "{error_code}";
const MAX_SYNC_TIMELINE_LIMIT: u32 = 1_000;
const MAX_GAP_PAGE_LIMIT: u32 = 100;
const MAX_CONCURRENT_ROOMS: u32 = 64;
const MAX_CONTEXT_EVENTS: u32 = 200;
const MAX_CONTEXT_CHARACTERS: u32 = 200_000;
const MAX_LLM_ITERATIONS: u32 = 16;
const MAX_DEVICE_DISPLAY_NAME_CHARACTERS: usize = 256;
/// Holds a resolved secret while preventing accidental debug output.
#[derive(Clone, Eq, PartialEq)]
pub struct SecretString(String);
impl SecretString {
/// Returns the secret only for an authenticated client request.
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl fmt::Debug for SecretString {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str("[REDACTED]")
}
}
/// Holds the Matrix settings needed for Lisa's dedicated client session.
#[derive(Clone, Debug)]
pub struct MatrixConfig {
/// Absolute Matrix homeserver URL.
pub homeserver_url: Url,
/// Matrix user ID for Lisa.
pub user_id: String,
/// Stable Matrix device ID used for transaction idempotency.
pub device_id: String,
/// Human-readable label assigned to Lisa's Matrix device.
pub device_display_name: String,
/// Password resolved from the environment for automatic session renewal.
pub password: SecretString,
/// Exact room IDs in which Lisa may operate.
pub allowed_room_ids: HashSet<String>,
/// Human-facing aliases accepted as Lisa mentions.
pub mention_aliases: Vec<String>,
/// Long-poll duration for controlled Matrix sync requests.
pub sync_timeout_ms: u64,
/// Timeline event limit requested from Matrix.
pub sync_timeline_limit: u32,
/// Maximum pages used to recover a limited sync gap.
pub gap_page_limit: u32,
}
/// Holds local-process settings needed during the foundation phase.
#[derive(Clone, Debug)]
pub struct RuntimeConfig {
/// SQLite database path for Lisa's authoritative state.
pub database_path: PathBuf,
/// Separate persistent state-store path for the Matrix SDK.
pub matrix_store_path: PathBuf,
/// Maximum simultaneous room workers for later phases.
pub max_concurrent_rooms: u32,
/// Maximum time an invocation may remain leased.
pub invocation_timeout_seconds: u64,
/// Grace period used during process shutdown.
pub shutdown_grace_seconds: u64,
/// Default tracing level when RUST_LOG is unset.
pub log_level: String,
}
/// Holds the self-hosted OpenAI-compatible LLM settings.
#[derive(Clone, Debug)]
pub struct LlmConfig {
/// Base URL of the operator's OpenAI-compatible service.
pub base_url: Url,
/// Bearer credential resolved from the environment.
pub api_key: SecretString,
/// Model identifier sent to the inference service.
pub model: String,
/// Total request timeout for an LLM completion.
pub request_timeout_seconds: u64,
/// Maximum generated tokens requested from the LLM.
pub max_output_tokens: u32,
/// Maximum character count for the complete model request.
pub max_request_characters: u32,
/// Maximum character count for the model's JSON response.
pub max_response_characters: u32,
/// Maximum LLM turns permitted for an invocation.
pub max_iterations: u32,
/// Maximum fixed tool calls permitted for an invocation.
pub max_tool_calls: u32,
/// Model sampling temperature.
pub temperature: f64,
}
/// Holds the operator-owned persona and fallbacks.
#[derive(Clone, Debug)]
pub struct PersonaConfig {
/// Regular file containing the operator's persona prose.
pub path: PathBuf,
/// In-character response used after an internal invocation failure.
pub generic_error: String,
/// In-character response used when a configured limit is exceeded.
pub overload_error: String,
}
/// Holds bounded conversation-context settings.
#[derive(Clone, Debug)]
pub struct ContextConfig {
/// Maximum event count exposed to the LLM.
pub max_events: u32,
/// Maximum total normalized context characters.
pub max_characters: u32,
/// Maximum normalized characters from one event.
pub max_event_characters: u32,
/// Maximum followed reply references, reserved for a later context expansion.
pub max_reply_depth: u32,
}
/// Holds the fixed SearXNG search-service configuration.
#[derive(Clone, Debug)]
pub struct SearchConfig {
/// Fixed SearXNG JSON API endpoint.
pub endpoint: Url,
/// Optional API credential resolved from the environment.
pub api_key: Option<SecretString>,
/// Maximum returned results visible to the model.
pub max_results: u32,
/// Search request timeout.
pub timeout_seconds: u64,
}
/// Holds resource limits for model-selected web requests and Pandoc.
#[derive(Clone, Debug)]
pub struct WebConfig {
/// HTTP connection timeout.
pub connect_timeout_seconds: u64,
/// HTTP read timeout.
pub read_timeout_seconds: u64,
/// Total request deadline.
pub total_timeout_seconds: u64,
/// Maximum followed HTTP redirects.
pub max_redirects: u32,
/// Maximum downloaded page bytes.
pub max_download_bytes: usize,
/// Maximum model-visible tool output characters.
pub max_tool_output_characters: u32,
/// Maximum bytes retained in one invocation's artifacts.
pub max_total_artifact_bytes: usize,
/// Pandoc conversion deadline.
pub pandoc_timeout_seconds: u64,
/// Fixed page-download user agent.
pub user_agent: String,
}
/// Contains Lisa's validated application configuration.
#[derive(Clone, Debug)]
pub struct AppConfig {
/// Matrix connectivity and invocation policy.
pub matrix: MatrixConfig,
/// Local state and runtime configuration.
pub runtime: RuntimeConfig,
/// Inference-service configuration.
pub llm: LlmConfig,
/// Persona and failure-text configuration.
pub persona: PersonaConfig,
/// Conversation context limits.
pub context: ContextConfig,
/// Fixed search-provider configuration.
pub search: SearchConfig,
/// Model-selected web resource limits.
pub web: WebConfig,
}
#[derive(Debug, Deserialize)]
struct RawConfig {
matrix: RawMatrixConfig,
runtime: RawRuntimeConfig,
llm: RawLlmConfig,
persona: RawPersonaConfig,
context: RawContextConfig,
search: RawSearchConfig,
web: RawWebConfig,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
struct RawMatrixConfig {
homeserver_url: String,
user_id: String,
device_id: String,
device_display_name: String,
password: String,
allowed_room_ids: Vec<String>,
mention_aliases: Vec<String>,
sync_timeout_ms: u64,
sync_timeline_limit: u32,
gap_page_limit: u32,
}
#[derive(Debug, Deserialize)]
struct RawRuntimeConfig {
database_path: PathBuf,
matrix_store_path: PathBuf,
max_concurrent_rooms: u32,
invocation_timeout_seconds: u64,
shutdown_grace_seconds: u64,
log_level: String,
}
#[derive(Debug, Deserialize)]
struct RawLlmConfig {
base_url: String,
api_key: String,
model: String,
request_timeout_seconds: u64,
max_output_tokens: u32,
max_request_characters: u32,
max_response_characters: u32,
max_iterations: u32,
max_tool_calls: u32,
temperature: f64,
}
#[derive(Debug, Deserialize)]
struct RawPersonaConfig {
path: PathBuf,
generic_error: String,
overload_error: String,
}
#[derive(Debug, Deserialize)]
#[allow(clippy::struct_field_names)]
struct RawContextConfig {
max_events: u32,
max_characters: u32,
max_event_characters: u32,
max_reply_depth: u32,
}
#[derive(Debug, Deserialize)]
struct RawSearchConfig {
endpoint: String,
api_key: Option<String>,
max_results: u32,
timeout_seconds: u64,
}
#[derive(Debug, Deserialize)]
struct RawWebConfig {
connect_timeout_seconds: u64,
read_timeout_seconds: u64,
total_timeout_seconds: u64,
max_redirects: u32,
max_download_bytes: usize,
max_tool_output_characters: u32,
max_total_artifact_bytes: usize,
pandoc_timeout_seconds: u64,
user_agent: String,
}
impl AppConfig {
/// Loads, resolves, and validates configuration at the supplied path.
pub fn load(path: &Path) -> Result<Self, ConfigurationError> {
let input = fs::read_to_string(path)?;
Self::from_toml_with_env(&input, |name| env::var(name).ok())
}
pub(crate) fn from_toml_with_env<F>(input: &str, get_env: F) -> Result<Self, ConfigurationError>
where
F: Fn(&str) -> Option<String>,
{
let raw: RawConfig = toml::from_str(input)?;
let homeserver_url = parse_http_url("matrix.homeserver_url", &raw.matrix.homeserver_url)?;
validate_user_id(&raw.matrix.user_id)?;
validate_device_id(&raw.matrix.device_id)?;
validate_device_display_name(&raw.matrix.device_display_name)?;
let password = resolve_secret("matrix.password", &raw.matrix.password, &get_env)?;
validate_limits(&raw.matrix, &raw.runtime)?;
validate_llm(&raw.llm)?;
validate_persona(&raw.persona)?;
validate_context(&raw.context)?;
validate_search(&raw.search)?;
validate_web(&raw.web)?;
let mention_aliases = validate_aliases(raw.matrix.mention_aliases, &raw.matrix.user_id)?;
let allowed_room_ids = validate_rooms(raw.matrix.allowed_room_ids)?;
Ok(Self {
matrix: MatrixConfig {
homeserver_url,
user_id: raw.matrix.user_id,
device_id: raw.matrix.device_id,
device_display_name: raw.matrix.device_display_name,
password,
allowed_room_ids,
mention_aliases,
sync_timeout_ms: raw.matrix.sync_timeout_ms,
sync_timeline_limit: raw.matrix.sync_timeline_limit,
gap_page_limit: raw.matrix.gap_page_limit,
},
runtime: RuntimeConfig {
database_path: raw.runtime.database_path,
matrix_store_path: raw.runtime.matrix_store_path,
max_concurrent_rooms: raw.runtime.max_concurrent_rooms,
invocation_timeout_seconds: raw.runtime.invocation_timeout_seconds,
shutdown_grace_seconds: raw.runtime.shutdown_grace_seconds,
log_level: raw.runtime.log_level,
},
llm: LlmConfig {
base_url: parse_http_url("llm.base_url", &raw.llm.base_url)?,
api_key: resolve_secret("llm.api_key", &raw.llm.api_key, &get_env)?,
model: raw.llm.model,
request_timeout_seconds: raw.llm.request_timeout_seconds,
max_output_tokens: raw.llm.max_output_tokens,
max_request_characters: raw.llm.max_request_characters,
max_response_characters: raw.llm.max_response_characters,
max_iterations: raw.llm.max_iterations,
max_tool_calls: raw.llm.max_tool_calls,
temperature: raw.llm.temperature,
},
persona: PersonaConfig {
path: raw.persona.path,
generic_error: raw.persona.generic_error,
overload_error: raw.persona.overload_error,
},
context: ContextConfig {
max_events: raw.context.max_events,
max_characters: raw.context.max_characters,
max_event_characters: raw.context.max_event_characters,
max_reply_depth: raw.context.max_reply_depth,
},
search: SearchConfig {
endpoint: parse_http_url("search.endpoint", &raw.search.endpoint)?,
api_key: raw.search.api_key.as_deref()
.map(|value| resolve_secret("search.api_key", value, &get_env))
.transpose()?,
max_results: raw.search.max_results,
timeout_seconds: raw.search.timeout_seconds,
},
web: WebConfig {
connect_timeout_seconds: raw.web.connect_timeout_seconds,
read_timeout_seconds: raw.web.read_timeout_seconds,
total_timeout_seconds: raw.web.total_timeout_seconds,
max_redirects: raw.web.max_redirects,
max_download_bytes: raw.web.max_download_bytes,
max_tool_output_characters: raw.web.max_tool_output_characters,
max_total_artifact_bytes: raw.web.max_total_artifact_bytes,
pandoc_timeout_seconds: raw.web.pandoc_timeout_seconds,
user_agent: raw.web.user_agent,
},
})
}
}
fn parse_http_url(key: &'static str, value: &str) -> Result<Url, ConfigurationError> {
let url = Url::parse(value).map_err(|error| invalid(key, error.to_string()))?;
if !matches!(url.scheme(), "http" | "https") || url.host_str().is_none() {
return Err(invalid(key, "must be an absolute HTTP or HTTPS URL"));
}
if !url.username().is_empty() || url.password().is_some() {
return Err(invalid(key, "must not contain user information"));
}
Ok(url)
}
fn resolve_secret<F>(
key: &'static str,
value: &str,
get_env: &F,
) -> Result<SecretString, ConfigurationError>
where
F: Fn(&str) -> Option<String>,
{
let Some(name) = value.strip_prefix("env:") else {
return Err(invalid(key, "must be an env: reference"));
};
if name.is_empty() {
return Err(invalid(key, "environment variable name is empty"));
}
let Some(secret) = get_env(name) else {
return Err(invalid(key, "referenced environment variable is missing"));
};
if secret.is_empty() {
return Err(invalid(key, "referenced environment variable is empty"));
}
Ok(SecretString(secret))
}
fn validate_user_id(value: &str) -> Result<(), ConfigurationError> {
if !value.starts_with('@') || !value.contains(':') || value.chars().any(char::is_whitespace) {
return Err(invalid("matrix.user_id", "must be a Matrix user ID"));
}
Ok(())
}
fn validate_device_id(value: &str) -> Result<(), ConfigurationError> {
if value.is_empty() || value.chars().any(char::is_whitespace) {
return Err(invalid("matrix.device_id", "must be a non-empty device ID"));
}
Ok(())
}
fn validate_device_display_name(value: &str) -> Result<(), ConfigurationError> {
if value.trim().is_empty() || value.chars().count() > MAX_DEVICE_DISPLAY_NAME_CHARACTERS {
return Err(invalid(
"matrix.device_display_name",
"must be non-empty and no more than 256 characters",
));
}
Ok(())
}
fn validate_rooms(values: Vec<String>) -> Result<HashSet<String>, ConfigurationError> {
if values.is_empty() {
return Err(invalid("matrix.allowed_room_ids", "must not be empty"));
}
let mut rooms = HashSet::with_capacity(values.len());
for room_id in values {
if !room_id.starts_with('!')
|| room_id.len() == 1
|| room_id.chars().any(char::is_whitespace)
{
return Err(invalid(
"matrix.allowed_room_ids",
"must contain only Matrix room IDs",
));
}
if !rooms.insert(room_id) {
return Err(invalid(
"matrix.allowed_room_ids",
"must not contain duplicate room IDs",
));
}
}
Ok(rooms)
}
fn validate_aliases(
mut values: Vec<String>,
user_id: &str,
) -> Result<Vec<String>, ConfigurationError> {
if values.is_empty() {
return Err(invalid("matrix.mention_aliases", "must not be empty"));
}
values.push(user_id.to_owned());
let mut seen = HashSet::new();
values.retain(|alias| !alias.trim().is_empty() && seen.insert(alias.to_lowercase()));
if values.iter().any(|alias| alias.chars().count() > 128) {
return Err(invalid(
"matrix.mention_aliases",
"aliases may not exceed 128 characters",
));
}
Ok(values)
}
fn validate_limits(
matrix: &RawMatrixConfig,
runtime: &RawRuntimeConfig,
) -> Result<(), ConfigurationError> {
if matrix.sync_timeout_ms == 0 || matrix.sync_timeout_ms > 120_000 {
return Err(invalid(
"matrix.sync_timeout_ms",
"must be between 1 and 120000",
));
}
if matrix.sync_timeline_limit == 0 || matrix.sync_timeline_limit > MAX_SYNC_TIMELINE_LIMIT {
return Err(invalid(
"matrix.sync_timeline_limit",
"is outside the safety ceiling",
));
}
if matrix.gap_page_limit > MAX_GAP_PAGE_LIMIT {
return Err(invalid(
"matrix.gap_page_limit",
"is outside the safety ceiling",
));
}
if runtime.max_concurrent_rooms == 0 || runtime.max_concurrent_rooms > MAX_CONCURRENT_ROOMS {
return Err(invalid(
"runtime.max_concurrent_rooms",
"is outside the safety ceiling",
));
}
if runtime.invocation_timeout_seconds == 0 || runtime.invocation_timeout_seconds > 3_600 {
return Err(invalid(
"runtime.invocation_timeout_seconds",
"is outside the safety ceiling",
));
}
if runtime.shutdown_grace_seconds == 0 || runtime.shutdown_grace_seconds > 300 {
return Err(invalid(
"runtime.shutdown_grace_seconds",
"is outside the safety ceiling",
));
}
if runtime.database_path.as_os_str().is_empty()
|| runtime.matrix_store_path.as_os_str().is_empty()
{
return Err(invalid("runtime", "state paths must not be empty"));
}
Ok(())
}
fn validate_llm(value: &RawLlmConfig) -> Result<(), ConfigurationError> {
parse_http_url("llm.base_url", &value.base_url)?;
if value.model.trim().is_empty() || value.model.chars().count() > 256 {
return Err(invalid("llm.model", "must be a non-empty model identifier"));
}
if value.request_timeout_seconds == 0 || value.request_timeout_seconds > 600 {
return Err(invalid(
"llm.request_timeout_seconds",
"is outside the safety ceiling",
));
}
if value.max_output_tokens == 0
|| value.max_output_tokens > 16_000
|| value.max_request_characters == 0
|| value.max_request_characters > 500_000
|| value.max_response_characters == 0
|| value.max_response_characters > 100_000
|| value.max_response_characters > value.max_request_characters
|| value.max_iterations == 0
|| value.max_iterations > MAX_LLM_ITERATIONS
|| value.max_tool_calls > 32
|| !value.temperature.is_finite()
|| !(0.0..=2.0).contains(&value.temperature)
{
return Err(invalid("llm", "contains unsafe generation limits"));
}
Ok(())
}
fn validate_persona(value: &RawPersonaConfig) -> Result<(), ConfigurationError> {
if value.path.as_os_str().is_empty()
|| value.generic_error.trim().is_empty()
|| value.overload_error.trim().is_empty()
|| value.generic_error.chars().count() > 4_000
|| value.overload_error.chars().count() > 4_000
|| value
.generic_error
.matches(ERROR_CODE_PLACEHOLDER)
.count()
!= 1
{
return Err(invalid(
"persona",
"must contain non-empty bounded values and exactly one {error_code} placeholder",
));
}
Ok(())
}
fn validate_context(value: &RawContextConfig) -> Result<(), ConfigurationError> {
if value.max_events == 0
|| value.max_events > MAX_CONTEXT_EVENTS
|| value.max_characters == 0
|| value.max_characters > MAX_CONTEXT_CHARACTERS
|| value.max_event_characters == 0
|| value.max_event_characters > value.max_characters
|| value.max_reply_depth > 8
{
return Err(invalid("context", "contains unsafe context limits"));
}
Ok(())
}
fn validate_search(value: &RawSearchConfig) -> Result<(), ConfigurationError> {
parse_http_url("search.endpoint", &value.endpoint)?;
if value.max_results == 0 || value.max_results > 20
|| value.timeout_seconds == 0 || value.timeout_seconds > 120
{
return Err(invalid("search", "contains unsafe search limits"));
}
Ok(())
}
fn validate_web(value: &RawWebConfig) -> Result<(), ConfigurationError> {
if value.connect_timeout_seconds == 0 || value.connect_timeout_seconds > 60
|| value.read_timeout_seconds == 0 || value.read_timeout_seconds > 120
|| value.total_timeout_seconds == 0 || value.total_timeout_seconds > 300
|| value.max_redirects > 10
|| value.max_download_bytes == 0 || value.max_download_bytes > 10_000_000
|| value.max_tool_output_characters == 0 || value.max_tool_output_characters > 100_000
|| value.max_total_artifact_bytes < value.max_download_bytes
|| value.max_total_artifact_bytes > 20_000_000
|| value.pandoc_timeout_seconds == 0 || value.pandoc_timeout_seconds > 120
|| value.user_agent.trim().is_empty() || value.user_agent.chars().count() > 256
{
return Err(invalid("web", "contains unsafe web limits"));
}
Ok(())
}
fn invalid(key: &'static str, reason: impl Into<String>) -> ConfigurationError {
ConfigurationError::InvalidValue {
key,
reason: reason.into(),
}
}
#[cfg(test)]
mod tests {
use super::AppConfig;
const CONFIG: &str = r#"
[matrix]
homeserver_url = "https://matrix.example.test"
user_id = "@lisa:example.test"
device_id = "LISABOT"
device_display_name = "Lisa bot"
password = "env:LISA_MATRIX_PASSWORD"
allowed_room_ids = ["!private:example.test"]
mention_aliases = ["Lisa", "@Lisa"]
sync_timeout_ms = 30000
sync_timeline_limit = 100
gap_page_limit = 20
[runtime]
database_path = "state.sqlite3"
matrix_store_path = "matrix_sdk"
max_concurrent_rooms = 3
invocation_timeout_seconds = 180
shutdown_grace_seconds = 20
log_level = "INFO"
[llm]
base_url = "http://llm.example.test/v1/"
api_key = "env:LISA_LLM_API_KEY"
model = "operator-model"
request_timeout_seconds = 90
max_output_tokens = 700
max_request_characters = 120000
max_response_characters = 12000
max_iterations = 8
max_tool_calls = 6
temperature = 0.7
[persona]
path = "persona.md"
generic_error = "Sorry—my thoughts got tangled. Please try me again. [Error: {error_code}]"
overload_error = "I have too much to untangle at once. Try a narrower question."
[context]
max_events = 40
max_characters = 24000
max_event_characters = 4000
max_reply_depth = 2
[search]
endpoint = "https://search.example.test/search"
max_results = 8
timeout_seconds = 15
[web]
connect_timeout_seconds = 5
read_timeout_seconds = 15
total_timeout_seconds = 20
max_redirects = 5
max_download_bytes = 2000000
max_tool_output_characters = 30000
max_total_artifact_bytes = 4000000
pandoc_timeout_seconds = 10
user_agent = "LisaBot/1.0"
"#;
#[test]
fn resolves_password_without_exposing_it() {
let config = AppConfig::from_toml_with_env(CONFIG, |name| {
matches!(name, "LISA_MATRIX_PASSWORD" | "LISA_LLM_API_KEY")
.then(|| "secret-canary".to_owned())
})
.unwrap();
assert_eq!(config.matrix.password.expose(), "secret-canary");
assert_eq!(format!("{:?}", config.matrix.password), "[REDACTED]");
}
#[test]
fn accepts_room_id_without_server_name() {
let config = CONFIG.replace("!private:example.test", "!private");
let config = AppConfig::from_toml_with_env(&config, |name| {
matches!(name, "LISA_MATRIX_PASSWORD" | "LISA_LLM_API_KEY")
.then(|| "secret-canary".to_owned())
})
.unwrap();
assert!(config.matrix.allowed_room_ids.contains("!private"));
}
#[test]
fn rejects_error_template_without_placeholder() {
let config = CONFIG.replace(" [Error: {error_code}]", "");
assert!(AppConfig::from_toml_with_env(&config, |name| {
matches!(name, "LISA_MATRIX_PASSWORD" | "LISA_LLM_API_KEY")
.then(|| "secret-canary".to_owned())
})
.is_err());
}
#[test]
fn rejects_literal_secret_and_duplicate_room() {
let literal = CONFIG.replace("env:LISA_MATRIX_PASSWORD", "secret");
assert!(AppConfig::from_toml_with_env(&literal, |_| None).is_err());
let legacy = CONFIG.replace(
"password = \"env:LISA_MATRIX_PASSWORD\"",
"password = \"env:LISA_MATRIX_PASSWORD\"\naccess_token = \"env:LISA_MATRIX_ACCESS_TOKEN\"",
);
assert!(AppConfig::from_toml_with_env(&legacy, |_| Some("token".to_owned())).is_err());
let duplicate = CONFIG.replace(
"[\"!private:example.test\"]",
"[\"!private:example.test\", \"!private:example.test\"]",
);
assert!(AppConfig::from_toml_with_env(&duplicate, |_| Some("token".to_owned())).is_err());
}
}