use std::{
collections::BTreeMap,
env, fs,
io::Read,
path::{Path, PathBuf},
};
use objects::fs_atomic::write_file_atomic_secret;
use proto::AuthToken;
use repo::{FsMonitorMode, FsMonitorSettings, OutputFormat, WorktreeStatusOptions};
use serde::{Deserialize, Serialize};
use crate::client_config::ClientConfig;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserConfig {
#[serde(default)]
pub principal: Option<UserPrincipalConfig>,
#[serde(default)]
pub agent: UserAgentConfig,
#[serde(default)]
pub output: UserOutputConfig,
#[serde(default)]
pub display: UserDisplayConfig,
#[serde(default)]
pub worktree: UserWorktreeConfig,
#[serde(default)]
pub logging: UserLoggingConfig,
#[serde(default)]
pub remote: UserRemoteConfig,
#[serde(default)]
pub harness: UserHarnessConfig,
#[serde(default)]
pub land: UserLandConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserPrincipalConfig {
pub name: String,
pub email: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserAgentConfig {
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub default_policy: Option<String>,
#[serde(default = "default_confidence")]
pub confidence: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserOutputConfig {
#[serde(default)]
pub format: OutputFormat,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserDisplayConfig {
#[serde(default = "default_hash_length")]
pub hash_length: usize,
#[serde(default = "default_change_id_format")]
pub change_id_format: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserWorktreeConfig {
#[serde(default)]
pub fsmonitor: UserFsMonitorConfig,
#[serde(default)]
pub thread_workspace: UserThreadWorkspaceConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserFsMonitorConfig {
#[serde(default)]
pub mode: Option<FsMonitorMode>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum UserThreadWorkspaceMode {
#[default]
Auto,
Materialized,
Virtualized,
Solid,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserThreadWorkspaceConfig {
#[serde(default)]
pub top_level_default: UserThreadWorkspaceMode,
#[serde(default)]
pub delegated_default: Option<UserThreadWorkspaceMode>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserLoggingConfig {
#[serde(default)]
pub format: Option<String>,
#[serde(default)]
pub include_location: bool,
#[serde(default)]
pub include_thread_ids: bool,
#[serde(default)]
pub log_spans: bool,
#[serde(default)]
pub otel_service_name: Option<String>,
#[serde(default)]
pub otel_endpoint: Option<String>,
#[serde(default)]
pub otel_traces_endpoint: Option<String>,
#[serde(default)]
pub otel_metrics_endpoint: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserRemoteConfig {
#[serde(default)]
pub token: Option<String>,
#[serde(default)]
pub tls_enabled: bool,
#[serde(default)]
pub tls_domain_name: Option<String>,
#[serde(default)]
pub tls_ca_certificate_path: Option<PathBuf>,
#[serde(default)]
pub auth_proof_key_pem_path: Option<PathBuf>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserLandConfig {
#[serde(default = "default_land_squash")]
pub squash: bool,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum HarnessMode {
#[default]
Auto,
Off,
Required,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum HarnessTransport {
#[default]
Spool,
Direct,
End,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum HarnessTranscriptMode {
#[default]
Off,
Summary,
Full,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserHarnessOverride {
#[serde(default)]
pub provider: Option<String>,
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub thinking_level: Option<String>,
#[serde(default)]
pub policy: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserHarnessConfig {
#[serde(default)]
pub mode: HarnessMode,
#[serde(default)]
pub transport: HarnessTransport,
#[serde(default)]
pub transcript: HarnessTranscriptMode,
#[serde(default = "default_auto_infer")]
pub auto_infer: bool,
#[serde(default)]
pub threading: UserHarnessThreadingConfig,
#[serde(default)]
pub harnesses: BTreeMap<String, UserHarnessOverride>,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum UserHarnessRootThreadPolicy {
CreateNew,
#[default]
AttachCurrent,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "kebab-case")]
pub enum UserHarnessSubagentThreadPolicy {
AttachCurrent,
#[default]
CreateChild,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct UserHarnessThreadingConfig {
#[serde(default)]
pub root_actor: UserHarnessRootThreadPolicy,
#[serde(default)]
pub subagent: UserHarnessSubagentThreadPolicy,
#[serde(default)]
pub workspace_default: Option<UserThreadWorkspaceMode>,
}
fn default_confidence() -> f32 {
0.8
}
fn default_hash_length() -> usize {
8
}
fn default_change_id_format() -> String {
"short".to_string()
}
fn default_auto_infer() -> bool {
true
}
fn default_land_squash() -> bool {
true
}
impl Default for UserDisplayConfig {
fn default() -> Self {
Self {
hash_length: default_hash_length(),
change_id_format: default_change_id_format(),
}
}
}
impl Default for UserHarnessConfig {
fn default() -> Self {
Self {
mode: HarnessMode::Auto,
transport: HarnessTransport::Spool,
transcript: HarnessTranscriptMode::Off,
auto_infer: default_auto_infer(),
threading: UserHarnessThreadingConfig::default(),
harnesses: BTreeMap::new(),
}
}
}
impl Default for UserLandConfig {
fn default() -> Self {
Self {
squash: default_land_squash(),
}
}
}
impl UserConfig {
pub fn default_path() -> Option<PathBuf> {
if let Ok(path) = std::env::var("HEDDLE_CONFIG")
&& !path.is_empty()
{
return Some(PathBuf::from(path));
}
if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
&& !xdg.is_empty()
{
return Some(PathBuf::from(xdg).join("heddle").join("config.toml"));
}
if let Ok(home) = std::env::var("HOME")
&& !home.is_empty()
{
return Some(PathBuf::from(home).join(".config/heddle/config.toml"));
}
None
}
pub fn load(path: &Path) -> anyhow::Result<Self> {
let mut file = fs::File::open(path)?;
let mut contents = String::new();
file.read_to_string(&mut contents)?;
toml::from_str::<Self>(&contents).map_err(|err| {
let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
objects::error::HeddleError::ConfigParse {
path: resolved,
source: err,
}
.into()
})
}
pub fn load_default() -> anyhow::Result<Self> {
match Self::default_path() {
Some(path) => match Self::load(&path) {
Ok(config) => Ok(config),
Err(err) if path_missing(&err) => Ok(Self::default()),
Err(err) => Err(err),
},
None => Ok(Self::default()),
}
}
pub fn save_default(&self) -> anyhow::Result<PathBuf> {
let path = Self::default_path()
.ok_or_else(|| anyhow::anyhow!("unable to determine user config path"))?;
self.save(&path)?;
Ok(path)
}
pub fn save(&self, path: &Path) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let contents = toml::to_string_pretty(self)?;
write_file_atomic_secret(path, contents.as_bytes())?;
Ok(())
}
pub fn set_principal(&mut self, name: impl Into<String>, email: impl Into<String>) {
self.principal = Some(UserPrincipalConfig {
name: name.into(),
email: email.into(),
});
}
pub fn remote_token(&self) -> anyhow::Result<Option<AuthToken>> {
match env::var("HEDDLE_REMOTE_TOKEN") {
Ok(token) if !token.is_empty() => Ok(Some(AuthToken::new(token, "env"))),
Ok(_) | Err(env::VarError::NotPresent) => Ok(self
.remote
.token
.clone()
.map(|token| AuthToken::new(token, "user-config"))),
Err(err @ env::VarError::NotUnicode(_)) => Err(security_config_error(
"HEDDLE_REMOTE_TOKEN",
format!("read environment value: {err}"),
)),
}
}
pub fn heddle_client_config(
&self,
token_override: Option<AuthToken>,
) -> anyhow::Result<ClientConfig> {
let token = match token_override {
Some(token) => Some(token),
None => self.remote_token()?,
};
let mut config = token
.map(|token| ClientConfig::default().with_token(token))
.unwrap_or_default();
if self.remote.tls_enabled {
config = config.with_tls(false);
}
if let Some(domain) = &self.remote.tls_domain_name {
config = config.with_tls_domain_name(domain.clone());
}
if let Some(path) = &self.remote.tls_ca_certificate_path {
let pem = read_security_config_file("remote.tls_ca_certificate_path", path)?;
config = config.with_tls_ca_certificate_pem(pem);
}
if let Some(path) = &self.remote.auth_proof_key_pem_path {
let pem = read_security_config_file("remote.auth_proof_key_pem_path", path)?;
config = config.with_auth_proof_key_pem(pem);
}
if env_bool("HEDDLE_REMOTE_TLS")? {
config = config.with_tls(false);
}
match env::var("HEDDLE_REMOTE_TLS_DOMAIN") {
Ok(domain) => config = config.with_tls_domain_name(domain),
Err(env::VarError::NotPresent) => {}
Err(err @ env::VarError::NotUnicode(_)) => {
return Err(security_config_error(
"HEDDLE_REMOTE_TLS_DOMAIN",
format!("read environment value: {err}"),
));
}
}
match env::var("HEDDLE_REMOTE_TLS_CA_CERT") {
Ok(path) => {
let pem =
read_security_config_file("HEDDLE_REMOTE_TLS_CA_CERT", &PathBuf::from(path))?;
config = config.with_tls_ca_certificate_pem(pem);
}
Err(env::VarError::NotPresent) => {}
Err(err @ env::VarError::NotUnicode(_)) => {
return Err(security_config_error(
"HEDDLE_REMOTE_TLS_CA_CERT",
format!("read environment value: {err}"),
));
}
}
Ok(config)
}
pub fn worktree_status_options(
&self,
repo_config: Option<&repo::RepoConfig>,
) -> WorktreeStatusOptions {
let mut mode = self
.worktree
.fsmonitor
.mode
.or_else(|| repo_config.map(|config| config.worktree.fsmonitor.mode))
.unwrap_or(FsMonitorMode::Off);
if let Ok(value) = std::env::var("HEDDLE_FSMONITOR")
&& let Some(parsed) = FsMonitorMode::parse(&value)
{
mode = parsed;
}
WorktreeStatusOptions {
fsmonitor: FsMonitorSettings { mode },
}
}
}
fn read_security_config_file(setting: &str, path: &Path) -> anyhow::Result<String> {
fs::read_to_string(path).map_err(|err| {
security_config_error(
setting,
format!("read configured file {}: {err}", path.display()),
)
})
}
fn env_bool(name: &str) -> anyhow::Result<bool> {
let value = match env::var(name) {
Ok(value) => value,
Err(env::VarError::NotPresent) => return Ok(false),
Err(err @ env::VarError::NotUnicode(_)) => {
return Err(security_config_error(
name,
format!("read environment value: {err}"),
));
}
};
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Ok(true),
"0" | "false" | "no" | "off" => Ok(false),
_ => Err(security_config_error(
name,
format!(
"parse boolean value {value:?}; expected one of 1/0, true/false, yes/no, or on/off"
),
)),
}
}
fn security_config_error(setting: &str, reason: String) -> anyhow::Error {
anyhow::anyhow!(
"fatal TLS/auth configuration error for `{setting}`: {reason}; refusing to proceed with an ambiguous security posture"
)
}
fn path_missing(err: &anyhow::Error) -> bool {
err.downcast_ref::<std::io::Error>()
.is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
}
#[cfg(test)]
mod tests {
use std::{
ffi::OsString,
fs,
path::PathBuf,
sync::MutexGuard,
time::{SystemTime, UNIX_EPOCH},
};
use repo::{FsMonitorMode, RepoConfig};
use super::{
HarnessMode, HarnessTranscriptMode, HarnessTransport, UserConfig, UserRemoteConfig,
};
static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
const REMOTE_ENV_KEYS: &[&str] = &[
"HEDDLE_REMOTE_TOKEN",
"HEDDLE_REMOTE_TLS",
"HEDDLE_REMOTE_TLS_DOMAIN",
"HEDDLE_REMOTE_TLS_CA_CERT",
];
struct RemoteEnvGuard {
_guard: MutexGuard<'static, ()>,
saved: Vec<(&'static str, Option<OsString>)>,
}
impl RemoteEnvGuard {
fn clean() -> Self {
let guard = TEST_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let saved = REMOTE_ENV_KEYS
.iter()
.map(|key| (*key, std::env::var_os(key)))
.collect();
for key in REMOTE_ENV_KEYS {
unsafe { std::env::remove_var(key) };
}
Self {
_guard: guard,
saved,
}
}
fn set(&self, key: &str, value: impl AsRef<std::ffi::OsStr>) {
unsafe { std::env::set_var(key, value) };
}
}
impl Drop for RemoteEnvGuard {
fn drop(&mut self) {
for (key, value) in &self.saved {
unsafe {
if let Some(value) = value {
std::env::set_var(key, value);
} else {
std::env::remove_var(key);
}
}
}
}
}
fn unique_temp_path(prefix: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system time before unix epoch")
.as_nanos();
std::env::temp_dir().join(format!("{prefix}-{}-{unique}", std::process::id()))
}
#[test]
fn user_worktree_status_options_fall_back_to_repo_config() {
let mut repo = RepoConfig::default();
repo.worktree.fsmonitor.mode = FsMonitorMode::Watchman;
let config = UserConfig::default();
let options = config.worktree_status_options(Some(&repo));
assert_eq!(options.fsmonitor.mode, FsMonitorMode::Watchman);
}
#[test]
fn harness_config_defaults_are_magical_but_safe() {
let config = UserConfig::default();
assert_eq!(config.harness.mode, HarnessMode::Auto);
assert_eq!(config.harness.transport, HarnessTransport::Spool);
assert_eq!(config.harness.transcript, HarnessTranscriptMode::Off);
assert!(config.harness.auto_infer);
assert!(config.harness.harnesses.is_empty());
}
#[test]
fn heddle_client_config_absent_security_settings_uses_defaults() {
let _env = RemoteEnvGuard::clean();
let config = UserConfig::default()
.heddle_client_config(None)
.expect("absent optional settings should not error");
assert!(!config.tls_enabled);
assert!(!config.tls_skip_verify);
assert!(config.tls_ca_certificate_pem.is_none());
assert!(config.auth_proof_key_pem.is_none());
assert!(config.token.is_none());
}
#[test]
fn heddle_client_config_valid_security_files_are_applied() {
let _env = RemoteEnvGuard::clean();
let dir = unique_temp_path("heddle-user-config-valid-security");
fs::create_dir_all(&dir).expect("create temp dir");
let ca_path = dir.join("ca.pem");
let key_path = dir.join("proof-key.pem");
fs::write(&ca_path, "test ca pem").expect("write ca pem");
fs::write(&key_path, "test key pem").expect("write key pem");
let user = UserConfig {
remote: UserRemoteConfig {
tls_ca_certificate_path: Some(ca_path),
auth_proof_key_pem_path: Some(key_path),
..UserRemoteConfig::default()
},
..UserConfig::default()
};
let config = user
.heddle_client_config(None)
.expect("valid TLS/auth files should load");
assert!(config.tls_enabled);
assert_eq!(
config.tls_ca_certificate_pem.as_deref(),
Some("test ca pem")
);
assert_eq!(config.auth_proof_key_pem.as_deref(), Some("test key pem"));
fs::remove_dir_all(dir).expect("remove temp dir");
}
#[test]
fn heddle_client_config_missing_tls_ca_path_fails_closed() {
let _env = RemoteEnvGuard::clean();
let missing = unique_temp_path("heddle-user-config-missing-ca").join("ca.pem");
let user = UserConfig {
remote: UserRemoteConfig {
tls_ca_certificate_path: Some(missing),
..UserRemoteConfig::default()
},
..UserConfig::default()
};
let err = user
.heddle_client_config(None)
.expect_err("missing configured CA path must fail closed");
let message = err.to_string();
assert!(message.contains("fatal TLS/auth configuration error"));
assert!(message.contains("remote.tls_ca_certificate_path"));
}
#[test]
fn heddle_client_config_missing_auth_proof_key_path_fails_closed() {
let _env = RemoteEnvGuard::clean();
let missing = unique_temp_path("heddle-user-config-missing-key").join("proof-key.pem");
let user = UserConfig {
remote: UserRemoteConfig {
auth_proof_key_pem_path: Some(missing),
..UserRemoteConfig::default()
},
..UserConfig::default()
};
let err = user
.heddle_client_config(None)
.expect_err("missing configured proof key path must fail closed");
let message = err.to_string();
assert!(message.contains("fatal TLS/auth configuration error"));
assert!(message.contains("remote.auth_proof_key_pem_path"));
}
#[test]
fn heddle_client_config_missing_env_tls_ca_path_fails_closed() {
let env = RemoteEnvGuard::clean();
let missing = unique_temp_path("heddle-user-config-missing-env-ca").join("ca.pem");
env.set("HEDDLE_REMOTE_TLS_CA_CERT", missing);
let err = UserConfig::default()
.heddle_client_config(None)
.expect_err("missing env CA path must fail closed");
let message = err.to_string();
assert!(message.contains("fatal TLS/auth configuration error"));
assert!(message.contains("HEDDLE_REMOTE_TLS_CA_CERT"));
}
#[test]
fn heddle_client_config_invalid_env_tls_value_fails_closed() {
let env = RemoteEnvGuard::clean();
env.set("HEDDLE_REMOTE_TLS", "enabled");
let err = UserConfig::default()
.heddle_client_config(None)
.expect_err("invalid TLS env value must fail closed");
let message = err.to_string();
assert!(message.contains("fatal TLS/auth configuration error"));
assert!(message.contains("HEDDLE_REMOTE_TLS"));
}
}