1use std::{
3 collections::BTreeMap,
4 env, fs,
5 io::Read,
6 path::{Path, PathBuf},
7};
8
9use objects::fs_atomic::write_file_atomic_secret;
10use wire::AuthToken;
11use repo::{FsMonitorMode, FsMonitorSettings, OutputFormat, WorktreeStatusOptions};
12use serde::{Deserialize, Serialize};
13
14use crate::client_config::ClientConfig;
15
16#[derive(Debug, Clone, Serialize, Deserialize, Default)]
17pub struct UserConfig {
18 #[serde(default)]
19 pub principal: Option<UserPrincipalConfig>,
20 #[serde(default)]
21 pub agent: UserAgentConfig,
22 #[serde(default)]
23 pub output: UserOutputConfig,
24 #[serde(default)]
25 pub display: UserDisplayConfig,
26 #[serde(default)]
27 pub worktree: UserWorktreeConfig,
28 #[serde(default)]
29 pub logging: UserLoggingConfig,
30 #[serde(default)]
31 pub remote: UserRemoteConfig,
32 #[serde(default)]
33 pub harness: UserHarnessConfig,
34 #[serde(default)]
35 pub land: UserLandConfig,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct UserPrincipalConfig {
40 pub name: String,
41 pub email: String,
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct UserAgentConfig {
46 #[serde(default)]
47 pub provider: Option<String>,
48 #[serde(default)]
49 pub model: Option<String>,
50 #[serde(default)]
51 pub default_policy: Option<String>,
52 #[serde(default = "default_confidence")]
53 pub confidence: f32,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize, Default)]
57pub struct UserOutputConfig {
58 #[serde(default)]
59 pub format: OutputFormat,
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct UserDisplayConfig {
64 #[serde(default = "default_hash_length")]
65 pub hash_length: usize,
66 #[serde(default = "default_change_id_format")]
67 pub change_id_format: String,
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
71pub struct UserWorktreeConfig {
72 #[serde(default)]
73 pub fsmonitor: UserFsMonitorConfig,
74 #[serde(default)]
75 pub thread_workspace: UserThreadWorkspaceConfig,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, Default)]
79pub struct UserFsMonitorConfig {
80 #[serde(default)]
81 pub mode: Option<FsMonitorMode>,
82}
83
84#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
89#[serde(rename_all = "kebab-case")]
90pub enum UserThreadWorkspaceMode {
91 #[default]
92 Auto,
93 Materialized,
94 Virtualized,
95 Solid,
96}
97
98#[derive(Debug, Clone, Serialize, Deserialize, Default)]
99pub struct UserThreadWorkspaceConfig {
100 #[serde(default)]
101 pub top_level_default: UserThreadWorkspaceMode,
102 #[serde(default)]
103 pub delegated_default: Option<UserThreadWorkspaceMode>,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize, Default)]
107pub struct UserLoggingConfig {
108 #[serde(default)]
109 pub format: Option<String>,
110 #[serde(default)]
111 pub include_location: bool,
112 #[serde(default)]
113 pub include_thread_ids: bool,
114 #[serde(default)]
115 pub log_spans: bool,
116 #[serde(default)]
117 pub otel_service_name: Option<String>,
118 #[serde(default)]
119 pub otel_endpoint: Option<String>,
120 #[serde(default)]
121 pub otel_traces_endpoint: Option<String>,
122 #[serde(default)]
123 pub otel_metrics_endpoint: Option<String>,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize, Default)]
127pub struct UserRemoteConfig {
128 #[serde(default)]
129 pub token: Option<String>,
130 #[serde(default)]
131 pub tls_enabled: bool,
132 #[serde(default)]
133 pub tls_domain_name: Option<String>,
134 #[serde(default)]
135 pub tls_ca_certificate_path: Option<PathBuf>,
136 #[serde(default)]
137 pub auth_proof_key_pem_path: Option<PathBuf>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct UserLandConfig {
142 #[serde(default = "default_land_squash")]
143 pub squash: bool,
144}
145
146#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
147#[serde(rename_all = "lowercase")]
148pub enum HarnessMode {
149 #[default]
150 Auto,
151 Off,
152 Required,
153}
154
155#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
156#[serde(rename_all = "lowercase")]
157pub enum HarnessTransport {
158 #[default]
159 Spool,
160 Direct,
161 End,
162}
163
164#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
165#[serde(rename_all = "lowercase")]
166pub enum HarnessTranscriptMode {
167 #[default]
168 Off,
169 Summary,
170 Full,
171}
172
173#[derive(Debug, Clone, Serialize, Deserialize, Default)]
174pub struct UserHarnessOverride {
175 #[serde(default)]
176 pub provider: Option<String>,
177 #[serde(default)]
178 pub model: Option<String>,
179 #[serde(default)]
180 pub thinking_level: Option<String>,
181 #[serde(default)]
182 pub policy: Option<String>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186pub struct UserHarnessConfig {
187 #[serde(default)]
188 pub mode: HarnessMode,
189 #[serde(default)]
190 pub transport: HarnessTransport,
191 #[serde(default)]
192 pub transcript: HarnessTranscriptMode,
193 #[serde(default = "default_auto_infer")]
194 pub auto_infer: bool,
195 #[serde(default)]
196 pub threading: UserHarnessThreadingConfig,
197 #[serde(default)]
198 pub harnesses: BTreeMap<String, UserHarnessOverride>,
199}
200
201#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
202#[serde(rename_all = "kebab-case")]
203pub enum UserHarnessRootThreadPolicy {
204 CreateNew,
205 #[default]
206 AttachCurrent,
207}
208
209#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
210#[serde(rename_all = "kebab-case")]
211pub enum UserHarnessSubagentThreadPolicy {
212 AttachCurrent,
213 #[default]
214 CreateChild,
215}
216
217#[derive(Debug, Clone, Serialize, Deserialize, Default)]
218pub struct UserHarnessThreadingConfig {
219 #[serde(default)]
220 pub root_actor: UserHarnessRootThreadPolicy,
221 #[serde(default)]
222 pub subagent: UserHarnessSubagentThreadPolicy,
223 #[serde(default)]
224 pub workspace_default: Option<UserThreadWorkspaceMode>,
225}
226
227fn default_confidence() -> f32 {
228 0.8
229}
230
231fn default_hash_length() -> usize {
232 8
233}
234
235fn default_change_id_format() -> String {
236 "short".to_string()
237}
238
239fn default_auto_infer() -> bool {
240 true
241}
242
243fn default_land_squash() -> bool {
244 true
245}
246
247impl Default for UserDisplayConfig {
248 fn default() -> Self {
249 Self {
250 hash_length: default_hash_length(),
251 change_id_format: default_change_id_format(),
252 }
253 }
254}
255
256impl Default for UserHarnessConfig {
257 fn default() -> Self {
258 Self {
259 mode: HarnessMode::Auto,
260 transport: HarnessTransport::Spool,
261 transcript: HarnessTranscriptMode::Off,
262 auto_infer: default_auto_infer(),
263 threading: UserHarnessThreadingConfig::default(),
264 harnesses: BTreeMap::new(),
265 }
266 }
267}
268
269impl Default for UserLandConfig {
270 fn default() -> Self {
271 Self {
272 squash: default_land_squash(),
273 }
274 }
275}
276
277impl UserConfig {
278 pub fn default_path() -> Option<PathBuf> {
279 if let Ok(path) = std::env::var("HEDDLE_CONFIG")
280 && !path.is_empty()
281 {
282 return Some(PathBuf::from(path));
283 }
284 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
285 && !xdg.is_empty()
286 {
287 return Some(PathBuf::from(xdg).join("heddle").join("config.toml"));
288 }
289 if let Ok(home) = std::env::var("HOME")
290 && !home.is_empty()
291 {
292 return Some(PathBuf::from(home).join(".config/heddle/config.toml"));
293 }
294 None
295 }
296
297 pub fn load(path: &Path) -> anyhow::Result<Self> {
298 let mut file = fs::File::open(path)?;
299 let mut contents = String::new();
300 file.read_to_string(&mut contents)?;
301 toml::from_str::<Self>(&contents).map_err(|err| {
309 let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
310 objects::error::HeddleError::ConfigParse {
311 path: resolved,
312 source: err,
313 }
314 .into()
315 })
316 }
317
318 pub fn load_default() -> anyhow::Result<Self> {
319 match Self::default_path() {
320 Some(path) => match Self::load(&path) {
321 Ok(config) => Ok(config),
322 Err(err) if path_missing(&err) => Ok(Self::default()),
323 Err(err) => Err(err),
324 },
325 None => Ok(Self::default()),
326 }
327 }
328
329 pub fn save_default(&self) -> anyhow::Result<PathBuf> {
330 let path = Self::default_path()
331 .ok_or_else(|| anyhow::anyhow!("unable to determine user config path"))?;
332 self.save(&path)?;
333 Ok(path)
334 }
335
336 pub fn save(&self, path: &Path) -> anyhow::Result<()> {
337 if let Some(parent) = path.parent() {
338 fs::create_dir_all(parent)?;
339 }
340 let contents = toml::to_string_pretty(self)?;
341 write_file_atomic_secret(path, contents.as_bytes())?;
342 Ok(())
343 }
344
345 pub fn set_principal(&mut self, name: impl Into<String>, email: impl Into<String>) {
346 self.principal = Some(UserPrincipalConfig {
347 name: name.into(),
348 email: email.into(),
349 });
350 }
351
352 pub fn remote_token(&self) -> anyhow::Result<Option<AuthToken>> {
353 match env::var("HEDDLE_REMOTE_TOKEN") {
354 Ok(token) if !token.is_empty() => Ok(Some(AuthToken::new(token, "env"))),
355 Ok(_) | Err(env::VarError::NotPresent) => Ok(self
356 .remote
357 .token
358 .clone()
359 .map(|token| AuthToken::new(token, "user-config"))),
360 Err(err @ env::VarError::NotUnicode(_)) => Err(security_config_error(
361 "HEDDLE_REMOTE_TOKEN",
362 format!("read environment value: {err}"),
363 )),
364 }
365 }
366
367 pub fn heddle_client_config(
368 &self,
369 token_override: Option<AuthToken>,
370 ) -> anyhow::Result<ClientConfig> {
371 let token = match token_override {
372 Some(token) => Some(token),
373 None => self.remote_token()?,
374 };
375 let mut config = token
376 .map(|token| ClientConfig::default().with_token(token))
377 .unwrap_or_default();
378
379 if self.remote.tls_enabled {
380 config = config.with_tls(false);
381 }
382 if let Some(domain) = &self.remote.tls_domain_name {
383 config = config.with_tls_domain_name(domain.clone());
384 }
385 if let Some(path) = &self.remote.tls_ca_certificate_path {
386 let pem = read_security_config_file("remote.tls_ca_certificate_path", path)?;
387 config = config.with_tls_ca_certificate_pem(pem);
388 }
389 if let Some(path) = &self.remote.auth_proof_key_pem_path {
390 let pem = read_security_config_file("remote.auth_proof_key_pem_path", path)?;
391 config = config.with_auth_proof_key_pem(pem);
392 }
393
394 if env_bool("HEDDLE_REMOTE_TLS")? {
395 config = config.with_tls(false);
396 }
397 match env::var("HEDDLE_REMOTE_TLS_DOMAIN") {
398 Ok(domain) => config = config.with_tls_domain_name(domain),
399 Err(env::VarError::NotPresent) => {}
400 Err(err @ env::VarError::NotUnicode(_)) => {
401 return Err(security_config_error(
402 "HEDDLE_REMOTE_TLS_DOMAIN",
403 format!("read environment value: {err}"),
404 ));
405 }
406 }
407 match env::var("HEDDLE_REMOTE_TLS_CA_CERT") {
408 Ok(path) => {
409 let pem =
410 read_security_config_file("HEDDLE_REMOTE_TLS_CA_CERT", &PathBuf::from(path))?;
411 config = config.with_tls_ca_certificate_pem(pem);
412 }
413 Err(env::VarError::NotPresent) => {}
414 Err(err @ env::VarError::NotUnicode(_)) => {
415 return Err(security_config_error(
416 "HEDDLE_REMOTE_TLS_CA_CERT",
417 format!("read environment value: {err}"),
418 ));
419 }
420 }
421 Ok(config)
422 }
423
424 pub fn worktree_status_options(
425 &self,
426 repo_config: Option<&repo::RepoConfig>,
427 ) -> WorktreeStatusOptions {
428 let mut mode = self
429 .worktree
430 .fsmonitor
431 .mode
432 .or_else(|| repo_config.map(|config| config.worktree.fsmonitor.mode))
433 .unwrap_or(FsMonitorMode::Off);
434 if let Ok(value) = std::env::var("HEDDLE_FSMONITOR")
435 && let Some(parsed) = FsMonitorMode::parse(&value)
436 {
437 mode = parsed;
438 }
439
440 WorktreeStatusOptions {
441 fsmonitor: FsMonitorSettings { mode },
442 }
443 }
444}
445
446fn read_security_config_file(setting: &str, path: &Path) -> anyhow::Result<String> {
447 fs::read_to_string(path).map_err(|err| {
448 security_config_error(
449 setting,
450 format!("read configured file {}: {err}", path.display()),
451 )
452 })
453}
454
455fn env_bool(name: &str) -> anyhow::Result<bool> {
456 let value = match env::var(name) {
457 Ok(value) => value,
458 Err(env::VarError::NotPresent) => return Ok(false),
459 Err(err @ env::VarError::NotUnicode(_)) => {
460 return Err(security_config_error(
461 name,
462 format!("read environment value: {err}"),
463 ));
464 }
465 };
466 match value.trim().to_ascii_lowercase().as_str() {
467 "1" | "true" | "yes" | "on" => Ok(true),
468 "0" | "false" | "no" | "off" => Ok(false),
469 _ => Err(security_config_error(
470 name,
471 format!(
472 "parse boolean value {value:?}; expected one of 1/0, true/false, yes/no, or on/off"
473 ),
474 )),
475 }
476}
477
478fn security_config_error(setting: &str, reason: String) -> anyhow::Error {
479 anyhow::anyhow!(
480 "fatal TLS/auth configuration error for `{setting}`: {reason}; refusing to proceed with an ambiguous security posture"
481 )
482}
483
484fn path_missing(err: &anyhow::Error) -> bool {
485 err.downcast_ref::<std::io::Error>()
486 .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
487}
488
489#[cfg(test)]
490mod tests {
491 use std::{
492 ffi::OsString,
493 fs,
494 path::PathBuf,
495 sync::MutexGuard,
496 time::{SystemTime, UNIX_EPOCH},
497 };
498
499 use repo::{FsMonitorMode, RepoConfig};
500
501 use super::{
502 HarnessMode, HarnessTranscriptMode, HarnessTransport, UserConfig, UserRemoteConfig,
503 };
504
505 static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
506 const REMOTE_ENV_KEYS: &[&str] = &[
507 "HEDDLE_REMOTE_TOKEN",
508 "HEDDLE_REMOTE_TLS",
509 "HEDDLE_REMOTE_TLS_DOMAIN",
510 "HEDDLE_REMOTE_TLS_CA_CERT",
511 ];
512
513 struct RemoteEnvGuard {
514 _guard: MutexGuard<'static, ()>,
515 saved: Vec<(&'static str, Option<OsString>)>,
516 }
517
518 impl RemoteEnvGuard {
519 fn clean() -> Self {
520 let guard = TEST_ENV_LOCK
521 .lock()
522 .unwrap_or_else(|poisoned| poisoned.into_inner());
523 let saved = REMOTE_ENV_KEYS
524 .iter()
525 .map(|key| (*key, std::env::var_os(key)))
526 .collect();
527 for key in REMOTE_ENV_KEYS {
528 unsafe { std::env::remove_var(key) };
529 }
530 Self {
531 _guard: guard,
532 saved,
533 }
534 }
535
536 fn set(&self, key: &str, value: impl AsRef<std::ffi::OsStr>) {
537 unsafe { std::env::set_var(key, value) };
538 }
539 }
540
541 impl Drop for RemoteEnvGuard {
542 fn drop(&mut self) {
543 for (key, value) in &self.saved {
544 unsafe {
545 if let Some(value) = value {
546 std::env::set_var(key, value);
547 } else {
548 std::env::remove_var(key);
549 }
550 }
551 }
552 }
553 }
554
555 fn unique_temp_path(prefix: &str) -> PathBuf {
556 let unique = SystemTime::now()
557 .duration_since(UNIX_EPOCH)
558 .expect("system time before unix epoch")
559 .as_nanos();
560 std::env::temp_dir().join(format!("{prefix}-{}-{unique}", std::process::id()))
561 }
562
563 #[test]
564 fn user_worktree_status_options_fall_back_to_repo_config() {
565 let mut repo = RepoConfig::default();
566 repo.worktree.fsmonitor.mode = FsMonitorMode::Watchman;
567
568 let config = UserConfig::default();
569 let options = config.worktree_status_options(Some(&repo));
570
571 assert_eq!(options.fsmonitor.mode, FsMonitorMode::Watchman);
572 }
573
574 #[test]
575 fn harness_config_defaults_are_magical_but_safe() {
576 let config = UserConfig::default();
577 assert_eq!(config.harness.mode, HarnessMode::Auto);
578 assert_eq!(config.harness.transport, HarnessTransport::Spool);
579 assert_eq!(config.harness.transcript, HarnessTranscriptMode::Off);
580 assert!(config.harness.auto_infer);
581 assert!(config.harness.harnesses.is_empty());
582 }
583
584 #[test]
585 fn heddle_client_config_absent_security_settings_uses_defaults() {
586 let _env = RemoteEnvGuard::clean();
587 let config = UserConfig::default()
588 .heddle_client_config(None)
589 .expect("absent optional settings should not error");
590
591 assert!(!config.tls_enabled);
592 assert!(!config.tls_skip_verify);
593 assert!(config.tls_ca_certificate_pem.is_none());
594 assert!(config.auth_proof_key_pem.is_none());
595 assert!(config.token.is_none());
596 }
597
598 #[test]
599 fn heddle_client_config_valid_security_files_are_applied() {
600 let _env = RemoteEnvGuard::clean();
601 let dir = unique_temp_path("heddle-user-config-valid-security");
602 fs::create_dir_all(&dir).expect("create temp dir");
603 let ca_path = dir.join("ca.pem");
604 let key_path = dir.join("proof-key.pem");
605 fs::write(&ca_path, "test ca pem").expect("write ca pem");
606 fs::write(&key_path, "test key pem").expect("write key pem");
607 let user = UserConfig {
608 remote: UserRemoteConfig {
609 tls_ca_certificate_path: Some(ca_path),
610 auth_proof_key_pem_path: Some(key_path),
611 ..UserRemoteConfig::default()
612 },
613 ..UserConfig::default()
614 };
615
616 let config = user
617 .heddle_client_config(None)
618 .expect("valid TLS/auth files should load");
619
620 assert!(config.tls_enabled);
621 assert_eq!(
622 config.tls_ca_certificate_pem.as_deref(),
623 Some("test ca pem")
624 );
625 assert_eq!(config.auth_proof_key_pem.as_deref(), Some("test key pem"));
626
627 fs::remove_dir_all(dir).expect("remove temp dir");
628 }
629
630 #[test]
631 fn heddle_client_config_missing_tls_ca_path_fails_closed() {
632 let _env = RemoteEnvGuard::clean();
633 let missing = unique_temp_path("heddle-user-config-missing-ca").join("ca.pem");
634 let user = UserConfig {
635 remote: UserRemoteConfig {
636 tls_ca_certificate_path: Some(missing),
637 ..UserRemoteConfig::default()
638 },
639 ..UserConfig::default()
640 };
641
642 let err = user
643 .heddle_client_config(None)
644 .expect_err("missing configured CA path must fail closed");
645 let message = err.to_string();
646
647 assert!(message.contains("fatal TLS/auth configuration error"));
648 assert!(message.contains("remote.tls_ca_certificate_path"));
649 }
650
651 #[test]
652 fn heddle_client_config_missing_auth_proof_key_path_fails_closed() {
653 let _env = RemoteEnvGuard::clean();
654 let missing = unique_temp_path("heddle-user-config-missing-key").join("proof-key.pem");
655 let user = UserConfig {
656 remote: UserRemoteConfig {
657 auth_proof_key_pem_path: Some(missing),
658 ..UserRemoteConfig::default()
659 },
660 ..UserConfig::default()
661 };
662
663 let err = user
664 .heddle_client_config(None)
665 .expect_err("missing configured proof key path must fail closed");
666 let message = err.to_string();
667
668 assert!(message.contains("fatal TLS/auth configuration error"));
669 assert!(message.contains("remote.auth_proof_key_pem_path"));
670 }
671
672 #[test]
673 fn heddle_client_config_missing_env_tls_ca_path_fails_closed() {
674 let env = RemoteEnvGuard::clean();
675 let missing = unique_temp_path("heddle-user-config-missing-env-ca").join("ca.pem");
676 env.set("HEDDLE_REMOTE_TLS_CA_CERT", missing);
677
678 let err = UserConfig::default()
679 .heddle_client_config(None)
680 .expect_err("missing env CA path must fail closed");
681 let message = err.to_string();
682
683 assert!(message.contains("fatal TLS/auth configuration error"));
684 assert!(message.contains("HEDDLE_REMOTE_TLS_CA_CERT"));
685 }
686
687 #[test]
688 fn heddle_client_config_invalid_env_tls_value_fails_closed() {
689 let env = RemoteEnvGuard::clean();
690 env.set("HEDDLE_REMOTE_TLS", "enabled");
691
692 let err = UserConfig::default()
693 .heddle_client_config(None)
694 .expect_err("invalid TLS env value must fail closed");
695 let message = err.to_string();
696
697 assert!(message.contains("fatal TLS/auth configuration error"));
698 assert!(message.contains("HEDDLE_REMOTE_TLS"));
699 }
700}