use anyhow::{anyhow, bail, Context, Result};
use ipnet::IpNet;
use serde::{Deserialize, Deserializer};
use std::io::ErrorKind;
use std::path::PathBuf;
use std::time::Duration;
use tracing::warn;
use secrecy::SecretString;
const DEFAULT_AUTH_BASE_URL: &str = "https://auth.loopflow.studio";
const DEFAULT_EXECUTOR_IMAGE: &str = "loopflow/agent:latest";
const DEFAULT_AGENT_TIMEOUT_SECS: u64 = 45 * 60;
const DEFAULT_EXECUTOR_MEMORY_LIMIT_BYTES: i64 = 8 * 1024 * 1024 * 1024;
const DEFAULT_EXECUTOR_MEMORY_SWAP_LIMIT_BYTES: i64 = 8 * 1024 * 1024 * 1024;
const DEFAULT_EXECUTOR_CPU_QUOTA: i64 = 400_000;
const DEFAULT_EXECUTOR_PIDS_LIMIT: i64 = 1024;
const DEFAULT_HTTP_MAX_JSON_BODY_BYTES: usize = 1_048_576;
const DEFAULT_HTTP_MAX_HOOK_BODY_BYTES: usize = 262_144;
const DEFAULT_HTTP_MAX_WS_FRAME_BYTES: usize = 65_536;
const DEFAULT_HTTP_MAX_WS_MESSAGE_BYTES: usize = 262_144;
const DEFAULT_HTTP_MAX_WS_QUEUE: usize = 256;
const DEFAULT_HTTP_MAX_WS_MALFORMED: u32 = 3;
const DEFAULT_HTTP_AUTH_FAILURES_PER_MINUTE: u32 = 12;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum AuthMode {
#[default]
Local,
Studio,
}
impl AuthMode {
fn parse(value: &str) -> Result<Self> {
match value.trim() {
"local" => Ok(Self::Local),
"studio" => Ok(Self::Studio),
other => bail!("invalid auth.mode value '{other}'; expected 'local' or 'studio'"),
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::Local => "local",
Self::Studio => "studio",
}
}
}
impl std::fmt::Display for AuthMode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl<'de> Deserialize<'de> for AuthMode {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let value = String::deserialize(deserializer)?;
Self::parse(&value).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AuthConfig {
#[serde(default)]
pub mode: AuthMode,
pub token: Option<SecretString>,
#[serde(default = "default_base_url")]
pub base_url: String,
}
impl Default for AuthConfig {
fn default() -> Self {
Self {
mode: AuthMode::default(),
token: None,
base_url: default_base_url(),
}
}
}
fn default_base_url() -> String {
DEFAULT_AUTH_BASE_URL.to_string()
}
#[derive(Debug, Clone)]
pub struct LfdConfig {
pub mode: Mode,
pub service_manager: ServiceManager,
pub runtime_backend: RuntimeBackend,
pub storage: StorageType,
pub credential_socket: Option<String>,
pub auth: AuthConfig,
pub executor: ExecutorConfig,
pub github: GitHubConfig,
pub http_security: HttpSecurityConfig,
pub output_log_retention_days: u32,
}
impl Default for LfdConfig {
fn default() -> Self {
Self {
mode: Mode::default(),
service_manager: ServiceManager::default(),
runtime_backend: RuntimeBackend::default(),
storage: StorageType::default(),
credential_socket: None,
auth: AuthConfig::default(),
executor: ExecutorConfig::default(),
github: GitHubConfig::default(),
http_security: HttpSecurityConfig::default(),
output_log_retention_days: DEFAULT_OUTPUT_LOG_RETENTION_DAYS,
}
}
}
impl LfdConfig {
pub fn load() -> Result<Self> {
let path = config_path();
let mut config: RawLfdConfig = match std::fs::read_to_string(&path) {
Ok(content) => serde_yaml_ng::from_str(&content)
.with_context(|| format!("invalid lfd config at {}", path.display()))?,
Err(err) if err.kind() == ErrorKind::NotFound => RawLfdConfig::default(),
Err(err) => {
warn!(
path = %path.display(),
error = %err,
"failed reading lfd config, using defaults"
);
RawLfdConfig::default()
}
};
config.apply_env_overrides()?;
config.resolve()
}
}
const DEFAULT_OUTPUT_LOG_RETENTION_DAYS: u32 = 7;
#[derive(Debug, Clone, Default, Deserialize)]
struct RawLfdConfig {
#[serde(default)]
mode: Mode,
service_manager: Option<ServiceManager>,
runtime_backend: Option<RuntimeBackend>,
storage: Option<StorageType>,
#[serde(default)]
credential_socket: Option<String>,
#[serde(default)]
auth: AuthConfig,
#[serde(default)]
executor: RawExecutorConfig,
#[serde(default)]
github: GitHubConfig,
#[serde(default)]
http_security: RawHttpSecurityConfig,
#[serde(default = "default_output_log_retention_days")]
output_log_retention_days: u32,
}
fn default_output_log_retention_days() -> u32 {
DEFAULT_OUTPUT_LOG_RETENTION_DAYS
}
impl RawLfdConfig {
fn apply_env_overrides(&mut self) -> Result<()> {
if let Ok(value) = std::env::var("LFD_MODE") {
let trimmed = value.trim();
if !trimmed.is_empty() {
self.mode = Mode::parse(trimmed)?;
}
}
if let Ok(value) = std::env::var("LFD_AUTH_MODE") {
let trimmed = value.trim();
if !trimmed.is_empty() {
self.auth.mode = AuthMode::parse(trimmed)?;
}
}
if let Ok(value) = std::env::var("LFD_AUTH_TOKEN") {
let trimmed = value.trim();
if !trimmed.is_empty() {
self.auth.token = Some(SecretString::new(trimmed.to_string()));
}
}
if let Ok(value) = std::env::var("LFD_CREDENTIAL_SOCKET") {
let trimmed = value.trim();
if !trimmed.is_empty() {
self.credential_socket = Some(trimmed.to_string());
}
}
if let Ok(value) = std::env::var("LFD_EXECUTOR_CREDENTIALS_ENV") {
let names: Vec<String> = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
if !names.is_empty() {
self.executor.credentials.env = names;
}
}
if let Ok(value) = std::env::var("LFD_EXECUTOR_CREDENTIALS_MOUNTS") {
let names: Result<Vec<CredentialMount>, _> = value
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.map(CredentialMount::try_from)
.collect();
match names {
Ok(mounts) if !mounts.is_empty() => {
self.executor.credentials.mounts = mounts;
}
Err(err) => {
return Err(anyhow!("invalid LFD_EXECUTOR_CREDENTIALS_MOUNTS: {err}"));
}
_ => {}
}
}
if let Ok(value) = std::env::var("LFD_EXECUTOR_IMAGE") {
if !value.trim().is_empty() {
self.executor.image = value;
}
}
if let Ok(value) = std::env::var("LFD_EXECUTOR_AGENT_TIMEOUT") {
let trimmed = value.trim();
if !trimmed.is_empty() {
self.executor.agent_timeout =
parse_agent_timeout_duration(trimmed).map_err(|err| anyhow!(err))?;
}
}
Self::apply_env_override(
&mut self.executor.limits.memory,
"LFD_EXECUTOR_LIMITS_MEMORY",
"executor.limits.memory",
parse_executor_limit_i64,
)?;
Self::apply_env_override(
&mut self.executor.limits.memory_swap,
"LFD_EXECUTOR_LIMITS_MEMORY_SWAP",
"executor.limits.memory_swap",
parse_executor_limit_i64,
)?;
Self::apply_env_override(
&mut self.executor.limits.cpu_quota,
"LFD_EXECUTOR_LIMITS_CPU_QUOTA",
"executor.limits.cpu_quota",
parse_executor_limit_i64,
)?;
Self::apply_env_override(
&mut self.executor.limits.pids_limit,
"LFD_EXECUTOR_LIMITS_PIDS_LIMIT",
"executor.limits.pids_limit",
parse_executor_limit_i64,
)?;
if let Ok(value) = std::env::var("LFD_GITHUB_WEBHOOK_SECRET") {
self.github.webhook_secret = value;
}
if let Ok(value) = std::env::var("LFD_GITHUB_TOKEN") {
let token = value.trim();
self.github.token = if token.is_empty() {
None
} else {
Some(SecretString::new(token.to_string()))
};
}
Self::apply_env_override(
&mut self.http_security.max_json_body_bytes,
"LFD_HTTP_MAX_JSON_BODY_BYTES",
"http_security.max_json_body_bytes",
parse_positive_usize,
)?;
Self::apply_env_override(
&mut self.http_security.max_hook_body_bytes,
"LFD_HTTP_MAX_HOOK_BODY_BYTES",
"http_security.max_hook_body_bytes",
parse_positive_usize,
)?;
Self::apply_env_override(
&mut self.http_security.max_ws_frame_bytes,
"LFD_HTTP_MAX_WS_FRAME_BYTES",
"http_security.max_ws_frame_bytes",
parse_positive_usize,
)?;
Self::apply_env_override(
&mut self.http_security.max_ws_message_bytes,
"LFD_HTTP_MAX_WS_MESSAGE_BYTES",
"http_security.max_ws_message_bytes",
parse_positive_usize,
)?;
Self::apply_env_override(
&mut self.http_security.max_ws_queue,
"LFD_HTTP_MAX_WS_QUEUE",
"http_security.max_ws_queue",
parse_positive_usize,
)?;
Self::apply_env_override(
&mut self.http_security.max_ws_malformed,
"LFD_HTTP_MAX_WS_MALFORMED",
"http_security.max_ws_malformed",
parse_positive_u32,
)?;
Self::apply_env_override(
&mut self.http_security.auth_failures_per_minute,
"LFD_HTTP_AUTH_FAILURES_PER_MINUTE",
"http_security.auth_failures_per_minute",
parse_positive_u32,
)?;
if let Ok(value) = std::env::var("LFD_HTTP_TRUSTED_PROXY_CIDRS") {
let trimmed = value.trim();
self.http_security.trusted_proxy_cidrs = if trimmed.is_empty() {
Vec::new()
} else {
trimmed
.split(',')
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string)
.collect()
};
}
Ok(())
}
fn apply_env_override<T>(
target: &mut T,
env_key: &str,
field: &str,
parse: fn(&str, &str, &str) -> Result<T>,
) -> Result<()> {
let Ok(value) = std::env::var(env_key) else {
return Ok(());
};
let trimmed = value.trim();
if trimmed.is_empty() {
return Ok(());
}
*target = parse(trimmed, env_key, field)?;
Ok(())
}
fn resolve(self) -> Result<LfdConfig> {
if self.service_manager.is_some() {
bail!("`service_manager` is managed by `mode`; remove this key");
}
if self.runtime_backend.is_some() {
bail!("`runtime_backend` is managed by `mode`; remove this key");
}
if self.storage.is_some() {
bail!("`storage` is managed by `mode`; remove this key");
}
if self.executor.r#type.is_some() {
bail!("`executor.type` is managed by `mode`; remove this key");
}
if self.executor.sandbox.is_present() {
bail!(
"`executor.sandbox` was removed; container mode is Docker-only now. Delete this key and rerun `lfd install`"
);
}
let profile = ModeProfile::for_mode(self.mode);
self.executor.limits.validate()?;
let mut auth = self.auth;
if self.mode == Mode::Container && auth.mode == AuthMode::Local && auth.token.is_none() {
auth.mode = AuthMode::Studio;
}
Ok(LfdConfig {
mode: self.mode,
service_manager: profile.service_manager,
runtime_backend: profile.runtime_backend,
storage: profile.storage,
credential_socket: self.credential_socket,
auth,
executor: ExecutorConfig {
r#type: profile.executor_type,
image: self.executor.image,
credentials: self.executor.credentials,
agent_timeout: self.executor.agent_timeout,
limits: self.executor.limits,
},
github: self.github,
http_security: self.http_security.resolve()?,
output_log_retention_days: self.output_log_retention_days,
})
}
}
#[derive(Debug, Clone, Copy)]
struct ModeProfile {
service_manager: ServiceManager,
runtime_backend: RuntimeBackend,
storage: StorageType,
executor_type: ExecutorType,
}
impl ModeProfile {
fn for_mode(mode: Mode) -> Self {
match mode {
Mode::Native => Self {
service_manager: ServiceManager::default_for_os(),
runtime_backend: RuntimeBackend::Native,
storage: StorageType::Sqlite,
executor_type: ExecutorType::Local,
},
Mode::Container => Self {
service_manager: ServiceManager::default_for_os(),
runtime_backend: RuntimeBackend::Compose,
storage: StorageType::Postgres,
executor_type: ExecutorType::Docker,
},
}
}
}
#[derive(Clone, Default, Deserialize)]
pub struct GitHubConfig {
#[serde(default)]
pub webhook_secret: String,
pub token: Option<SecretString>,
}
impl std::fmt::Debug for GitHubConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let webhook_secret = if self.webhook_secret.is_empty() {
""
} else {
"[REDACTED]"
};
let token = self.token.as_ref().map(|_| "[REDACTED]");
f.debug_struct("GitHubConfig")
.field("webhook_secret", &webhook_secret)
.field("token", &token)
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HttpSecurityConfig {
pub max_json_body_bytes: usize,
pub max_hook_body_bytes: usize,
pub max_ws_frame_bytes: usize,
pub max_ws_message_bytes: usize,
pub max_ws_queue: usize,
pub max_ws_malformed: u32,
pub auth_failures_per_minute: u32,
pub trusted_proxy_cidrs: Vec<IpNet>,
}
impl Default for HttpSecurityConfig {
fn default() -> Self {
Self {
max_json_body_bytes: DEFAULT_HTTP_MAX_JSON_BODY_BYTES,
max_hook_body_bytes: DEFAULT_HTTP_MAX_HOOK_BODY_BYTES,
max_ws_frame_bytes: DEFAULT_HTTP_MAX_WS_FRAME_BYTES,
max_ws_message_bytes: DEFAULT_HTTP_MAX_WS_MESSAGE_BYTES,
max_ws_queue: DEFAULT_HTTP_MAX_WS_QUEUE,
max_ws_malformed: DEFAULT_HTTP_MAX_WS_MALFORMED,
auth_failures_per_minute: DEFAULT_HTTP_AUTH_FAILURES_PER_MINUTE,
trusted_proxy_cidrs: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
struct RawHttpSecurityConfig {
max_json_body_bytes: usize,
max_hook_body_bytes: usize,
max_ws_frame_bytes: usize,
max_ws_message_bytes: usize,
max_ws_queue: usize,
max_ws_malformed: u32,
auth_failures_per_minute: u32,
trusted_proxy_cidrs: Vec<String>,
}
impl Default for RawHttpSecurityConfig {
fn default() -> Self {
let default = HttpSecurityConfig::default();
Self {
max_json_body_bytes: default.max_json_body_bytes,
max_hook_body_bytes: default.max_hook_body_bytes,
max_ws_frame_bytes: default.max_ws_frame_bytes,
max_ws_message_bytes: default.max_ws_message_bytes,
max_ws_queue: default.max_ws_queue,
max_ws_malformed: default.max_ws_malformed,
auth_failures_per_minute: default.auth_failures_per_minute,
trusted_proxy_cidrs: Vec::new(),
}
}
}
impl RawHttpSecurityConfig {
fn resolve(self) -> Result<HttpSecurityConfig> {
require_positive_usize(
self.max_json_body_bytes,
"http_security.max_json_body_bytes",
)?;
require_positive_usize(
self.max_hook_body_bytes,
"http_security.max_hook_body_bytes",
)?;
require_positive_usize(self.max_ws_frame_bytes, "http_security.max_ws_frame_bytes")?;
require_positive_usize(
self.max_ws_message_bytes,
"http_security.max_ws_message_bytes",
)?;
require_positive_usize(self.max_ws_queue, "http_security.max_ws_queue")?;
require_positive_u32(self.max_ws_malformed, "http_security.max_ws_malformed")?;
require_positive_u32(
self.auth_failures_per_minute,
"http_security.auth_failures_per_minute",
)?;
let mut trusted_proxy_cidrs = Vec::with_capacity(self.trusted_proxy_cidrs.len());
for cidr in self.trusted_proxy_cidrs {
let trimmed = cidr.trim();
if trimmed.is_empty() {
continue;
}
let parsed = trimmed
.parse::<IpNet>()
.map_err(|err| anyhow!("invalid trusted proxy CIDR '{trimmed}': {err}"))?;
trusted_proxy_cidrs.push(parsed);
}
Ok(HttpSecurityConfig {
max_json_body_bytes: self.max_json_body_bytes,
max_hook_body_bytes: self.max_hook_body_bytes,
max_ws_frame_bytes: self.max_ws_frame_bytes,
max_ws_message_bytes: self.max_ws_message_bytes,
max_ws_queue: self.max_ws_queue,
max_ws_malformed: self.max_ws_malformed,
auth_failures_per_minute: self.auth_failures_per_minute,
trusted_proxy_cidrs,
})
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
#[default]
Native,
Container,
}
impl Mode {
fn parse(raw: &str) -> Result<Self> {
match raw.trim().to_ascii_lowercase().as_str() {
"native" => Ok(Self::Native),
"container" => Ok(Self::Container),
_ => bail!("invalid LFD_MODE value '{raw}'; expected 'native' or 'container'"),
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Native => "native",
Self::Container => "container",
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ServiceManager {
Launchd,
Systemd,
}
impl ServiceManager {
fn default_for_os() -> Self {
#[cfg(target_os = "linux")]
{
Self::Systemd
}
#[cfg(not(target_os = "linux"))]
{
Self::Launchd
}
}
pub fn as_str(&self) -> &'static str {
match self {
Self::Launchd => "launchd",
Self::Systemd => "systemd",
}
}
}
impl Default for ServiceManager {
fn default() -> Self {
Self::default_for_os()
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum RuntimeBackend {
#[default]
Native,
Compose,
}
impl RuntimeBackend {
pub fn as_str(&self) -> &'static str {
match self {
Self::Native => "native",
Self::Compose => "compose",
}
}
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
pub enum StorageType {
#[default]
Sqlite,
Postgres,
}
#[derive(Debug, Clone, Copy, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum ExecutorType {
#[default]
Local,
Docker,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct ExecutorCredentialsConfig {
#[serde(default)]
pub env: Vec<String>,
#[serde(default)]
pub mounts: Vec<CredentialMount>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(try_from = "String")]
pub struct CredentialMount(String);
impl CredentialMount {
pub fn name(&self) -> &str {
self.0.as_str()
}
}
impl TryFrom<String> for CredentialMount {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
let name = value.trim();
if name.is_empty() {
return Err("credential mount name must not be empty".to_string());
}
if name.contains(':') {
return Err(
"credential mounts no longer accept host:container paths; use named mounts"
.to_string(),
);
}
if name.starts_with('/') {
return Err("credential mount name must not be an absolute path".to_string());
}
Ok(Self(name.to_string()))
}
}
#[derive(Debug, Clone)]
pub struct ExecutorConfig {
pub r#type: ExecutorType,
pub image: String,
pub credentials: ExecutorCredentialsConfig,
pub agent_timeout: Duration,
pub limits: ExecutorLimitsConfig,
}
impl Default for ExecutorConfig {
fn default() -> Self {
Self {
r#type: ExecutorType::Local,
image: default_executor_image(),
credentials: ExecutorCredentialsConfig::default(),
agent_timeout: default_agent_timeout(),
limits: ExecutorLimitsConfig::default(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct ExecutorLimitsConfig {
pub memory: i64,
pub memory_swap: i64,
pub cpu_quota: i64,
pub pids_limit: i64,
}
impl ExecutorLimitsConfig {
fn validate(&self) -> Result<()> {
require_positive_limit(self.memory, "executor.limits.memory")?;
require_positive_limit(self.memory_swap, "executor.limits.memory_swap")?;
require_positive_limit(self.cpu_quota, "executor.limits.cpu_quota")?;
require_positive_limit(self.pids_limit, "executor.limits.pids_limit")?;
Ok(())
}
}
impl Default for ExecutorLimitsConfig {
fn default() -> Self {
Self {
memory: DEFAULT_EXECUTOR_MEMORY_LIMIT_BYTES,
memory_swap: DEFAULT_EXECUTOR_MEMORY_SWAP_LIMIT_BYTES,
cpu_quota: DEFAULT_EXECUTOR_CPU_QUOTA,
pids_limit: DEFAULT_EXECUTOR_PIDS_LIMIT,
}
}
}
#[derive(Debug, Clone, Deserialize)]
struct RawExecutorConfig {
r#type: Option<ExecutorType>,
#[serde(default)]
sandbox: RemovedExecutorSandboxConfig,
#[serde(default = "default_executor_image")]
image: String,
#[serde(default)]
credentials: ExecutorCredentialsConfig,
#[serde(
default = "default_agent_timeout",
deserialize_with = "deserialize_duration"
)]
agent_timeout: Duration,
#[serde(default)]
limits: ExecutorLimitsConfig,
}
impl Default for RawExecutorConfig {
fn default() -> Self {
Self {
r#type: None,
sandbox: RemovedExecutorSandboxConfig::default(),
image: default_executor_image(),
credentials: ExecutorCredentialsConfig::default(),
agent_timeout: default_agent_timeout(),
limits: ExecutorLimitsConfig::default(),
}
}
}
fn default_executor_image() -> String {
DEFAULT_EXECUTOR_IMAGE.to_string()
}
fn default_agent_timeout() -> Duration {
Duration::from_secs(DEFAULT_AGENT_TIMEOUT_SECS)
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
enum RemovedExecutorSandboxConfig {
#[default]
Absent,
Present,
}
impl RemovedExecutorSandboxConfig {
fn is_present(self) -> bool {
matches!(self, Self::Present)
}
}
impl<'de> Deserialize<'de> for RemovedExecutorSandboxConfig {
fn deserialize<D>(deserializer: D) -> std::result::Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let _ = serde_yaml_ng::Value::deserialize(deserializer)?;
Ok(Self::Present)
}
}
fn parse_agent_timeout_duration(raw: &str) -> std::result::Result<Duration, String> {
let trimmed = raw.trim();
humantime::parse_duration(trimmed).map_err(|err| {
format!(
"invalid duration '{}' for executor.agent_timeout: {}",
raw, err
)
})
}
fn parse_executor_limit_i64(raw: &str, env_key: &str, field: &str) -> Result<i64> {
let value: i64 = raw
.parse()
.map_err(|err| anyhow!("invalid {env_key} value '{raw}' for {field}: {err}"))?;
if value <= 0 {
bail!("invalid {env_key} value '{raw}' for {field}: must be greater than zero");
}
Ok(value)
}
fn parse_positive_usize(raw: &str, env_key: &str, field: &str) -> Result<usize> {
let value: usize = raw
.parse()
.map_err(|err| anyhow!("invalid {env_key} value '{raw}' for {field}: {err}"))?;
if value == 0 {
bail!("invalid {env_key} value '{raw}' for {field}: must be greater than zero");
}
Ok(value)
}
fn parse_positive_u32(raw: &str, env_key: &str, field: &str) -> Result<u32> {
let value: u32 = raw
.parse()
.map_err(|err| anyhow!("invalid {env_key} value '{raw}' for {field}: {err}"))?;
if value == 0 {
bail!("invalid {env_key} value '{raw}' for {field}: must be greater than zero");
}
Ok(value)
}
fn require_positive_limit(value: i64, field: &str) -> Result<()> {
if value <= 0 {
bail!("{field} must be greater than zero");
}
Ok(())
}
fn require_positive_usize(value: usize, field: &str) -> Result<()> {
if value == 0 {
bail!("{field} must be greater than zero");
}
Ok(())
}
fn require_positive_u32(value: u32, field: &str) -> Result<()> {
if value == 0 {
bail!("{field} must be greater than zero");
}
Ok(())
}
fn deserialize_duration<'de, D>(deserializer: D) -> std::result::Result<Duration, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum DurationValue {
String(String),
Seconds(u64),
}
let value = DurationValue::deserialize(deserializer)?;
match value {
DurationValue::String(raw) => {
parse_agent_timeout_duration(raw.as_str()).map_err(serde::de::Error::custom)
}
DurationValue::Seconds(seconds) => Ok(Duration::from_secs(seconds)),
}
}
fn config_path() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".lf")
.join("lfd.yaml")
}
#[cfg(test)]
mod tests {
use super::*;
use secrecy::{ExposeSecret, SecretString};
use std::ffi::OsString;
use std::sync::{Mutex, OnceLock};
use std::time::Duration;
use tempfile::tempdir;
fn env_lock() -> &'static Mutex<()> {
static ENV_LOCK: OnceLock<Mutex<()>> = OnceLock::new();
ENV_LOCK.get_or_init(|| Mutex::new(()))
}
struct EnvGuard {
vars: Vec<(&'static str, Option<OsString>)>,
}
impl EnvGuard {
fn snapshot(vars: &[&'static str]) -> Self {
Self {
vars: vars
.iter()
.map(|name| (*name, std::env::var_os(name)))
.collect(),
}
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
for (name, value) in &self.vars {
if let Some(value) = value {
std::env::set_var(name, value);
} else {
std::env::remove_var(name);
}
}
}
}
#[test]
fn mode_native_is_default() {
let config = RawLfdConfig::default().resolve().expect("default resolves");
assert_eq!(config.mode, Mode::Native);
assert_eq!(config.runtime_backend, RuntimeBackend::Native);
assert_eq!(config.storage, StorageType::Sqlite);
assert_eq!(config.credential_socket, None);
assert_eq!(config.executor.r#type, ExecutorType::Local);
assert_eq!(config.executor.agent_timeout, Duration::from_secs(45 * 60));
assert_eq!(
config.executor.limits,
ExecutorLimitsConfig {
memory: 8 * 1024 * 1024 * 1024,
memory_swap: 8 * 1024 * 1024 * 1024,
cpu_quota: 400_000,
pids_limit: 1024,
}
);
assert_eq!(config.http_security.max_json_body_bytes, 1_048_576);
assert_eq!(config.http_security.max_hook_body_bytes, 262_144);
assert_eq!(config.http_security.max_ws_frame_bytes, 65_536);
assert_eq!(config.http_security.max_ws_message_bytes, 262_144);
assert_eq!(config.http_security.max_ws_queue, 256);
assert_eq!(config.http_security.max_ws_malformed, 3);
assert_eq!(config.http_security.auth_failures_per_minute, 12);
assert!(config.http_security.trusted_proxy_cidrs.is_empty());
}
#[test]
fn mode_container_defaults_to_docker_profile() {
let config: RawLfdConfig = serde_yaml_ng::from_str("mode: container").expect("yaml parses");
let resolved = config.resolve().expect("container resolves");
assert_eq!(resolved.mode, Mode::Container);
assert_eq!(resolved.runtime_backend, RuntimeBackend::Compose);
assert_eq!(resolved.storage, StorageType::Postgres);
assert_eq!(resolved.executor.r#type, ExecutorType::Docker);
assert_eq!(resolved.auth.mode, AuthMode::Studio);
}
#[test]
fn executor_sandbox_key_is_rejected() {
let raw = r#"
mode: container
executor:
sandbox: true
"#;
let config: RawLfdConfig = serde_yaml_ng::from_str(raw).expect("yaml parses");
let err = config.resolve().expect_err("sandbox key should fail");
assert_eq!(
err.to_string(),
"`executor.sandbox` was removed; container mode is Docker-only now. Delete this key and rerun `lfd install`"
);
}
#[test]
fn executor_sandbox_null_key_is_rejected() {
let raw = r#"
mode: container
executor:
sandbox:
"#;
let config: RawLfdConfig = serde_yaml_ng::from_str(raw).expect("yaml parses");
let err = config.resolve().expect_err("sandbox key should fail");
assert_eq!(
err.to_string(),
"`executor.sandbox` was removed; container mode is Docker-only now. Delete this key and rerun `lfd install`"
);
}
#[test]
fn mode_container_preserves_explicit_local_token_auth() {
let raw = r#"
mode: container
auth:
mode: local
token: explicit-token
"#;
let config: RawLfdConfig = serde_yaml_ng::from_str(raw).expect("yaml parses");
let resolved = config.resolve().expect("container resolves");
assert_eq!(resolved.auth.mode, AuthMode::Local);
}
#[test]
fn explicit_runtime_override_in_yaml_is_rejected() {
let raw = r#"
mode: container
runtime_backend: native
"#;
let config: RawLfdConfig = serde_yaml_ng::from_str(raw).expect("yaml parses");
let err = config.resolve().expect_err("override should fail");
assert_eq!(
err.to_string(),
"`runtime_backend` is managed by `mode`; remove this key"
);
}
#[test]
fn explicit_executor_type_in_yaml_is_rejected() {
let raw = r#"
executor:
type: docker
"#;
let config: RawLfdConfig = serde_yaml_ng::from_str(raw).expect("yaml parses");
let err = config
.resolve()
.expect_err("executor type override should fail");
assert_eq!(
err.to_string(),
"`executor.type` is managed by `mode`; remove this key"
);
}
#[test]
fn raw_credential_mount_paths_are_rejected() {
let raw = r#"
executor:
credentials:
mounts:
- ~/.claude:/home/agent/.claude
"#;
let result = serde_yaml_ng::from_str::<RawLfdConfig>(raw);
assert!(result.is_err());
}
#[test]
fn env_overrides_allowed_fields() {
let _lock = env_lock().lock().expect("env lock");
let _guard = EnvGuard::snapshot(&[
"LFD_MODE",
"LFD_AUTH_MODE",
"LFD_AUTH_TOKEN",
"LFD_CREDENTIAL_SOCKET",
"LFD_EXECUTOR_CREDENTIALS_ENV",
"LFD_EXECUTOR_IMAGE",
"LFD_EXECUTOR_AGENT_TIMEOUT",
"LFD_EXECUTOR_LIMITS_MEMORY",
"LFD_EXECUTOR_LIMITS_MEMORY_SWAP",
"LFD_EXECUTOR_LIMITS_CPU_QUOTA",
"LFD_EXECUTOR_LIMITS_PIDS_LIMIT",
"LFD_GITHUB_WEBHOOK_SECRET",
"LFD_GITHUB_TOKEN",
"LFD_HTTP_MAX_JSON_BODY_BYTES",
"LFD_HTTP_MAX_HOOK_BODY_BYTES",
"LFD_HTTP_MAX_WS_FRAME_BYTES",
"LFD_HTTP_MAX_WS_MESSAGE_BYTES",
"LFD_HTTP_MAX_WS_QUEUE",
"LFD_HTTP_MAX_WS_MALFORMED",
"LFD_HTTP_AUTH_FAILURES_PER_MINUTE",
"LFD_HTTP_TRUSTED_PROXY_CIDRS",
]);
std::env::set_var("LFD_MODE", "container");
std::env::set_var("LFD_AUTH_MODE", "studio");
std::env::set_var("LFD_AUTH_TOKEN", "env-token-456");
std::env::set_var("LFD_CREDENTIAL_SOCKET", "/tmp/concerto-auth.sock");
std::env::set_var(
"LFD_EXECUTOR_CREDENTIALS_ENV",
"ANTHROPIC_API_KEY,OPENAI_API_KEY",
);
std::env::set_var("LFD_EXECUTOR_IMAGE", "loopflow/agent:env");
std::env::set_var("LFD_EXECUTOR_AGENT_TIMEOUT", "30m");
std::env::set_var("LFD_EXECUTOR_LIMITS_MEMORY", "2147483648");
std::env::set_var("LFD_EXECUTOR_LIMITS_MEMORY_SWAP", "2147483648");
std::env::set_var("LFD_EXECUTOR_LIMITS_CPU_QUOTA", "100000");
std::env::set_var("LFD_EXECUTOR_LIMITS_PIDS_LIMIT", "256");
std::env::set_var("LFD_GITHUB_WEBHOOK_SECRET", "env-secret");
std::env::set_var("LFD_GITHUB_TOKEN", "ghp_env");
std::env::set_var("LFD_HTTP_MAX_JSON_BODY_BYTES", "2097152");
std::env::set_var("LFD_HTTP_MAX_HOOK_BODY_BYTES", "131072");
std::env::set_var("LFD_HTTP_MAX_WS_FRAME_BYTES", "32768");
std::env::set_var("LFD_HTTP_MAX_WS_MESSAGE_BYTES", "131072");
std::env::set_var("LFD_HTTP_MAX_WS_QUEUE", "64");
std::env::set_var("LFD_HTTP_MAX_WS_MALFORMED", "5");
std::env::set_var("LFD_HTTP_AUTH_FAILURES_PER_MINUTE", "9");
std::env::set_var("LFD_HTTP_TRUSTED_PROXY_CIDRS", "127.0.0.1/32,10.0.0.0/8");
let mut config = RawLfdConfig::default();
config.apply_env_overrides().expect("overrides apply");
let resolved = config.resolve().expect("resolved");
assert_eq!(resolved.mode, Mode::Container);
assert_eq!(resolved.storage, StorageType::Postgres);
assert_eq!(resolved.executor.r#type, ExecutorType::Docker);
assert_eq!(
resolved.credential_socket,
Some("/tmp/concerto-auth.sock".to_string())
);
assert_eq!(resolved.auth.mode, AuthMode::Studio);
assert_eq!(
resolved
.auth
.token
.as_ref()
.map(|t| t.expose_secret().as_str()),
Some("env-token-456")
);
assert_eq!(
resolved.executor.credentials.env,
vec!["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]
);
assert_eq!(resolved.executor.image, "loopflow/agent:env");
assert_eq!(
resolved.executor.agent_timeout,
Duration::from_secs(30 * 60)
);
assert_eq!(
resolved.executor.limits,
ExecutorLimitsConfig {
memory: 2 * 1024 * 1024 * 1024,
memory_swap: 2 * 1024 * 1024 * 1024,
cpu_quota: 100_000,
pids_limit: 256,
}
);
assert_eq!(resolved.github.webhook_secret, "env-secret");
assert_eq!(
resolved
.github
.token
.as_ref()
.map(|token| token.expose_secret().as_str()),
Some("ghp_env")
);
assert_eq!(resolved.http_security.max_json_body_bytes, 2_097_152);
assert_eq!(resolved.http_security.max_hook_body_bytes, 131_072);
assert_eq!(resolved.http_security.max_ws_frame_bytes, 32_768);
assert_eq!(resolved.http_security.max_ws_message_bytes, 131_072);
assert_eq!(resolved.http_security.max_ws_queue, 64);
assert_eq!(resolved.http_security.max_ws_malformed, 5);
assert_eq!(resolved.http_security.auth_failures_per_minute, 9);
assert_eq!(resolved.http_security.trusted_proxy_cidrs.len(), 2);
}
#[test]
fn invalid_mode_env_override_is_rejected() {
let _lock = env_lock().lock().expect("env lock");
let _guard = EnvGuard::snapshot(&["LFD_MODE"]);
std::env::set_var("LFD_MODE", "invalid");
let mut config = RawLfdConfig::default();
let err = config
.apply_env_overrides()
.expect_err("invalid mode should fail");
assert_eq!(
err.to_string(),
"invalid LFD_MODE value 'invalid'; expected 'native' or 'container'"
);
}
#[test]
fn auth_mode_yaml_rejects_removed_ci_alias() {
let raw = r#"
auth:
mode: ci
"#;
let err = serde_yaml_ng::from_str::<RawLfdConfig>(raw).expect_err("ci mode rejected");
assert!(err.to_string().contains("expected 'local' or 'studio'"));
}
#[test]
fn legacy_auth_provider_key_is_rejected() {
let raw = r#"
auth:
provider: studio
"#;
let err = serde_yaml_ng::from_str::<RawLfdConfig>(raw).expect_err("provider key rejected");
assert!(err.to_string().contains("unknown field `provider`"));
}
#[test]
fn invalid_executor_limits_env_override_is_rejected() {
let _lock = env_lock().lock().expect("env lock");
let _guard = EnvGuard::snapshot(&["LFD_EXECUTOR_LIMITS_MEMORY"]);
std::env::set_var("LFD_EXECUTOR_LIMITS_MEMORY", "0");
let mut config = RawLfdConfig::default();
let err = config
.apply_env_overrides()
.expect_err("invalid executor limit should fail");
assert_eq!(
err.to_string(),
"invalid LFD_EXECUTOR_LIMITS_MEMORY value '0' for executor.limits.memory: must be greater than zero"
);
}
#[test]
fn invalid_http_limit_env_override_is_rejected() {
let _lock = env_lock().lock().expect("env lock");
let _guard = EnvGuard::snapshot(&["LFD_HTTP_MAX_WS_QUEUE"]);
std::env::set_var("LFD_HTTP_MAX_WS_QUEUE", "0");
let mut config = RawLfdConfig::default();
let err = config
.apply_env_overrides()
.expect_err("invalid HTTP limit should fail");
assert_eq!(
err.to_string(),
"invalid LFD_HTTP_MAX_WS_QUEUE value '0' for http_security.max_ws_queue: must be greater than zero"
);
}
#[test]
fn invalid_trusted_proxy_cidr_is_rejected() {
let raw = r#"
http_security:
trusted_proxy_cidrs:
- not-a-cidr
"#;
let config: RawLfdConfig = serde_yaml_ng::from_str(raw).expect("yaml parses");
let err = config.resolve().expect_err("invalid cidr should fail");
assert!(err
.to_string()
.contains("invalid trusted proxy CIDR 'not-a-cidr'"));
}
#[test]
fn load_invalid_yaml_returns_error() {
let _lock = env_lock().lock().expect("env lock");
let _guard = EnvGuard::snapshot(&[
"LFD_MODE",
"LFD_AUTH_MODE",
"LFD_AUTH_TOKEN",
"LFD_CREDENTIAL_SOCKET",
"LFD_EXECUTOR_CREDENTIALS_ENV",
"LFD_EXECUTOR_IMAGE",
"LFD_EXECUTOR_AGENT_TIMEOUT",
"LFD_EXECUTOR_LIMITS_MEMORY",
"LFD_EXECUTOR_LIMITS_MEMORY_SWAP",
"LFD_EXECUTOR_LIMITS_CPU_QUOTA",
"LFD_EXECUTOR_LIMITS_PIDS_LIMIT",
"LFD_GITHUB_WEBHOOK_SECRET",
"LFD_GITHUB_TOKEN",
"LFD_HTTP_MAX_JSON_BODY_BYTES",
"LFD_HTTP_MAX_HOOK_BODY_BYTES",
"LFD_HTTP_MAX_WS_FRAME_BYTES",
"LFD_HTTP_MAX_WS_MESSAGE_BYTES",
"LFD_HTTP_MAX_WS_QUEUE",
"LFD_HTTP_MAX_WS_MALFORMED",
"LFD_HTTP_AUTH_FAILURES_PER_MINUTE",
"LFD_HTTP_TRUSTED_PROXY_CIDRS",
]);
let tmp = tempdir().expect("tempdir");
let lf_dir = tmp.path().join(".lf");
std::fs::create_dir_all(&lf_dir).expect("lf dir");
std::fs::write(lf_dir.join("lfd.yaml"), "executor: [").expect("write config");
let original_home = std::env::var_os("HOME");
std::env::set_var("HOME", tmp.path());
let result = LfdConfig::load();
match original_home {
Some(home) => std::env::set_var("HOME", home),
None => std::env::remove_var("HOME"),
}
assert!(result.is_err());
}
#[test]
fn executor_agent_timeout_accepts_duration_string() {
let raw = r#"
executor:
agent_timeout: 75s
"#;
let config: RawLfdConfig = serde_yaml_ng::from_str(raw).expect("yaml parses");
let resolved = config.resolve().expect("resolved");
assert_eq!(resolved.executor.agent_timeout, Duration::from_secs(75));
}
#[test]
fn executor_limits_accept_yaml_override() {
let raw = r#"
executor:
limits:
memory: 2147483648
memory_swap: 2147483648
cpu_quota: 100000
pids_limit: 256
"#;
let config: RawLfdConfig = serde_yaml_ng::from_str(raw).expect("yaml parses");
let resolved = config.resolve().expect("resolved");
assert_eq!(
resolved.executor.limits,
ExecutorLimitsConfig {
memory: 2 * 1024 * 1024 * 1024,
memory_swap: 2 * 1024 * 1024 * 1024,
cpu_quota: 100_000,
pids_limit: 256,
}
);
}
#[test]
fn executor_limits_reject_non_positive_yaml_values() {
let raw = r#"
executor:
limits:
pids_limit: 0
"#;
let config: RawLfdConfig = serde_yaml_ng::from_str(raw).expect("yaml parses");
let err = config.resolve().expect_err("invalid limits should fail");
assert_eq!(
err.to_string(),
"executor.limits.pids_limit must be greater than zero"
);
}
#[test]
fn github_config_debug_redacts_secrets() {
let github = GitHubConfig {
webhook_secret: "whsec_123".to_string(),
token: Some(SecretString::new("ghp_abc".to_string())),
};
let rendered = format!("{github:?}");
assert!(rendered.contains("webhook_secret: \"[REDACTED]\""));
assert!(rendered.contains("token: Some(\"[REDACTED]\")"));
assert!(!rendered.contains("whsec_123"));
assert!(!rendered.contains("ghp_abc"));
}
}