use std::path::{Path, PathBuf};
use serde::Deserialize;
use crate::error::{OlError, ERR_INVALID_CONFIG, ERR_PORT_IN_USE};
#[derive(Debug, Clone)]
pub struct UpdateConfig {
pub check: bool,
pub registry_origin: String,
pub download_timeout_secs: u64,
pub auto_update: bool,
pub check_interval_secs: u64,
pub quiet_window_secs: u64,
pub max_defer_secs: u64,
}
impl Default for UpdateConfig {
fn default() -> Self {
Self {
check: true,
registry_origin: "https://registry.npmjs.org".into(),
download_timeout_secs: 60,
auto_update: true,
check_interval_secs: 6 * 60 * 60,
quiet_window_secs: 60,
max_defer_secs: 24 * 60 * 60,
}
}
}
#[derive(Debug, Clone)]
pub struct CloudConfig {
pub enabled: bool,
pub api_url: String,
pub timeout_connect_ms: u64,
pub timeout_total_ms: u64,
pub retry_delay_ms: u64,
pub channel_size: usize,
pub credential_poll_interval_ms: u64,
pub outbox_enabled: bool,
pub outbox_max_bytes: u64,
pub fallback_max_bytes: u64,
pub batch_max_events: usize,
pub batch_max_wait_ms: u64,
}
impl Default for CloudConfig {
fn default() -> Self {
Self {
enabled: true,
api_url: "https://app.openlatch.ai".into(),
timeout_connect_ms: 5000,
timeout_total_ms: 10000,
retry_delay_ms: 2000,
channel_size: 1000,
credential_poll_interval_ms: 60_000,
outbox_enabled: true,
outbox_max_bytes: 104_857_600,
fallback_max_bytes: 52_428_800,
batch_max_events: 50,
batch_max_wait_ms: 5000,
}
}
}
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ContentForwardMode {
#[default]
Filtered,
HashOnly,
FullUnfiltered,
}
#[derive(Debug, Clone)]
pub struct InventoryMonitorConfig {
pub enabled: bool,
pub periodic_rescan_interval_hours: u64,
pub watcher_debounce_ms: u64,
pub max_inline_content_bytes: u64,
pub content_forward: ContentForwardMode,
pub project_scope_auto_detect: bool,
pub cache_max_entries: usize,
}
impl Default for InventoryMonitorConfig {
fn default() -> Self {
Self {
enabled: true,
periodic_rescan_interval_hours: 12,
watcher_debounce_ms: 500,
max_inline_content_bytes: 65_536,
content_forward: ContentForwardMode::Filtered,
project_scope_auto_detect: true,
cache_max_entries: 4096,
}
}
}
#[derive(Debug, Clone)]
pub struct PolicyConfig {
pub enabled: bool,
pub poll_interval_secs: u64,
pub stale_warn_after_secs: u64,
}
impl Default for PolicyConfig {
fn default() -> Self {
Self {
enabled: true,
poll_interval_secs: 300,
stale_warn_after_secs: 86_400,
}
}
}
#[derive(Debug, Clone)]
pub struct BoundaryConfig {
pub enabled: bool,
pub port: u16,
pub upstream: String,
}
impl Default for BoundaryConfig {
fn default() -> Self {
Self {
enabled: true,
port: crate::boundary::default_boundary_port(),
upstream: crate::boundary::ANTHROPIC_BASE.to_string(),
}
}
}
impl BoundaryConfig {
pub fn owns_agent_wiring(&self) -> bool {
self.port == crate::boundary::default_boundary_port()
}
pub fn upstream_url(&self) -> reqwest::Url {
reqwest::Url::parse(&self.upstream).unwrap_or_else(|_| {
tracing::warn!(
upstream = %self.upstream,
"[boundary] upstream is not a valid URL — forwarding to {} instead",
crate::boundary::ANTHROPIC_BASE
);
crate::boundary::default_upstream()
})
}
}
pub fn openlatch_dir() -> PathBuf {
if let Ok(dir) = std::env::var("OPENLATCH_DIR") {
if !dir.is_empty() {
return PathBuf::from(dir);
}
}
#[cfg(windows)]
{
dirs::data_dir()
.unwrap_or_else(|| dirs::home_dir().expect("home directory must exist"))
.join("openlatch")
}
#[cfg(not(windows))]
{
dirs::home_dir()
.expect("home directory must exist")
.join(".openlatch")
}
}
fn env_bool(name: &str) -> Option<bool> {
std::env::var(name)
.ok()
.map(|v| matches!(v.as_str(), "true" | "1"))
}
pub fn sniff_agent_id(openlatch_dir: &Path) -> Option<String> {
std::fs::read_to_string(openlatch_dir.join("config.toml"))
.ok()
.and_then(|raw| {
raw.lines()
.find_map(|l| {
let l = l.trim();
l.strip_prefix("agent_id")
.and_then(|rest| rest.split('=').nth(1))
.map(|v| v.trim().trim_matches('"').to_string())
})
.filter(|s| s.starts_with("agt_"))
})
}
#[derive(Debug, Clone)]
pub struct Config {
pub port: u16,
pub log_dir: PathBuf,
pub log_level: String,
pub retention_days: u32,
pub extra_patterns: Vec<String>,
pub foreground: bool,
pub update: UpdateConfig,
pub cloud: CloudConfig,
pub agent_id: Option<String>,
pub supervision: crate::supervision::SupervisionConfig,
pub inventory_monitor: InventoryMonitorConfig,
pub policy: PolicyConfig,
pub boundary: BoundaryConfig,
}
impl Config {
pub fn defaults() -> Self {
Self {
port: 7443,
log_dir: openlatch_dir().join("logs"),
log_level: "info".into(),
retention_days: 30,
extra_patterns: vec![],
foreground: false,
update: UpdateConfig::default(),
cloud: CloudConfig::default(),
agent_id: None,
supervision: crate::supervision::SupervisionConfig::default(),
inventory_monitor: InventoryMonitorConfig::default(),
policy: PolicyConfig::default(),
boundary: BoundaryConfig::default(),
}
}
pub fn load(
cli_port: Option<u16>,
cli_log_level: Option<String>,
cli_foreground: bool,
) -> Result<Self, OlError> {
let mut cfg = Self::defaults();
let config_path = openlatch_dir().join("config.toml");
if config_path.exists() {
let raw = std::fs::read_to_string(&config_path).map_err(|e| {
OlError::new(ERR_INVALID_CONFIG, format!("Cannot read config file: {e}"))
.with_suggestion("Check that the file is readable and not corrupted.")
})?;
let toml_cfg: TomlConfig = toml::from_str(&raw).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Invalid TOML in config file: {e}"),
)
.with_suggestion("Check your config.toml for syntax errors.")
.with_docs("https://docs.openlatch.ai/configuration")
})?;
if let Some(daemon) = toml_cfg.daemon {
if let Some(port) = daemon.port {
cfg.port = port;
}
if let Some(ref mid) = daemon.agent_id {
cfg.agent_id = Some(mid.clone());
}
}
if let Some(logging) = toml_cfg.logging {
if let Some(level) = logging.level {
cfg.log_level = level;
}
if let Some(dir) = logging.dir {
cfg.log_dir = PathBuf::from(dir);
}
if let Some(days) = logging.retention_days {
cfg.retention_days = days;
}
}
if let Some(privacy) = toml_cfg.privacy {
if let Some(patterns) = privacy.extra_patterns {
cfg.extra_patterns = patterns;
}
}
if let Some(update) = toml_cfg.update {
if let Some(check) = update.check {
cfg.update.check = check;
}
if let Some(origin) = update.registry_origin {
cfg.update.registry_origin = origin;
}
if let Some(secs) = update.download_timeout_secs {
cfg.update.download_timeout_secs = secs;
}
if let Some(v) = update.auto_update {
cfg.update.auto_update = v;
}
if let Some(v) = update.check_interval_secs {
cfg.update.check_interval_secs = v;
}
if let Some(v) = update.quiet_window_secs {
cfg.update.quiet_window_secs = v;
}
if let Some(v) = update.max_defer_secs {
cfg.update.max_defer_secs = v;
}
}
if let Some(sup) = toml_cfg.supervision {
use crate::supervision::{SupervisionMode, SupervisorKind};
if let Some(mode) = sup.mode.as_deref() {
cfg.supervision.mode = match mode {
"active" => SupervisionMode::Active,
"deferred" => SupervisionMode::Deferred,
_ => SupervisionMode::Disabled,
};
}
if let Some(backend) = sup.backend.as_deref() {
cfg.supervision.backend = match backend {
"launchd" => SupervisorKind::Launchd,
"systemd" => SupervisorKind::Systemd,
"task_scheduler" => SupervisorKind::TaskScheduler,
_ => SupervisorKind::None,
};
}
cfg.supervision.disabled_reason = sup.disabled_reason;
}
if let Some(inv) = toml_cfg.inventory_monitor {
if let Some(v) = inv.enabled {
cfg.inventory_monitor.enabled = v;
}
if let Some(v) = inv.periodic_rescan_interval_hours {
cfg.inventory_monitor.periodic_rescan_interval_hours = v;
}
if let Some(v) = inv.watcher_debounce_ms {
cfg.inventory_monitor.watcher_debounce_ms = v;
}
if let Some(v) = inv.max_inline_content_bytes {
cfg.inventory_monitor.max_inline_content_bytes = v;
}
if let Some(v) = inv.content_forward {
cfg.inventory_monitor.content_forward = v;
}
if let Some(v) = inv.project_scope_auto_detect {
cfg.inventory_monitor.project_scope_auto_detect = v;
}
if let Some(v) = inv.cache_max_entries {
cfg.inventory_monitor.cache_max_entries = v;
}
}
if let Some(cloud) = toml_cfg.cloud {
if let Some(v) = cloud.enabled {
cfg.cloud.enabled = v;
}
if let Some(v) = cloud.api_url {
cfg.cloud.api_url = v;
}
if let Some(v) = cloud.timeout_connect_ms {
cfg.cloud.timeout_connect_ms = v;
}
if let Some(v) = cloud.timeout_total_ms {
cfg.cloud.timeout_total_ms = v;
}
if let Some(v) = cloud.retry_delay_ms {
cfg.cloud.retry_delay_ms = v;
}
if let Some(v) = cloud.channel_size {
cfg.cloud.channel_size = v;
}
if let Some(v) = cloud.credential_poll_interval_ms {
cfg.cloud.credential_poll_interval_ms = v;
}
if let Some(v) = cloud.outbox_enabled {
cfg.cloud.outbox_enabled = v;
}
if let Some(v) = cloud.outbox_max_bytes {
cfg.cloud.outbox_max_bytes = v;
}
if let Some(v) = cloud.fallback_max_bytes {
cfg.cloud.fallback_max_bytes = v;
}
if let Some(v) = cloud.batch_max_events {
cfg.cloud.batch_max_events = v;
}
if let Some(v) = cloud.batch_max_wait_ms {
cfg.cloud.batch_max_wait_ms = v;
}
}
if let Some(policy) = toml_cfg.policy {
if let Some(v) = policy.enabled {
cfg.policy.enabled = v;
}
if let Some(v) = policy.poll_interval_secs {
cfg.policy.poll_interval_secs = v;
}
if let Some(v) = policy.stale_warn_after_secs {
cfg.policy.stale_warn_after_secs = v;
}
}
if let Some(boundary) = toml_cfg.boundary {
if let Some(v) = boundary.enabled {
cfg.boundary.enabled = v;
}
if let Some(v) = boundary.port {
cfg.boundary.port = v;
}
if let Some(v) = boundary.upstream {
cfg.boundary.upstream = v;
}
}
}
if let Ok(val) = std::env::var("OPENLATCH_PORT") {
cfg.port = parse_port_env(&val)?;
}
if let Ok(val) = std::env::var("OPENLATCH_LOG_DIR") {
cfg.log_dir = PathBuf::from(val);
}
if let Ok(val) = std::env::var("OPENLATCH_LOG") {
cfg.log_level = val;
}
if let Ok(val) = std::env::var("OPENLATCH_RETENTION_DAYS") {
cfg.retention_days = val.parse::<u32>().map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_RETENTION_DAYS is not a valid integer: '{val}'"),
)
.with_suggestion("Set OPENLATCH_RETENTION_DAYS to a positive integer.")
})?;
}
if let Ok(val) = std::env::var("OPENLATCH_UPDATE_CHECK") {
if val == "false" || val == "0" {
cfg.update.check = false;
}
}
if let Ok(val) = std::env::var("OPENLATCH_NPM_REGISTRY") {
if !val.is_empty() {
cfg.update.registry_origin = val;
}
}
if let Ok(val) = std::env::var("OPENLATCH_UPDATE_DOWNLOAD_TIMEOUT_SECS") {
if let Ok(secs) = val.parse::<u64>() {
cfg.update.download_timeout_secs = secs;
}
}
if let Some(v) = env_bool("OPENLATCH_AUTO_UPDATE") {
cfg.update.auto_update = v;
}
if let Ok(val) = std::env::var("OPENLATCH_UPDATE_CHECK_INTERVAL_SECS") {
if let Ok(secs) = val.parse::<u64>() {
cfg.update.check_interval_secs = secs;
}
}
if let Ok(val) = std::env::var("OPENLATCH_UPDATE_QUIET_WINDOW_SECS") {
if let Ok(secs) = val.parse::<u64>() {
cfg.update.quiet_window_secs = secs;
}
}
if let Ok(val) = std::env::var("OPENLATCH_UPDATE_MAX_DEFER_SECS") {
if let Ok(secs) = val.parse::<u64>() {
cfg.update.max_defer_secs = secs;
}
}
if let Some(v) = env_bool("OPENLATCH_CLOUD_ENABLED") {
cfg.cloud.enabled = v;
}
if let Ok(val) = std::env::var("OPENLATCH_API_URL") {
cfg.cloud.api_url = val;
}
if let Ok(val) = std::env::var("OPENLATCH_CLOUD_CREDENTIAL_POLL_MS") {
cfg.cloud.credential_poll_interval_ms = val.parse::<u64>().map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_CLOUD_CREDENTIAL_POLL_MS is not a valid integer: '{val}'"),
)
.with_suggestion(
"Set OPENLATCH_CLOUD_CREDENTIAL_POLL_MS to a positive integer (ms).",
)
})?;
}
if let Some(v) = env_bool("OPENLATCH_CLOUD_OUTBOX_ENABLED") {
cfg.cloud.outbox_enabled = v;
}
if let Ok(val) = std::env::var("OPENLATCH_CLOUD_OUTBOX_MAX_BYTES") {
cfg.cloud.outbox_max_bytes = val.parse::<u64>().map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_CLOUD_OUTBOX_MAX_BYTES is not a valid integer: '{val}'"),
)
.with_suggestion(
"Set OPENLATCH_CLOUD_OUTBOX_MAX_BYTES to a non-negative byte count (0 disables the cap).",
)
})?;
}
if let Ok(val) = std::env::var("OPENLATCH_CLOUD_FALLBACK_MAX_BYTES") {
cfg.cloud.fallback_max_bytes = val.parse::<u64>().map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_CLOUD_FALLBACK_MAX_BYTES is not a valid integer: '{val}'"),
)
.with_suggestion(
"Set OPENLATCH_CLOUD_FALLBACK_MAX_BYTES to a non-negative byte count (0 disables the cap).",
)
})?;
}
if let Ok(val) = std::env::var("OPENLATCH_CLOUD_BATCH_MAX_EVENTS") {
cfg.cloud.batch_max_events = val.parse::<usize>().map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_CLOUD_BATCH_MAX_EVENTS is not a valid integer: '{val}'"),
)
.with_suggestion(
"Set OPENLATCH_CLOUD_BATCH_MAX_EVENTS to an integer between 1 and 100 (values outside that range are clamped).",
)
})?;
}
if let Ok(val) = std::env::var("OPENLATCH_CLOUD_BATCH_MAX_WAIT_MS") {
cfg.cloud.batch_max_wait_ms = val.parse::<u64>().map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_CLOUD_BATCH_MAX_WAIT_MS is not a valid integer: '{val}'"),
)
.with_suggestion(
"Set OPENLATCH_CLOUD_BATCH_MAX_WAIT_MS to a positive integer (ms).",
)
})?;
}
if let Some(v) = env_bool("OPENLATCH_INVENTORY_ENABLED") {
cfg.inventory_monitor.enabled = v;
}
if let Ok(val) = std::env::var("OPENLATCH_INVENTORY_PERIODIC_RESCAN_HOURS") {
if let Ok(n) = val.parse::<u64>() {
cfg.inventory_monitor.periodic_rescan_interval_hours = n;
}
}
if let Ok(val) = std::env::var("OPENLATCH_INVENTORY_DEBOUNCE_MS") {
if let Ok(n) = val.parse::<u64>() {
cfg.inventory_monitor.watcher_debounce_ms = n;
}
}
if let Ok(val) = std::env::var("OPENLATCH_INVENTORY_MAX_INLINE_BYTES") {
if let Ok(n) = val.parse::<u64>() {
cfg.inventory_monitor.max_inline_content_bytes = n;
}
}
if let Ok(val) = std::env::var("OPENLATCH_INVENTORY_CONTENT_FORWARD") {
cfg.inventory_monitor.content_forward = match val.as_str() {
"filtered" => ContentForwardMode::Filtered,
"hash_only" => ContentForwardMode::HashOnly,
"full_unfiltered" => ContentForwardMode::FullUnfiltered,
_ => cfg.inventory_monitor.content_forward,
};
}
if let Some(v) = env_bool("OPENLATCH_INVENTORY_PROJECT_AUTO_DETECT") {
cfg.inventory_monitor.project_scope_auto_detect = v;
}
if let Ok(val) = std::env::var("OPENLATCH_INVENTORY_CACHE_MAX_ENTRIES") {
if let Ok(n) = val.parse::<usize>() {
cfg.inventory_monitor.cache_max_entries = n;
}
}
if let Some(v) = env_bool("OPENLATCH_POLICY_ENABLED") {
cfg.policy.enabled = v;
}
if let Ok(val) = std::env::var("OPENLATCH_POLICY_POLL_INTERVAL_SECS") {
cfg.policy.poll_interval_secs = val.parse::<u64>().map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_POLICY_POLL_INTERVAL_SECS is not a valid integer: '{val}'"),
)
.with_suggestion(
"Set OPENLATCH_POLICY_POLL_INTERVAL_SECS to a positive integer (seconds).",
)
})?;
}
if let Ok(val) = std::env::var("OPENLATCH_POLICY_STALE_WARN_SECS") {
cfg.policy.stale_warn_after_secs = val.parse::<u64>().map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_POLICY_STALE_WARN_SECS is not a valid integer: '{val}'"),
)
.with_suggestion(
"Set OPENLATCH_POLICY_STALE_WARN_SECS to a positive integer (seconds).",
)
})?;
}
if let Some(v) = env_bool("OPENLATCH_BOUNDARY_ENABLED") {
cfg.boundary.enabled = v;
}
if let Ok(val) = std::env::var("OPENLATCH_BOUNDARY_PORT") {
cfg.boundary.port = val.parse::<u16>().map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_BOUNDARY_PORT is not a valid port: '{val}'"),
)
.with_suggestion(
"Set OPENLATCH_BOUNDARY_PORT to a port number (1-65535), or unset it to use \
the default 7600. A non-default port makes the instance isolated: it does \
not write ~/.claude/settings.json.",
)
})?;
}
if let Ok(val) = std::env::var("OPENLATCH_BOUNDARY_UPSTREAM") {
if !val.trim().is_empty() {
reqwest::Url::parse(val.trim()).map_err(|_| {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_BOUNDARY_UPSTREAM is not a valid URL: '{val}'"),
)
.with_suggestion(
"Set it to an absolute origin such as http://127.0.0.1:8080, or unset it \
to forward to https://api.anthropic.com.",
)
})?;
cfg.boundary.upstream = val.trim().to_string();
}
}
if let Some(port) = cli_port {
cfg.port = port;
}
if let Some(level) = cli_log_level {
cfg.log_level = level;
}
if cli_foreground {
cfg.foreground = true;
}
cfg.cloud.batch_max_events = cfg.cloud.batch_max_events.clamp(1, 100);
Ok(cfg)
}
}
#[derive(Debug, Deserialize)]
struct TomlConfig {
#[serde(default)]
daemon: Option<DaemonToml>,
#[serde(default)]
logging: Option<LoggingToml>,
#[serde(default)]
privacy: Option<PrivacyToml>,
#[serde(default)]
update: Option<UpdateToml>,
#[serde(default)]
cloud: Option<CloudToml>,
#[serde(default)]
supervision: Option<SupervisionToml>,
#[serde(default)]
inventory_monitor: Option<InventoryMonitorToml>,
#[serde(default)]
policy: Option<PolicyToml>,
#[serde(default)]
boundary: Option<BoundaryToml>,
}
#[derive(Debug, Deserialize)]
struct DaemonToml {
port: Option<u16>,
agent_id: Option<String>,
}
#[derive(Debug, Deserialize)]
struct CloudToml {
enabled: Option<bool>,
api_url: Option<String>,
timeout_connect_ms: Option<u64>,
timeout_total_ms: Option<u64>,
retry_delay_ms: Option<u64>,
channel_size: Option<usize>,
credential_poll_interval_ms: Option<u64>,
outbox_enabled: Option<bool>,
outbox_max_bytes: Option<u64>,
fallback_max_bytes: Option<u64>,
batch_max_events: Option<usize>,
batch_max_wait_ms: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct LoggingToml {
level: Option<String>,
dir: Option<String>,
retention_days: Option<u32>,
}
#[derive(Debug, Deserialize)]
struct PrivacyToml {
extra_patterns: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct UpdateToml {
check: Option<bool>,
registry_origin: Option<String>,
download_timeout_secs: Option<u64>,
auto_update: Option<bool>,
check_interval_secs: Option<u64>,
quiet_window_secs: Option<u64>,
max_defer_secs: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct SupervisionToml {
mode: Option<String>,
backend: Option<String>,
disabled_reason: Option<String>,
}
#[derive(Debug, Deserialize)]
struct PolicyToml {
enabled: Option<bool>,
poll_interval_secs: Option<u64>,
stale_warn_after_secs: Option<u64>,
}
#[derive(Debug, Deserialize)]
struct BoundaryToml {
enabled: Option<bool>,
port: Option<u16>,
upstream: Option<String>,
}
#[derive(Debug, Deserialize)]
struct InventoryMonitorToml {
enabled: Option<bool>,
periodic_rescan_interval_hours: Option<u64>,
watcher_debounce_ms: Option<u64>,
max_inline_content_bytes: Option<u64>,
content_forward: Option<ContentForwardMode>,
project_scope_auto_detect: Option<bool>,
cache_max_entries: Option<usize>,
}
const KNOWN_CONFIG_SECTIONS: &[(&str, &[&str])] = &[
("daemon", &["port", "agent_id"]),
("logging", &["level", "dir", "retention_days"]),
("privacy", &["extra_patterns"]),
(
"update",
&[
"check",
"registry_origin",
"download_timeout_secs",
"auto_update",
"check_interval_secs",
"quiet_window_secs",
"max_defer_secs",
],
),
(
"cloud",
&[
"enabled",
"api_url",
"timeout_connect_ms",
"timeout_total_ms",
"retry_delay_ms",
"channel_size",
"credential_poll_interval_ms",
"outbox_enabled",
"outbox_max_bytes",
"fallback_max_bytes",
"batch_max_events",
"batch_max_wait_ms",
],
),
("supervision", &["mode", "backend", "disabled_reason"]),
(
"inventory_monitor",
&[
"enabled",
"periodic_rescan_interval_hours",
"watcher_debounce_ms",
"max_inline_content_bytes",
"content_forward",
"project_scope_auto_detect",
"cache_max_entries",
],
),
(
"policy",
&["enabled", "poll_interval_secs", "stale_warn_after_secs"],
),
("boundary", &["enabled", "port", "upstream"]),
];
pub(crate) fn collect_unknown_config_keys(raw: &str) -> Vec<String> {
let Ok(table) = raw.parse::<toml::Table>() else {
return Vec::new();
};
let mut unknown = Vec::new();
for (section, value) in &table {
let Some((_, allowed)) = KNOWN_CONFIG_SECTIONS
.iter()
.find(|(name, _)| name == section)
else {
unknown.push(section.clone());
continue;
};
if let Some(sub) = value.as_table() {
for key in sub.keys() {
if !allowed.contains(&key.as_str()) {
unknown.push(format!("{section}.{key}"));
}
}
}
}
unknown
}
pub fn unknown_config_keys_on_disk() -> Vec<String> {
match std::fs::read_to_string(openlatch_dir().join("config.toml")) {
Ok(raw) => collect_unknown_config_keys(&raw),
Err(_) => Vec::new(),
}
}
pub fn generate_default_config_toml(port: u16) -> String {
format!(
r#"# OpenLatch Configuration
# Uncomment and modify values to customize behavior.
[daemon]
port = {port}
# SECURITY: bind address is always 127.0.0.1 — not configurable
# agent_id is generated by 'openlatch init'
[logging]
# level = "info"
# dir = "~/.openlatch/logs"
# retention_days = 30
[privacy]
# Extra regex patterns for secret masking (additive to built-ins).
# Each entry is a regex string applied to JSON string values.
# extra_patterns = ["CUSTOM_SECRET_[A-Z0-9]{{32}}"]
# [update]
# check = true # Set to false to disable update checks on daemon start
# [cloud]
# enabled = true
# api_url = "https://app.openlatch.ai"
# timeout_connect_ms = 5000
# timeout_total_ms = 10000
# retry_delay_ms = 2000
# channel_size = 1000
# batch_max_events = 50 # events per cloud POST (clamped to 1..=100; 1 = one POST per event)
# batch_max_wait_ms = 5000 # flush a partial batch this long after its FIRST event
# outbox_max_bytes = 104857600 # 100 MB cap on outbox.jsonl (drop-oldest)
# fallback_max_bytes = 52428800 # 50 MB cap on the UNREPLAYED window of fallback.jsonl, not on the
# # file: drop-oldest advances the read offset instead of rewriting.
# # The dead prefix is reclaimed only when a daemon fully drains the
# # file, so across a long outage the file on disk grows past this.
# # Daemon-side only — the hook uses a compiled-in 50 MB while the
# # daemon is down, which is exactly when this cap would matter.
# [policy]
# Local policy evaluation. On by default (secure-by-default). `enabled = false`
# is a complete off switch: no bundle is fetched and any bundle already on disk
# is not consulted — the daemon returns allow exactly as it does with this
# section absent. The on-disk bundle is left in place, so re-enabling does not
# re-download.
# enabled = true
# poll_interval_secs = 300 # +/-10% jitter is applied to every interval
# stale_warn_after_secs = 86400 # warn (OL-1213) after this long with no successful poll
# [boundary]
# Model-boundary listener — the loopback proxy for model-call economics +
# attribution. On by default (secure-by-default). Set `enabled = false` to skip
# binding the pinned loopback port and leave the agent connected directly to the
# provider. While enabled, the daemon points the agent at the listener via
# ANTHROPIC_BASE_URL — but only after a synthetic request has proven the listener
# can actually reach the provider through it — and removes it again when it stops
# or when that stops being true. So Claude Code Remote Control is disabled exactly
# while the boundary is up AND working. Opt out here, or per-install via
# `openlatch init --no-boundary`.
# enabled = true
# port = 7600 # a NON-default port makes this instance isolated:
# # it binds and serves but never writes or clears
# # ~/.claude/settings.json (that file belongs to the
# # daemon on the default port). Route sessions to it
# # with ANTHROPIC_BASE_URL=http://127.0.0.1:<port>.
# upstream = "https://api.anthropic.com"
# # where the listener forwards. Every model call and
# # every provider credential on this host goes here,
# # so change it only for a local harness or a
# # deliberate gateway. Env: OPENLATCH_BOUNDARY_UPSTREAM.
# [supervision]
# OS-native auto-restart (launchd / systemd-user / Task Scheduler).
# Managed by `openlatch init` and `openlatch supervision {{install|uninstall|enable|disable}}`.
# mode = "disabled" # "active" | "deferred" | "disabled"
# backend = "none" # "launchd" | "systemd" | "task_scheduler" | "none"
# disabled_reason = "user_opt_out"
"#
)
}
pub fn ensure_config(port: u16) -> Result<PathBuf, OlError> {
let dir = openlatch_dir();
std::fs::create_dir_all(&dir).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot create config directory '{}': {e}", dir.display()),
)
.with_suggestion("Check that you have write permission to your home directory.")
})?;
let config_path = dir.join("config.toml");
if !config_path.exists() {
let content = generate_default_config_toml(port);
std::fs::write(&config_path, content).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot write config file '{}': {e}", config_path.display()),
)
.with_suggestion("Check that you have write permission to ~/.openlatch/.")
})?;
}
Ok(config_path)
}
pub fn generate_token() -> String {
let a = uuid::Uuid::new_v4();
let b = uuid::Uuid::new_v4();
format!("{}{}", a.simple(), b.simple())
}
pub fn ensure_token(dir: &Path) -> Result<String, OlError> {
let token_path = dir.join("daemon.token");
if token_path.exists() {
let token = std::fs::read_to_string(&token_path).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot read token file '{}': {e}", token_path.display()),
)
.with_suggestion("Check that the file exists and is readable.")
})?;
return Ok(token.trim().to_string());
}
let token = generate_token();
if let Some(parent) = token_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot create directory '{}': {e}", parent.display()),
)
.with_suggestion("Check that you have write permission to the parent directory.")
})?;
}
std::fs::write(&token_path, &token).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot write token file '{}': {e}", token_path.display()),
)
.with_suggestion("Check that you have write permission to the openlatch directory.")
})?;
crate::fs_secure::restrict_to_owner(&token_path).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot set permissions on token file: {e}"),
)
.with_suggestion("Check that you have permission to modify file attributes.")
})?;
Ok(token)
}
pub fn ensure_agent_id(config_path: &Path) -> Result<String, OlError> {
let raw = std::fs::read_to_string(config_path).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot read config file '{}': {e}", config_path.display()),
)
.with_suggestion("Check that the file exists and is readable.")
})?;
let toml_cfg: TomlConfig = toml::from_str(&raw).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Invalid TOML in config file: {e}"),
)
.with_suggestion("Check your config.toml for syntax errors.")
})?;
if let Some(ref daemon) = toml_cfg.daemon {
if let Some(ref existing_id) = daemon.agent_id {
return Ok(existing_id.clone());
}
}
let new_id = format!("agt_{}", uuid::Uuid::new_v4().simple());
let updated_raw = insert_agent_id_into_toml(&raw, &new_id);
std::fs::write(config_path, &updated_raw).map_err(|e| {
OlError::new(
crate::error::ERR_INVALID_CONFIG,
format!("Cannot write config file '{}': {e}", config_path.display()),
)
.with_suggestion("Check that you have write permission to ~/.openlatch/.")
})?;
Ok(new_id)
}
fn insert_agent_id_into_toml(raw: &str, agent_id: &str) -> String {
let agent_id_line = format!("agent_id = \"{agent_id}\"");
let daemon_header_pos = raw
.lines()
.enumerate()
.find(|(_, line)| line.trim() == "[daemon]")
.map(|(idx, _)| idx);
match daemon_header_pos {
Some(daemon_idx) => {
let lines: Vec<&str> = raw.lines().collect();
let insert_after = find_insert_position(&lines, daemon_idx);
let mut result = String::with_capacity(raw.len() + agent_id_line.len() + 1);
for (i, line) in lines.iter().enumerate() {
result.push_str(line);
result.push('\n');
if i == insert_after {
result.push_str(&agent_id_line);
result.push('\n');
}
}
result
}
None => {
let mut result = raw.to_string();
if !result.ends_with('\n') {
result.push('\n');
}
result.push_str("\n[daemon]\n");
result.push_str(&agent_id_line);
result.push('\n');
result
}
}
}
fn find_insert_position(lines: &[&str], daemon_header_idx: usize) -> usize {
let mut best = daemon_header_idx;
for (i, line) in lines.iter().enumerate().skip(daemon_header_idx + 1) {
let trimmed = line.trim();
if trimmed.starts_with('[') {
break;
}
if trimmed.starts_with("port") {
best = i;
break;
}
}
best
}
pub fn persist_supervision_state(
config_path: &Path,
mode: &crate::supervision::SupervisionMode,
backend: &crate::supervision::SupervisorKind,
disabled_reason: Option<&str>,
) -> Result<(), OlError> {
let mode_str = match mode {
crate::supervision::SupervisionMode::Active => "active",
crate::supervision::SupervisionMode::Deferred => "deferred",
crate::supervision::SupervisionMode::Disabled => "disabled",
};
let backend_str = match backend {
crate::supervision::SupervisorKind::Launchd => "launchd",
crate::supervision::SupervisorKind::Systemd => "systemd",
crate::supervision::SupervisorKind::TaskScheduler => "task_scheduler",
crate::supervision::SupervisorKind::None => "none",
};
if !config_path.exists() {
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot create config directory: {e}"),
)
})?;
}
std::fs::write(config_path, "").map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot create config file: {e}"),
)
})?;
}
let raw = std::fs::read_to_string(config_path).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot read config file '{}': {e}", config_path.display()),
)
})?;
let new_raw = rewrite_supervision_section(&raw, mode_str, backend_str, disabled_reason);
let tmp_path = config_path.with_extension("toml.tmp");
std::fs::write(&tmp_path, &new_raw)
.map_err(|e| OlError::new(ERR_INVALID_CONFIG, format!("Cannot write config tmp: {e}")))?;
std::fs::rename(&tmp_path, config_path)
.map_err(|e| OlError::new(ERR_INVALID_CONFIG, format!("Cannot rename config tmp: {e}")))?;
Ok(())
}
fn rewrite_supervision_section(
raw: &str,
mode: &str,
backend: &str,
disabled_reason: Option<&str>,
) -> String {
let mut block = String::new();
block.push_str("[supervision]\n");
block.push_str(&format!("mode = \"{mode}\"\n"));
block.push_str(&format!("backend = \"{backend}\"\n"));
if let Some(reason) = disabled_reason {
block.push_str(&format!("disabled_reason = \"{reason}\"\n"));
}
let lines: Vec<&str> = raw.lines().collect();
let mut header_idx: Option<usize> = None;
for (i, line) in lines.iter().enumerate() {
let trimmed = line.trim();
if trimmed == "[supervision]" || trimmed == "# [supervision]" {
header_idx = Some(i);
break;
}
}
match header_idx {
Some(start) => {
let mut end = lines.len();
for (i, line) in lines.iter().enumerate().skip(start + 1) {
let trimmed = line.trim();
if trimmed.starts_with('[') && !trimmed.starts_with("[supervision]") {
end = i;
break;
}
}
let mut result = String::new();
for line in lines.iter().take(start) {
result.push_str(line);
result.push('\n');
}
result.push_str(&block);
for line in lines.iter().skip(end) {
result.push_str(line);
result.push('\n');
}
result
}
None => {
let mut result = raw.to_string();
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
if !result.is_empty() {
result.push('\n');
}
result.push_str(&block);
result
}
}
}
pub fn persist_api_url(config_path: &Path, api_url: &str) -> Result<(), OlError> {
let raw = std::fs::read_to_string(config_path).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot read config file '{}': {e}", config_path.display()),
)
.with_suggestion("Run 'openlatch init' first to create it.")
})?;
let new_raw = set_cloud_api_url(&raw, api_url);
let tmp_path = config_path.with_extension("toml.tmp");
std::fs::write(&tmp_path, &new_raw)
.map_err(|e| OlError::new(ERR_INVALID_CONFIG, format!("Cannot write config tmp: {e}")))?;
std::fs::rename(&tmp_path, config_path)
.map_err(|e| OlError::new(ERR_INVALID_CONFIG, format!("Cannot rename config tmp: {e}")))?;
Ok(())
}
fn set_cloud_api_url(raw: &str, api_url: &str) -> String {
set_section_key(raw, "cloud", "api_url", &format!("\"{api_url}\""))
}
fn set_section_key(raw: &str, section: &str, key: &str, value: &str) -> String {
let is_key = |line: &str| {
line.trim()
.trim_start_matches('#')
.trim_start()
.starts_with(key)
};
let header = format!("[{section}]");
let commented_header = format!("# [{section}]");
let key_line = format!("{key} = {value}");
let lines: Vec<&str> = raw.lines().collect();
let Some(start) = lines
.iter()
.position(|l| l.trim() == header || l.trim() == commented_header)
else {
let mut result = raw.to_string();
if !result.is_empty() && !result.ends_with('\n') {
result.push('\n');
}
result.push('\n');
result.push_str(&header);
result.push('\n');
result.push_str(&key_line);
result.push('\n');
return result;
};
let end = lines
.iter()
.enumerate()
.skip(start + 1)
.find(|(_, l)| {
let t = l.trim();
t.starts_with('[') && t != header
})
.map_or(lines.len(), |(i, _)| i);
let body = &lines[start + 1..end];
let mut out: Vec<String> = lines[..start].iter().map(|l| (*l).to_string()).collect();
out.push(header);
if !body.iter().any(|l| is_key(l)) {
out.push(key_line.clone());
}
let mut written = false;
for line in body {
if is_key(line) {
if !written {
out.push(key_line.clone());
written = true;
}
continue;
}
out.push((*line).to_string());
}
out.extend(lines[end..].iter().map(|l| (*l).to_string()));
let mut result = out.join("\n");
result.push('\n');
result
}
pub fn persist_boundary_enabled(config_path: &Path, enabled: bool) -> Result<(), OlError> {
let raw = std::fs::read_to_string(config_path).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot read config file '{}': {e}", config_path.display()),
)
.with_suggestion("Run 'openlatch init' first to create it.")
})?;
let new_raw = set_section_key(&raw, "boundary", "enabled", &enabled.to_string());
let tmp_path = config_path.with_extension("toml.tmp");
std::fs::write(&tmp_path, &new_raw)
.map_err(|e| OlError::new(ERR_INVALID_CONFIG, format!("Cannot write config tmp: {e}")))?;
std::fs::rename(&tmp_path, config_path)
.map_err(|e| OlError::new(ERR_INVALID_CONFIG, format!("Cannot rename config tmp: {e}")))?;
Ok(())
}
pub const PORT_RANGE_START: u16 = 7443;
pub const PORT_RANGE_END: u16 = 7543;
pub const MIN_USER_PORT: u16 = 1024;
pub fn parse_port_env(value: &str) -> Result<u16, OlError> {
let invalid = || {
OlError::new(
ERR_INVALID_CONFIG,
format!("OPENLATCH_PORT is not a valid port number: '{value}'"),
)
.with_suggestion(format!(
"Set OPENLATCH_PORT to an integer between {MIN_USER_PORT} and {}.",
u16::MAX
))
};
let port: u16 = value.trim().parse().map_err(|_| invalid())?;
if port < MIN_USER_PORT {
return Err(invalid());
}
Ok(port)
}
pub fn probe_free_port(start: u16, end: u16) -> Result<u16, OlError> {
for port in start..=end {
if std::net::TcpListener::bind(("127.0.0.1", port)).is_ok() {
return Ok(port);
}
}
Err(OlError::new(
ERR_PORT_IN_USE,
format!("No free port found in range {start}-{end}"),
)
.with_suggestion(format!(
"Free a port in the {start}-{end} range, or set OPENLATCH_PORT to a specific port."
))
.with_docs("https://docs.openlatch.ai/errors/OL-1500"))
}
pub fn write_port_file(port: u16) -> Result<(), OlError> {
let path = openlatch_dir().join("daemon.port");
std::fs::write(&path, port.to_string()).map_err(|e| {
OlError::new(
ERR_INVALID_CONFIG,
format!("Cannot write port file '{}': {e}", path.display()),
)
})?;
Ok(())
}
pub fn read_port_file() -> Option<u16> {
let path = openlatch_dir().join("daemon.port");
std::fs::read_to_string(path)
.ok()?
.trim()
.parse::<u16>()
.ok()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_config_defaults_values() {
let cfg = Config::defaults();
assert_eq!(cfg.port, 7443, "Default port must be 7443");
assert_eq!(cfg.log_level, "info", "Default log level must be info");
assert_eq!(cfg.retention_days, 30, "Default retention must be 30 days");
}
#[test]
fn test_config_loads_from_toml_file() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(
&config_path,
r#"
[daemon]
port = 8080
"#,
)
.unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
let toml_cfg: TomlConfig = toml::from_str(&raw).unwrap();
let daemon = toml_cfg.daemon.unwrap();
assert_eq!(daemon.port, Some(8080));
}
#[test]
fn parse_port_env_rejects_zero_and_the_privileged_range() {
for bad in ["0", "1", "1023", "-1", "70000", "", " ", "7443x"] {
let err = parse_port_env(bad)
.expect_err("must reject {bad}: the suggestion promises 1024..=65535");
assert_eq!(err.code, ERR_INVALID_CONFIG, "input {bad:?}");
}
}
#[test]
fn parse_port_env_accepts_the_range_it_advertises() {
assert_eq!(parse_port_env("1024").unwrap(), 1024);
assert_eq!(parse_port_env("7443").unwrap(), PORT_RANGE_START);
assert_eq!(parse_port_env(" 7543 ").unwrap(), PORT_RANGE_END);
assert_eq!(parse_port_env("65535").unwrap(), u16::MAX);
}
#[test]
fn persist_boundary_enabled_activates_the_commented_template_block() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(
&config_path,
"[daemon]\nport = 7443\n\n# [boundary]\n# Model-boundary listener.\n# enabled = true\n# port = 7600\n",
)
.unwrap();
persist_boundary_enabled(&config_path, false).unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
let parsed: TomlConfig = toml::from_str(&raw).expect("still valid TOML");
assert_eq!(parsed.boundary.unwrap().enabled, Some(false));
assert!(raw.contains("# port = 7600"), "template preserved: {raw}");
}
#[test]
fn persist_boundary_enabled_replaces_an_active_value() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("config.toml");
std::fs::write(&config_path, "[boundary]\nenabled = true\nport = 7600\n").unwrap();
persist_boundary_enabled(&config_path, false).unwrap();
let raw = std::fs::read_to_string(&config_path).unwrap();
let parsed: TomlConfig = toml::from_str(&raw).unwrap();
let boundary = parsed.boundary.unwrap();
assert_eq!(boundary.enabled, Some(false));
assert_eq!(boundary.port, Some(7600), "unrelated keys survive");
}
#[test]
fn test_config_cli_port_overrides_default() {
let cfg = Config::load(Some(9000), None, false)
.expect("Config::load should succeed with valid CLI port");
assert_eq!(cfg.port, 9000, "CLI port should override default");
}
#[test]
fn test_generate_token_produces_64_char_hex() {
let token = generate_token();
assert_eq!(
token.len(),
64,
"Token must be 64 characters (32 bytes hex-encoded), got: {token}"
);
assert!(
token.chars().all(|c| c.is_ascii_hexdigit()),
"Token must be hex-encoded, got: {token}"
);
}
#[test]
fn test_ensure_token_creates_and_returns_token() {
let tmp = TempDir::new().unwrap();
let token1 = ensure_token(tmp.path()).expect("First ensure_token call should succeed");
assert_eq!(token1.len(), 64, "Generated token must be 64 chars");
assert!(
tmp.path().join("daemon.token").exists(),
"Token file must be created"
);
let token2 = ensure_token(tmp.path()).expect("Second ensure_token call should succeed");
assert_eq!(token1, token2, "Second call must return the same token");
}
#[cfg(unix)]
#[test]
fn test_ensure_token_file_has_mode_0600() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
ensure_token(tmp.path()).expect("ensure_token should succeed");
let token_path = tmp.path().join("daemon.token");
let metadata = std::fs::metadata(&token_path).unwrap();
let mode = metadata.permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "Token file must have mode 0600, got: {mode:o}");
}
#[test]
fn test_generate_default_config_toml_format() {
let content = generate_default_config_toml(7443);
assert!(
content.contains("port = 7443"),
"Must contain active port line: {content}"
);
assert!(
content.contains("[daemon]"),
"Must contain [daemon] section: {content}"
);
assert!(
content.contains("# level ="),
"level must be commented out: {content}"
);
assert!(
content.contains("# retention_days ="),
"retention_days must be commented: {content}"
);
}
#[test]
fn test_config_extra_patterns_defaults_empty() {
let cfg = Config::defaults();
assert!(
cfg.extra_patterns.is_empty(),
"Default extra_patterns must be empty"
);
}
#[test]
fn test_probe_free_port_finds_available_port() {
let port = probe_free_port(PORT_RANGE_START, PORT_RANGE_END)
.expect("should find at least one free port");
assert!((PORT_RANGE_START..=PORT_RANGE_END).contains(&port));
}
#[test]
fn test_probe_free_port_skips_occupied_port() {
let listener =
std::net::TcpListener::bind(("127.0.0.1", 0)).expect("should bind to random port");
let occupied = listener.local_addr().unwrap().port();
let result = probe_free_port(occupied, occupied);
assert!(
result.is_err(),
"must fail when only port in range is occupied"
);
if occupied < 65535 {
let result = probe_free_port(occupied, occupied + 1);
assert!(result.is_ok(), "should find next port after occupied one");
assert_eq!(result.unwrap(), occupied + 1);
}
}
#[test]
fn test_write_and_read_port_file_round_trip() {
let tmp = TempDir::new().unwrap();
let port_path = tmp.path().join("daemon.port");
std::fs::write(&port_path, "8080").unwrap();
let content = std::fs::read_to_string(&port_path).unwrap();
assert_eq!(content.trim().parse::<u16>().unwrap(), 8080);
}
#[test]
fn test_ensure_agent_id_creates_agt_prefixed_id() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "[daemon]\nport = 7443\n").unwrap();
let mid = ensure_agent_id(&config_path).expect("ensure_agent_id should succeed");
assert!(
mid.starts_with("agt_"),
"agent_id must start with 'agt_': {mid}"
);
let hex_part = &mid[4..]; assert_eq!(
hex_part.len(),
32,
"hex part must be 32 chars (UUID simple): {mid}"
);
assert!(
hex_part.chars().all(|c| c.is_ascii_hexdigit()),
"hex part must be hex digits: {mid}"
);
}
#[test]
fn test_ensure_agent_id_is_idempotent() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "[daemon]\nport = 7443\n").unwrap();
let mid1 = ensure_agent_id(&config_path).expect("first call should succeed");
let mid2 = ensure_agent_id(&config_path).expect("second call should succeed");
assert_eq!(mid1, mid2, "ensure_agent_id must be idempotent");
}
#[test]
fn test_ensure_agent_id_preserves_port_value() {
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "[daemon]\nport = 8888\n").unwrap();
ensure_agent_id(&config_path).expect("ensure_agent_id should succeed");
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(
raw.contains("port = 8888"),
"port must be preserved after ensure_agent_id: {raw}"
);
}
#[test]
fn test_config_reads_agent_id_from_daemon_section() {
let toml_str = r#"
[daemon]
port = 7443
agent_id = "agt_abcdef1234567890abcdef1234567890"
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
let daemon = toml_cfg.daemon.unwrap();
assert_eq!(
daemon.agent_id.as_deref(),
Some("agt_abcdef1234567890abcdef1234567890")
);
}
#[test]
fn test_cloud_config_default_values() {
let cfg = CloudConfig::default();
assert!(cfg.enabled, "cloud.enabled default must be true");
assert_eq!(
cfg.api_url, "https://app.openlatch.ai",
"cloud.api_url default must be https://app.openlatch.ai (callers append /api/v1/...)"
);
assert_eq!(
cfg.timeout_connect_ms, 5000,
"cloud.timeout_connect_ms default must be 5000"
);
assert_eq!(
cfg.timeout_total_ms, 10000,
"cloud.timeout_total_ms default must be 10000"
);
assert_eq!(
cfg.retry_delay_ms, 2000,
"cloud.retry_delay_ms default must be 2000"
);
assert_eq!(
cfg.channel_size, 1000,
"cloud.channel_size default must be 1000"
);
}
#[test]
fn test_config_defaults_includes_cloud_config() {
let cfg = Config::defaults();
assert!(cfg.cloud.enabled);
assert_eq!(cfg.cloud.api_url, "https://app.openlatch.ai");
assert_eq!(cfg.cloud.timeout_connect_ms, 5000);
assert_eq!(cfg.cloud.timeout_total_ms, 10000);
assert_eq!(cfg.cloud.retry_delay_ms, 2000);
assert_eq!(cfg.cloud.channel_size, 1000);
}
#[test]
fn test_config_load_no_cloud_section_returns_defaults() {
let toml_str = r#"
[daemon]
port = 7443
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
assert!(
toml_cfg.cloud.is_none(),
"TomlConfig.cloud must be None when [cloud] is absent"
);
}
#[test]
fn test_config_load_parses_all_cloud_fields() {
let toml_str = r#"
[cloud]
enabled = true
api_url = "https://custom.openlatch.ai"
timeout_connect_ms = 3000
timeout_total_ms = 8000
retry_delay_ms = 1000
channel_size = 500
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
let cloud = toml_cfg.cloud.unwrap();
assert_eq!(cloud.enabled, Some(true));
assert_eq!(
cloud.api_url.as_deref(),
Some("https://custom.openlatch.ai")
);
assert_eq!(cloud.timeout_connect_ms, Some(3000));
assert_eq!(cloud.timeout_total_ms, Some(8000));
assert_eq!(cloud.retry_delay_ms, Some(1000));
assert_eq!(cloud.channel_size, Some(500));
}
#[test]
fn test_config_load_partial_cloud_section_merges_with_defaults() {
let toml_str = r#"
[cloud]
enabled = true
api_url = "https://staging.openlatch.ai"
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
let cloud = toml_cfg.cloud.unwrap();
assert_eq!(cloud.enabled, Some(true));
assert_eq!(
cloud.api_url.as_deref(),
Some("https://staging.openlatch.ai")
);
assert!(cloud.timeout_connect_ms.is_none());
}
#[test]
fn test_policy_config_default_values() {
let cfg = PolicyConfig::default();
assert!(
cfg.enabled,
"policy.enabled MUST ship true — secure-by-default (opt out via config/env)"
);
assert_eq!(
cfg.poll_interval_secs, 300,
"policy.poll_interval_secs default must be 300"
);
assert_eq!(
cfg.stale_warn_after_secs, 86_400,
"policy.stale_warn_after_secs default must be 86400 (24 h)"
);
}
#[test]
fn test_config_defaults_includes_policy_config() {
let cfg = Config::defaults();
assert!(cfg.policy.enabled);
assert_eq!(cfg.policy.poll_interval_secs, 300);
assert_eq!(cfg.policy.stale_warn_after_secs, 86_400);
}
#[test]
fn test_boundary_config_default_is_enabled() {
assert!(
BoundaryConfig::default().enabled,
"boundary.enabled MUST ship true — secure-by-default"
);
assert!(
Config::defaults().boundary.enabled,
"Config::defaults() must carry the enabled boundary default"
);
}
#[test]
fn test_config_load_no_policy_section_returns_defaults() {
let toml_str = r#"
[daemon]
port = 7443
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
assert!(
toml_cfg.policy.is_none(),
"TomlConfig.policy must be None when [policy] is absent"
);
}
#[test]
fn test_config_load_parses_all_policy_fields() {
let toml_str = r#"
[policy]
enabled = true
poll_interval_secs = 60
stale_warn_after_secs = 3600
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
let policy = toml_cfg.policy.unwrap();
assert_eq!(policy.enabled, Some(true));
assert_eq!(policy.poll_interval_secs, Some(60));
assert_eq!(policy.stale_warn_after_secs, Some(3600));
}
#[test]
fn test_config_load_partial_policy_section_merges_with_defaults() {
let toml_str = r#"
[policy]
enabled = true
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
let policy = toml_cfg.policy.unwrap();
assert_eq!(policy.enabled, Some(true));
assert!(policy.poll_interval_secs.is_none());
assert!(policy.stale_warn_after_secs.is_none());
}
#[test]
fn test_generate_default_config_toml_contains_policy_section() {
let content = generate_default_config_toml(7443);
assert!(
content.contains("# [policy]"),
"Must contain commented [policy] header: {content}"
);
assert!(
content.contains("# enabled = true"),
"Must document that policy ships enabled (secure-by-default): {content}"
);
assert!(
content.contains("# poll_interval_secs = 300"),
"Must contain commented poll_interval_secs line: {content}"
);
assert!(
content.contains("# stale_warn_after_secs = 86400"),
"Must contain commented stale_warn_after_secs line: {content}"
);
}
#[test]
fn test_generate_default_config_toml_contains_boundary_section() {
let content = generate_default_config_toml(7443);
assert!(
content.contains("# [boundary]"),
"Must contain commented [boundary] header: {content}"
);
assert!(
content.contains("# enabled = true"),
"Must document that the boundary ships enabled (secure-by-default): {content}"
);
}
#[test]
fn test_supervision_toml_round_trip() {
let toml_str = r#"
[supervision]
mode = "active"
backend = "launchd"
disabled_reason = "user_opt_out"
"#;
let toml_cfg: TomlConfig = toml::from_str(toml_str).unwrap();
let sup = toml_cfg.supervision.unwrap();
assert_eq!(sup.mode.as_deref(), Some("active"));
assert_eq!(sup.backend.as_deref(), Some("launchd"));
assert_eq!(sup.disabled_reason.as_deref(), Some("user_opt_out"));
}
#[test]
fn test_set_cloud_api_url_activates_commented_template_block() {
let raw = generate_default_config_toml(7443);
let out = set_cloud_api_url(&raw, "http://127.0.0.1:5183");
let parsed: TomlConfig = toml::from_str(&out).expect("template stays valid TOML");
assert_eq!(
parsed.cloud.and_then(|c| c.api_url).as_deref(),
Some("http://127.0.0.1:5183")
);
assert!(
!out.contains("# api_url = \"https://app.openlatch.ai\""),
"the commented production URL must be replaced, not left to confuse"
);
assert!(out.contains("# batch_max_events = 50"));
assert!(out.contains("# [policy]"));
assert!(out.contains("port = 7443"));
}
#[test]
fn test_set_cloud_api_url_replaces_active_value_and_keeps_other_sections() {
let raw = "[daemon]\nport = 7443\n\n[cloud]\nenabled = true\napi_url = \"https://app.openlatch.ai\"\n\n[privacy]\nextra_patterns = [\"KEEP_ME\"]\n";
let out = set_cloud_api_url(raw, "http://localhost:5173");
let parsed: TomlConfig = toml::from_str(&out).unwrap();
let cloud = parsed.cloud.unwrap();
assert_eq!(cloud.api_url.as_deref(), Some("http://localhost:5173"));
assert_eq!(cloud.enabled, Some(true), "sibling keys survive");
assert_eq!(
parsed.privacy.unwrap().extra_patterns.unwrap(),
vec!["KEEP_ME".to_string()],
"hand-written sections are never collateral"
);
assert!(!out.contains("app.openlatch.ai"));
}
#[test]
fn test_set_cloud_api_url_appends_when_section_absent() {
let raw = "[daemon]\nport = 7443\n";
let out = set_cloud_api_url(raw, "http://127.0.0.1:5183");
let parsed: TomlConfig = toml::from_str(&out).unwrap();
assert_eq!(
parsed.cloud.and_then(|c| c.api_url).as_deref(),
Some("http://127.0.0.1:5183")
);
assert_eq!(parsed.daemon.unwrap().port, Some(7443));
}
#[test]
fn test_set_cloud_api_url_is_idempotent() {
let once = set_cloud_api_url(&generate_default_config_toml(7443), "http://a.test");
let twice = set_cloud_api_url(&once, "http://b.test");
assert_eq!(twice.matches("api_url").count(), 1);
let parsed: TomlConfig = toml::from_str(&twice).unwrap();
assert_eq!(
parsed.cloud.and_then(|c| c.api_url).as_deref(),
Some("http://b.test")
);
}
#[test]
fn test_template_comments_match_compiled_defaults() {
const ASSERTED_SECTIONS: [&str; 4] = ["logging", "update", "cloud", "policy"];
let template = generate_default_config_toml(7443);
let mut uncommented = String::new();
let mut in_asserted_section = false;
for line in template.lines() {
let trimmed = line.trim();
let bare = trimmed.trim_start_matches('#').trim();
if bare.starts_with('[') && bare.ends_with(']') {
let name = bare.trim_matches(|c| c == '[' || c == ']');
in_asserted_section = ASSERTED_SECTIONS.contains(&name);
if in_asserted_section {
uncommented.push_str(bare);
uncommented.push('\n');
}
continue;
}
let is_key_line = bare.split_once(" = ").is_some_and(|(key, _)| {
!key.is_empty() && key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
});
if in_asserted_section && is_key_line {
uncommented.push_str(bare);
uncommented.push('\n');
}
}
let parsed: TomlConfig = toml::from_str(&uncommented).unwrap_or_else(|e| {
panic!("uncommented template must be valid TOML: {e}\n{uncommented}")
});
let defaults = Config::defaults();
let logging = parsed.logging.expect("[logging] present in template");
assert_eq!(logging.level.as_deref(), Some(defaults.log_level.as_str()));
assert_eq!(logging.retention_days, Some(defaults.retention_days));
let update = parsed.update.expect("[update] present in template");
assert_eq!(update.check, Some(defaults.update.check));
let cloud = parsed.cloud.expect("[cloud] present in template");
assert_eq!(cloud.enabled, Some(defaults.cloud.enabled));
assert_eq!(
cloud.api_url.as_deref(),
Some(defaults.cloud.api_url.as_str())
);
assert_eq!(
cloud.timeout_connect_ms,
Some(defaults.cloud.timeout_connect_ms)
);
assert_eq!(
cloud.timeout_total_ms,
Some(defaults.cloud.timeout_total_ms)
);
assert_eq!(cloud.retry_delay_ms, Some(defaults.cloud.retry_delay_ms));
assert_eq!(cloud.channel_size, Some(defaults.cloud.channel_size));
assert_eq!(
cloud.batch_max_events,
Some(defaults.cloud.batch_max_events)
);
assert_eq!(
cloud.batch_max_wait_ms,
Some(defaults.cloud.batch_max_wait_ms)
);
assert_eq!(
cloud.outbox_max_bytes,
Some(defaults.cloud.outbox_max_bytes)
);
assert_eq!(
cloud.fallback_max_bytes,
Some(defaults.cloud.fallback_max_bytes)
);
let policy = parsed.policy.expect("[policy] present in template");
assert_eq!(policy.enabled, Some(defaults.policy.enabled));
assert_eq!(
policy.poll_interval_secs,
Some(defaults.policy.poll_interval_secs)
);
assert_eq!(
policy.stale_warn_after_secs,
Some(defaults.policy.stale_warn_after_secs)
);
}
#[test]
fn test_rewrite_supervision_section_appends_when_absent() {
let raw = "[daemon]\nport = 7443\n";
let out = rewrite_supervision_section(raw, "active", "launchd", None);
assert!(out.contains("[supervision]"));
assert!(out.contains("mode = \"active\""));
assert!(out.contains("backend = \"launchd\""));
assert!(!out.contains("disabled_reason"));
assert!(out.contains("[daemon]"));
assert!(out.contains("port = 7443"));
}
#[test]
fn test_rewrite_supervision_section_replaces_existing() {
let raw = "[daemon]\nport = 7443\n\n[supervision]\nmode = \"disabled\"\nbackend = \"none\"\ndisabled_reason = \"user_opt_out\"\n\n[cloud]\nenabled = false\n";
let out = rewrite_supervision_section(raw, "active", "task_scheduler", None);
assert!(out.contains("mode = \"active\""));
assert!(out.contains("backend = \"task_scheduler\""));
assert!(!out.contains("user_opt_out"));
assert!(out.contains("[cloud]"));
assert!(out.contains("enabled = false"));
}
#[test]
fn test_rewrite_supervision_section_replaces_commented_header() {
let raw = "[daemon]\nport = 7443\n\n# [supervision]\n# mode = \"disabled\"\n";
let out = rewrite_supervision_section(raw, "active", "launchd", Some("ok"));
assert!(out.contains("[supervision]"));
assert!(out.contains("mode = \"active\""));
assert_eq!(out.matches("[supervision]").count(), 1);
}
#[test]
fn test_persist_supervision_state_writes_to_disk() {
use crate::supervision::{SupervisionMode, SupervisorKind};
let tmp = TempDir::new().unwrap();
let config_path = tmp.path().join("config.toml");
std::fs::write(&config_path, "[daemon]\nport = 7443\n").unwrap();
persist_supervision_state(
&config_path,
&SupervisionMode::Active,
&SupervisorKind::Launchd,
None,
)
.expect("persist should succeed");
let raw = std::fs::read_to_string(&config_path).unwrap();
assert!(raw.contains("[supervision]"));
assert!(raw.contains("mode = \"active\""));
assert!(raw.contains("backend = \"launchd\""));
}
#[test]
fn test_generate_default_config_toml_contains_supervision_template() {
let content = generate_default_config_toml(7443);
assert!(
content.contains("# [supervision]"),
"Must contain commented [supervision] header: {content}"
);
assert!(
content.contains("# mode = \"disabled\""),
"Must contain commented mode line: {content}"
);
}
#[test]
fn test_generate_default_config_toml_contains_cloud_section() {
let content = generate_default_config_toml(7443);
assert!(
content.contains("# [cloud]"),
"Must contain commented [cloud] header: {content}"
);
assert!(
content.contains("# enabled = true"),
"Must contain commented enabled line: {content}"
);
assert!(
content.contains("# api_url = \"https://app.openlatch.ai\""),
"Must contain commented api_url line: {content}"
);
assert!(
content.contains("# timeout_connect_ms = 5000"),
"Must contain commented timeout_connect_ms line: {content}"
);
assert!(
content.contains("# timeout_total_ms = 10000"),
"Must contain commented timeout_total_ms line: {content}"
);
assert!(
content.contains("# retry_delay_ms = 2000"),
"Must contain commented retry_delay_ms line: {content}"
);
assert!(
content.contains("# channel_size = 1000"),
"Must contain commented channel_size line: {content}"
);
assert!(
content.contains("# batch_max_events = 50"),
"Must contain commented batch_max_events line: {content}"
);
assert!(
content.contains("# batch_max_wait_ms = 5000"),
"Must contain commented batch_max_wait_ms line: {content}"
);
}
static BATCH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
fn with_batch_env<T>(
max_events: Option<&str>,
max_wait_ms: Option<&str>,
check: impl FnOnce(Config) -> T,
) -> T {
let _guard = BATCH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
match max_events {
Some(v) => std::env::set_var("OPENLATCH_CLOUD_BATCH_MAX_EVENTS", v),
None => std::env::remove_var("OPENLATCH_CLOUD_BATCH_MAX_EVENTS"),
}
match max_wait_ms {
Some(v) => std::env::set_var("OPENLATCH_CLOUD_BATCH_MAX_WAIT_MS", v),
None => std::env::remove_var("OPENLATCH_CLOUD_BATCH_MAX_WAIT_MS"),
}
let cfg = Config::load(None, None, false).expect("Config::load should succeed");
std::env::remove_var("OPENLATCH_CLOUD_BATCH_MAX_EVENTS");
std::env::remove_var("OPENLATCH_CLOUD_BATCH_MAX_WAIT_MS");
check(cfg)
}
#[test]
fn test_cloud_batch_defaults() {
let cfg = Config::defaults();
assert_eq!(
cfg.cloud.batch_max_events, 50,
"Default batch_max_events must be 50"
);
assert_eq!(
cfg.cloud.batch_max_wait_ms, 5000,
"Default batch_max_wait_ms must be 5000"
);
}
#[test]
fn test_cloud_batch_env_overrides() {
with_batch_env(Some("25"), Some("750"), |cfg| {
assert_eq!(
cfg.cloud.batch_max_events, 25,
"OPENLATCH_CLOUD_BATCH_MAX_EVENTS must override the default"
);
assert_eq!(
cfg.cloud.batch_max_wait_ms, 750,
"OPENLATCH_CLOUD_BATCH_MAX_WAIT_MS must override the default"
);
});
}
#[test]
fn test_cloud_batch_max_events_clamped_at_load() {
with_batch_env(Some("0"), None, |cfg| {
assert_eq!(
cfg.cloud.batch_max_events, 1,
"batch_max_events = 0 must clamp up to 1"
);
});
with_batch_env(Some("101"), None, |cfg| {
assert_eq!(
cfg.cloud.batch_max_events, 100,
"batch_max_events = 101 must clamp down to 100"
);
});
}
#[test]
fn test_cloud_batch_max_events_rejects_non_integer() {
let _guard = BATCH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var("OPENLATCH_CLOUD_BATCH_MAX_EVENTS", "fifty");
let err = Config::load(None, None, false).expect_err("non-integer must be rejected");
std::env::remove_var("OPENLATCH_CLOUD_BATCH_MAX_EVENTS");
assert_eq!(err.code, ERR_INVALID_CONFIG);
}
#[test]
fn test_unknown_config_key_is_flagged() {
let raw = "[policy]\nenable = true\npoll_interval_secs = 60\n";
assert_eq!(collect_unknown_config_keys(raw), vec!["policy.enable"]);
}
#[test]
fn test_unknown_top_level_section_is_flagged() {
let raw = "[nope]\nfoo = 1\n";
assert_eq!(collect_unknown_config_keys(raw), vec!["nope"]);
}
#[test]
fn test_all_known_keys_produce_no_warnings() {
let raw = "\
[daemon]
port = 7443
agent_id = \"a\"
[logging]
level = \"info\"
dir = \"/tmp\"
retention_days = 30
[privacy]
extra_patterns = []
[update]
check = true
registry_origin = \"o\"
download_timeout_secs = 1
auto_update = false
check_interval_secs = 1
quiet_window_secs = 1
max_defer_secs = 1
[cloud]
enabled = true
api_url = \"u\"
timeout_connect_ms = 1
timeout_total_ms = 1
retry_delay_ms = 1
channel_size = 1
credential_poll_interval_ms = 1
outbox_enabled = true
outbox_max_bytes = 1
fallback_max_bytes = 1
batch_max_events = 1
batch_max_wait_ms = 1
[supervision]
mode = \"m\"
backend = \"b\"
disabled_reason = \"r\"
[inventory_monitor]
enabled = true
periodic_rescan_interval_hours = 1
watcher_debounce_ms = 1
max_inline_content_bytes = 1
content_forward = \"metadata_only\"
project_scope_auto_detect = true
cache_max_entries = 1
[policy]
enabled = true
poll_interval_secs = 1
stale_warn_after_secs = 1
[boundary]
enabled = true
";
assert!(
collect_unknown_config_keys(raw).is_empty(),
"known keys must not be flagged: {:?}",
collect_unknown_config_keys(raw)
);
}
#[test]
fn test_invalid_toml_returns_no_keys() {
assert!(collect_unknown_config_keys("this is not = = toml").is_empty());
}
}