Skip to main content

cli_shared/config/
user_config.rs

1// SPDX-License-Identifier: Apache-2.0
2use 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 proto::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/// User-config default for thread workspace mode. Same vocabulary
85/// as [`crate::config::repo::ThreadMode`] and the `--workspace` flag,
86/// so a user setting `top_level_default = "materialized"` reads
87/// uniformly across the CLI surface and the thread record on disk.
88#[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        // Route TOML parse failures through `HeddleError::ConfigParse` so
302        // the CLI error envelope (see `print_error_with_hint`) can
303        // classify them and render the *actual* source file in the
304        // recovery advice — not a hard-coded `.heddle/config.toml`
305        // (Codex R3 cid 3313132711 on #271). The path is canonicalized
306        // so the rendered hint is copy/paste-safe even when the caller
307        // passed a relative or env-derived path.
308        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}