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::{
10    fs_atomic::{StagedAtomicWrite, stage_file_atomic_secret},
11    object::Principal,
12};
13use repo::{
14    FsMonitorMode, FsMonitorSettings, OutputFormat, Repository, WorktreeStatusOptions,
15    identity::heddle_home_override,
16};
17use serde::{Deserialize, Serialize};
18use wire::AuthToken;
19
20use crate::client_config::ClientConfig;
21
22#[derive(Debug, Clone, Serialize, Deserialize, Default)]
23pub struct UserConfig {
24    #[serde(default)]
25    pub principal: Option<UserPrincipalConfig>,
26    #[serde(default)]
27    pub agent: UserAgentConfig,
28    #[serde(default)]
29    pub capture: UserCaptureConfig,
30    #[serde(default)]
31    pub output: UserOutputConfig,
32    #[serde(default)]
33    pub display: UserDisplayConfig,
34    #[serde(default)]
35    pub worktree: UserWorktreeConfig,
36    #[serde(default)]
37    pub logging: UserLoggingConfig,
38    #[serde(default)]
39    pub remote: UserRemoteConfig,
40    #[serde(default)]
41    pub harness: UserHarnessConfig,
42    #[serde(default)]
43    pub land: UserLandConfig,
44}
45
46pub struct StagedUserConfig {
47    path: PathBuf,
48    write: StagedAtomicWrite,
49}
50
51/// A principal together with the configuration surface that selected it.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct ResolvedPrincipal {
54    pub principal: Principal,
55    pub source: Option<&'static str>,
56}
57
58impl ResolvedPrincipal {
59    fn configured(principal: Principal, source: &'static str) -> Self {
60        Self {
61            principal,
62            source: Some(source),
63        }
64    }
65
66    fn unknown(principal: Principal) -> Self {
67        Self {
68            principal,
69            source: None,
70        }
71    }
72}
73
74impl StagedUserConfig {
75    pub fn publish(self) -> anyhow::Result<PathBuf> {
76        self.write.publish()?;
77        Ok(self.path)
78    }
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
82pub struct UserPrincipalConfig {
83    pub name: String,
84    pub email: String,
85}
86
87#[derive(Debug, Clone, Serialize, Deserialize, Default)]
88pub struct UserAgentConfig {
89    #[serde(default)]
90    pub provider: Option<String>,
91    #[serde(default)]
92    pub model: Option<String>,
93    #[serde(default)]
94    pub default_policy: Option<String>,
95    #[serde(default = "default_confidence")]
96    pub confidence: f32,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, Default)]
100pub struct UserCaptureConfig {
101    #[serde(default)]
102    pub auto: UserAutoCaptureMode,
103}
104
105#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
106#[serde(rename_all = "lowercase")]
107pub enum UserAutoCaptureMode {
108    #[default]
109    Off,
110    Command,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, Default)]
114pub struct UserOutputConfig {
115    #[serde(default)]
116    pub format: OutputFormat,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct UserDisplayConfig {
121    #[serde(default = "default_hash_length")]
122    pub hash_length: usize,
123    #[serde(default = "default_change_id_format")]
124    pub change_id_format: String,
125}
126
127#[derive(Debug, Clone, Serialize, Deserialize, Default)]
128pub struct UserWorktreeConfig {
129    #[serde(default)]
130    pub fsmonitor: UserFsMonitorConfig,
131    #[serde(default)]
132    pub thread_workspace: UserThreadWorkspaceConfig,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize, Default)]
136pub struct UserFsMonitorConfig {
137    #[serde(default)]
138    pub mode: Option<FsMonitorMode>,
139}
140
141/// User-config default for thread workspace mode. Same vocabulary
142/// as [`crate::config::repo::ThreadMode`] and the `--workspace` flag,
143/// so a user setting `top_level_default = "materialized"` reads
144/// uniformly across the CLI surface and the thread record on disk.
145#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
146#[serde(rename_all = "kebab-case")]
147pub enum UserThreadWorkspaceMode {
148    #[default]
149    Auto,
150    Materialized,
151    Virtualized,
152    Solid,
153}
154
155#[derive(Debug, Clone, Serialize, Deserialize, Default)]
156pub struct UserThreadWorkspaceConfig {
157    #[serde(default)]
158    pub top_level_default: UserThreadWorkspaceMode,
159    #[serde(default)]
160    pub delegated_default: Option<UserThreadWorkspaceMode>,
161}
162
163#[derive(Debug, Clone, Serialize, Deserialize, Default)]
164pub struct UserLoggingConfig {
165    #[serde(default)]
166    pub format: Option<String>,
167    #[serde(default)]
168    pub include_location: bool,
169    #[serde(default)]
170    pub include_thread_ids: bool,
171    #[serde(default)]
172    pub log_spans: bool,
173    #[serde(default)]
174    pub otel_service_name: Option<String>,
175    #[serde(default)]
176    pub otel_endpoint: Option<String>,
177    #[serde(default)]
178    pub otel_traces_endpoint: Option<String>,
179    #[serde(default)]
180    pub otel_metrics_endpoint: Option<String>,
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize, Default)]
184pub struct UserRemoteConfig {
185    #[serde(default)]
186    pub tls_enabled: bool,
187    #[serde(default)]
188    pub tls_domain_name: Option<String>,
189    #[serde(default)]
190    pub tls_ca_certificate_path: Option<PathBuf>,
191    #[serde(default)]
192    pub auth_proof_key_pem_path: Option<PathBuf>,
193    #[serde(default)]
194    pub iroh_descriptor_key_id: Option<String>,
195    #[serde(default)]
196    pub iroh_descriptor_public_key_path: Option<PathBuf>,
197    #[serde(default)]
198    pub provider_global_concurrency: Option<usize>,
199    #[serde(default)]
200    pub provider_per_endpoint_concurrency: Option<usize>,
201    #[serde(default)]
202    pub provider_max_inflight_bytes: Option<usize>,
203    #[serde(default)]
204    pub provider_stall_timeout_secs: Option<u64>,
205    /// Allow cleartext connections to non-loopback hosts without TLS.
206    /// Prefer enabling TLS; this is an explicit opt-in for lab/VPN testing.
207    #[serde(default)]
208    pub insecure: bool,
209}
210
211#[derive(Debug, Clone, Serialize, Deserialize)]
212pub struct UserLandConfig {
213    #[serde(default = "default_land_squash")]
214    pub squash: bool,
215}
216
217#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
218#[serde(rename_all = "lowercase")]
219pub enum HarnessMode {
220    #[default]
221    Auto,
222    Off,
223    Required,
224}
225
226#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
227#[serde(rename_all = "lowercase")]
228pub enum HarnessTransport {
229    #[default]
230    Spool,
231    Direct,
232    End,
233}
234
235#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
236#[serde(rename_all = "lowercase")]
237pub enum HarnessTranscriptMode {
238    #[default]
239    Off,
240    Summary,
241    Full,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize, Default)]
245pub struct UserHarnessOverride {
246    #[serde(default)]
247    pub provider: Option<String>,
248    #[serde(default)]
249    pub model: Option<String>,
250    #[serde(default)]
251    pub thinking_level: Option<String>,
252    #[serde(default)]
253    pub policy: Option<String>,
254}
255
256#[derive(Debug, Clone, Serialize, Deserialize)]
257pub struct UserHarnessConfig {
258    #[serde(default)]
259    pub mode: HarnessMode,
260    #[serde(default)]
261    pub transport: HarnessTransport,
262    #[serde(default)]
263    pub transcript: HarnessTranscriptMode,
264    #[serde(default = "default_auto_infer")]
265    pub auto_infer: bool,
266    #[serde(default)]
267    pub threading: UserHarnessThreadingConfig,
268    #[serde(default)]
269    pub harnesses: BTreeMap<String, UserHarnessOverride>,
270}
271
272#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
273#[serde(rename_all = "kebab-case")]
274pub enum UserHarnessRootThreadPolicy {
275    CreateNew,
276    #[default]
277    AttachCurrent,
278}
279
280#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
281#[serde(rename_all = "kebab-case")]
282pub enum UserHarnessSubagentThreadPolicy {
283    AttachCurrent,
284    #[default]
285    CreateChild,
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize, Default)]
289pub struct UserHarnessThreadingConfig {
290    #[serde(default)]
291    pub root_actor: UserHarnessRootThreadPolicy,
292    #[serde(default)]
293    pub subagent: UserHarnessSubagentThreadPolicy,
294    #[serde(default)]
295    pub workspace_default: Option<UserThreadWorkspaceMode>,
296}
297
298fn default_confidence() -> f32 {
299    0.8
300}
301
302fn default_hash_length() -> usize {
303    8
304}
305
306fn default_change_id_format() -> String {
307    "short".to_string()
308}
309
310fn default_auto_infer() -> bool {
311    true
312}
313
314fn default_land_squash() -> bool {
315    true
316}
317
318impl Default for UserDisplayConfig {
319    fn default() -> Self {
320        Self {
321            hash_length: default_hash_length(),
322            change_id_format: default_change_id_format(),
323        }
324    }
325}
326
327impl Default for UserHarnessConfig {
328    fn default() -> Self {
329        Self {
330            mode: HarnessMode::Auto,
331            transport: HarnessTransport::Spool,
332            transcript: HarnessTranscriptMode::Off,
333            auto_infer: default_auto_infer(),
334            threading: UserHarnessThreadingConfig::default(),
335            harnesses: BTreeMap::new(),
336        }
337    }
338}
339
340impl Default for UserLandConfig {
341    fn default() -> Self {
342        Self {
343            squash: default_land_squash(),
344        }
345    }
346}
347
348impl UserConfig {
349    pub fn default_path() -> Option<PathBuf> {
350        if let Ok(path) = std::env::var("HEDDLE_CONFIG")
351            && !path.is_empty()
352        {
353            return Some(PathBuf::from(path));
354        }
355        if let Some(home) = heddle_home_override() {
356            return Some(home.join("config.toml"));
357        }
358        if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME")
359            && !xdg.is_empty()
360        {
361            return Some(PathBuf::from(xdg).join("heddle").join("config.toml"));
362        }
363        if let Ok(home) = std::env::var("HOME")
364            && !home.is_empty()
365        {
366            return Some(PathBuf::from(home).join(".config/heddle/config.toml"));
367        }
368        None
369    }
370
371    pub fn load(path: &Path) -> anyhow::Result<Self> {
372        let mut file = fs::File::open(path)?;
373        let mut contents = String::new();
374        file.read_to_string(&mut contents)?;
375        let resolved = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
376        if let Some(value) = invalid_output_format_value(&contents) {
377            return Err(objects::error::HeddleError::ConfigInvalidValue {
378                path: resolved,
379                key: "output.format".to_string(),
380                value,
381                valid_values: vec!["'text'".to_string(), "'json'".to_string()],
382            }
383            .into());
384        }
385        // Route TOML parse failures through `HeddleError::ConfigParse` so
386        // the CLI error envelope (see `print_error_with_hint`) can
387        // classify them and render the *actual* source file in the
388        // recovery advice — not a hard-coded `.heddle/config.toml`
389        // (Codex R3 cid 3313132711 on #271). The path is canonicalized
390        // so the rendered hint is copy/paste-safe even when the caller
391        // passed a relative or env-derived path.
392        toml::from_str::<Self>(&contents).map_err(|err| {
393            objects::error::HeddleError::ConfigParse {
394                path: resolved,
395                source: err,
396            }
397            .into()
398        })
399    }
400
401    pub fn load_default() -> anyhow::Result<Self> {
402        match Self::default_path() {
403            Some(path) => match Self::load(&path) {
404                Ok(config) => Ok(config),
405                Err(err) if path_missing(&err) => Ok(Self::default()),
406                Err(err) => Err(err),
407            },
408            None => Ok(Self::default()),
409        }
410    }
411
412    pub fn save_default(&self) -> anyhow::Result<PathBuf> {
413        self.stage_default()?.publish()
414    }
415
416    pub fn stage_default(&self) -> anyhow::Result<StagedUserConfig> {
417        let path = Self::default_path()
418            .ok_or_else(|| anyhow::anyhow!("unable to determine user config path"))?;
419        self.stage(&path)
420    }
421
422    pub fn save(&self, path: &Path) -> anyhow::Result<()> {
423        self.stage(path)?.publish()?;
424        Ok(())
425    }
426
427    pub fn stage(&self, path: &Path) -> anyhow::Result<StagedUserConfig> {
428        let contents = toml::to_string_pretty(self)?;
429        let write = stage_file_atomic_secret(path, contents.as_bytes())?;
430        Ok(StagedUserConfig {
431            path: path.to_path_buf(),
432            write,
433        })
434    }
435
436    pub fn set_principal(&mut self, name: impl Into<String>, email: impl Into<String>) {
437        self.principal = Some(UserPrincipalConfig {
438            name: name.into(),
439            email: email.into(),
440        });
441    }
442
443    pub fn command_auto_capture_enabled(&self) -> anyhow::Result<bool> {
444        let mut mode = self.capture.auto;
445        match env::var("HEDDLE_AUTO_CAPTURE") {
446            Ok(value) if !value.trim().is_empty() => {
447                mode = parse_auto_capture_env("HEDDLE_AUTO_CAPTURE", &value)?;
448            }
449            Ok(_) | Err(env::VarError::NotPresent) => {}
450            Err(err @ env::VarError::NotUnicode(_)) => {
451                return Err(config_value_error(
452                    "HEDDLE_AUTO_CAPTURE",
453                    format!("read environment value: {err}"),
454                ));
455            }
456        }
457        Ok(matches!(mode, UserAutoCaptureMode::Command))
458    }
459
460    /// Resolve the shared custom CA bundle used by hosted and Git transports.
461    ///
462    /// `HEDDLE_REMOTE_TLS_CA_CERT` overrides the user-config path.
463    pub fn remote_tls_ca_certificate_pem(&self) -> anyhow::Result<Option<String>> {
464        let mut ca_pem = self
465            .remote
466            .tls_ca_certificate_path
467            .as_ref()
468            .map(|path| read_security_config_file("remote.tls_ca_certificate_path", path))
469            .transpose()?;
470        match env::var("HEDDLE_REMOTE_TLS_CA_CERT") {
471            Ok(path) => {
472                ca_pem = Some(read_security_config_file(
473                    "HEDDLE_REMOTE_TLS_CA_CERT",
474                    &PathBuf::from(path),
475                )?);
476            }
477            Err(env::VarError::NotPresent) => {}
478            Err(err @ env::VarError::NotUnicode(_)) => {
479                return Err(security_config_error(
480                    "HEDDLE_REMOTE_TLS_CA_CERT",
481                    format!("read environment value: {err}"),
482                ));
483            }
484        }
485        Ok(ca_pem)
486    }
487
488    /// Build the validated TLS/auth client config. The bearer token is supplied
489    /// by the caller (resolved through the single `HEDDLE_CREDENTIAL` → keystore
490    /// precedence in the client crate); this method no longer reads any token
491    /// from env or user config.
492    pub fn hosted_runtime_config(&self, token: Option<AuthToken>) -> anyhow::Result<ClientConfig> {
493        let mut config = token
494            .map(|token| ClientConfig::default().with_token(token))
495            .unwrap_or_default();
496
497        if self.remote.tls_enabled {
498            config = config.with_tls(false);
499        }
500        if self.remote.insecure {
501            config = config.with_allow_insecure(true);
502        }
503        if let Some(domain) = &self.remote.tls_domain_name {
504            config = config.with_tls_domain_name(domain.clone());
505        }
506        if let Some(pem) = self.remote_tls_ca_certificate_pem()? {
507            config = config.with_tls_ca_certificate_pem(pem);
508        }
509        if let Some(path) = &self.remote.auth_proof_key_pem_path {
510            let pem = read_security_config_file("remote.auth_proof_key_pem_path", path)?;
511            config = config.with_auth_proof_key_pem(pem);
512        }
513        if let (Some(key_id), Some(path)) = (
514            self.remote.iroh_descriptor_key_id.as_deref(),
515            self.remote.iroh_descriptor_public_key_path.as_deref(),
516        ) {
517            config = config.with_descriptor_trust(
518                key_id,
519                read_descriptor_public_key("remote.iroh_descriptor_public_key_path", path)?,
520            );
521        } else if self.remote.iroh_descriptor_key_id.is_some()
522            || self.remote.iroh_descriptor_public_key_path.is_some()
523        {
524            return Err(security_config_error(
525                "remote.iroh_descriptor_key_id/remote.iroh_descriptor_public_key_path",
526                "both descriptor trust fields are required".to_string(),
527            ));
528        }
529        if let Some(value) = self.remote.provider_global_concurrency {
530            config = config.with_provider_global_concurrency(value);
531        }
532        if let Some(value) = self.remote.provider_per_endpoint_concurrency {
533            config = config.with_provider_per_endpoint_concurrency(value);
534        }
535        if let Some(value) = self.remote.provider_max_inflight_bytes {
536            config = config.with_provider_max_inflight_bytes(value);
537        }
538        if let Some(value) = self.remote.provider_stall_timeout_secs {
539            config = config.with_provider_stall_timeout(value);
540        }
541
542        if env_bool("HEDDLE_REMOTE_TLS")? {
543            config = config.with_tls(false);
544        }
545        if env_bool("HEDDLE_REMOTE_INSECURE")? {
546            config = config.with_allow_insecure(true);
547        }
548        match env::var("HEDDLE_REMOTE_TLS_DOMAIN") {
549            Ok(domain) => config = config.with_tls_domain_name(domain),
550            Err(env::VarError::NotPresent) => {}
551            Err(err @ env::VarError::NotUnicode(_)) => {
552                return Err(security_config_error(
553                    "HEDDLE_REMOTE_TLS_DOMAIN",
554                    format!("read environment value: {err}"),
555                ));
556            }
557        }
558        match (
559            env::var("HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID"),
560            env::var("HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY"),
561        ) {
562            (Ok(key_id), Ok(public_key)) if !key_id.is_empty() && !public_key.is_empty() => {
563                config = config.with_descriptor_trust(
564                    key_id,
565                    parse_descriptor_public_key(
566                        "HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
567                        &public_key,
568                    )?,
569                );
570            }
571            (Err(env::VarError::NotPresent), Err(env::VarError::NotPresent)) => {}
572            (Ok(_), Err(env::VarError::NotPresent))
573            | (Err(env::VarError::NotPresent), Ok(_))
574            | (Ok(_), Ok(_)) => {
575                return Err(security_config_error(
576                    "HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID/HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
577                    "both non-empty descriptor trust values are required".to_string(),
578                ));
579            }
580            (Err(error), _) | (_, Err(error)) => {
581                return Err(security_config_error(
582                    "HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID/HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
583                    format!("read environment value: {error}"),
584                ));
585            }
586        }
587        if let Some(value) = env_positive::<usize>("HEDDLE_REMOTE_PROVIDER_GLOBAL_CONCURRENCY")? {
588            config = config.with_provider_global_concurrency(value);
589        }
590        if let Some(value) =
591            env_positive::<usize>("HEDDLE_REMOTE_PROVIDER_PER_ENDPOINT_CONCURRENCY")?
592        {
593            config = config.with_provider_per_endpoint_concurrency(value);
594        }
595        if let Some(value) = env_positive::<usize>("HEDDLE_REMOTE_PROVIDER_MAX_INFLIGHT_BYTES")? {
596            config = config.with_provider_max_inflight_bytes(value);
597        }
598        if let Some(value) = env_positive::<u64>("HEDDLE_REMOTE_PROVIDER_STALL_TIMEOUT_SECS")? {
599            config = config.with_provider_stall_timeout(value);
600        }
601        Ok(config)
602    }
603
604    pub fn worktree_status_options(
605        &self,
606        repo_config: Option<&repo::RepoConfig>,
607    ) -> WorktreeStatusOptions {
608        let mut mode = self
609            .worktree
610            .fsmonitor
611            .mode
612            .or_else(|| repo_config.map(|config| config.worktree.fsmonitor.mode))
613            .unwrap_or(FsMonitorMode::Off);
614        if let Ok(value) = std::env::var("HEDDLE_FSMONITOR")
615            && let Some(parsed) = FsMonitorMode::parse(&value)
616        {
617            mode = parsed;
618        }
619
620        WorktreeStatusOptions {
621            fsmonitor: FsMonitorSettings { mode },
622        }
623    }
624}
625
626/// Resolve capture attribution once for init, status, capture, and other
627/// identity-bearing commands.
628///
629/// Precedence is environment, repository config, Git config (including a
630/// shared parent checkout), user config, then the built-in Unknown principal.
631pub fn resolve_principal(
632    repo: &Repository,
633    user_config: &UserConfig,
634) -> repo::Result<ResolvedPrincipal> {
635    if let Some(principal) = Principal::from_env() {
636        return Ok(ResolvedPrincipal::configured(principal, "environment"));
637    }
638    if let Some(config) = &repo.config().principal {
639        return Ok(ResolvedPrincipal::configured(
640            Principal::new(&config.name, &config.email),
641            "repository",
642        ));
643    }
644    let principal = repo.get_principal()?;
645    if principal_is_accountable(&principal) {
646        return Ok(ResolvedPrincipal::configured(principal, "git_config"));
647    }
648    if let Some(config) = &user_config.principal {
649        return Ok(ResolvedPrincipal::configured(
650            Principal::new(&config.name, &config.email),
651            "user_config",
652        ));
653    }
654    Ok(ResolvedPrincipal::unknown(principal))
655}
656
657/// Human-facing source label. User config is called out as global because it
658/// is shared across repositories unless `HEDDLE_HOME` or `HEDDLE_CONFIG`
659/// isolates it.
660pub fn principal_source_display(source: &str) -> &str {
661    match source {
662        "user_config" => "user_config (shared global config)",
663        _ => source,
664    }
665}
666
667fn principal_is_accountable(principal: &Principal) -> bool {
668    let name = principal.name.trim();
669    let email = principal.email.trim();
670    !name.is_empty() && !email.is_empty() && !(name == "Unknown" && email == "unknown@example.com")
671}
672
673fn parse_auto_capture_env(setting: &str, value: &str) -> anyhow::Result<UserAutoCaptureMode> {
674    match value.trim().to_ascii_lowercase().as_str() {
675        "1" | "true" | "yes" | "on" | "command" | "commands" => Ok(UserAutoCaptureMode::Command),
676        "0" | "false" | "no" | "off" => Ok(UserAutoCaptureMode::Off),
677        _ => Err(config_value_error(
678            setting,
679            format!(
680                "parse auto-capture value {value:?}; expected one of off, command, true, or false"
681            ),
682        )),
683    }
684}
685
686fn invalid_output_format_value(contents: &str) -> Option<String> {
687    let value = toml::from_str::<toml::Value>(contents).ok()?;
688    let format = value
689        .get("output")
690        .and_then(|output| output.get("format"))
691        .and_then(toml::Value::as_str)?;
692    (!matches!(format, "text" | "json")).then(|| format.to_string())
693}
694
695fn read_security_config_file(setting: &str, path: &Path) -> anyhow::Result<String> {
696    fs::read_to_string(path).map_err(|err| {
697        security_config_error(
698            setting,
699            format!("read configured file {}: {err}", path.display()),
700        )
701    })
702}
703
704fn read_descriptor_public_key(setting: &str, path: &Path) -> anyhow::Result<[u8; 32]> {
705    let value = read_security_config_file(setting, path)?;
706    parse_descriptor_public_key(setting, value.trim())
707}
708
709fn parse_descriptor_public_key(setting: &str, value: &str) -> anyhow::Result<[u8; 32]> {
710    let bytes = hex::decode(value).map_err(|error| {
711        security_config_error(setting, format!("decode hex descriptor key: {error}"))
712    })?;
713    bytes.try_into().map_err(|_| {
714        security_config_error(
715            setting,
716            "descriptor key must be a 32-byte Ed25519 public key".to_string(),
717        )
718    })
719}
720
721fn env_bool(name: &str) -> anyhow::Result<bool> {
722    let value = match env::var(name) {
723        Ok(value) => value,
724        Err(env::VarError::NotPresent) => return Ok(false),
725        Err(err @ env::VarError::NotUnicode(_)) => {
726            return Err(security_config_error(
727                name,
728                format!("read environment value: {err}"),
729            ));
730        }
731    };
732    match value.trim().to_ascii_lowercase().as_str() {
733        "1" | "true" | "yes" | "on" => Ok(true),
734        "0" | "false" | "no" | "off" => Ok(false),
735        _ => Err(security_config_error(
736            name,
737            format!(
738                "parse boolean value {value:?}; expected one of 1/0, true/false, yes/no, or on/off"
739            ),
740        )),
741    }
742}
743
744fn env_positive<T>(name: &str) -> anyhow::Result<Option<T>>
745where
746    T: std::str::FromStr + PartialEq + Default,
747    T::Err: std::fmt::Display,
748{
749    let value = match env::var(name) {
750        Ok(value) => value,
751        Err(env::VarError::NotPresent) => return Ok(None),
752        Err(err @ env::VarError::NotUnicode(_)) => {
753            return Err(config_value_error(
754                name,
755                format!("read environment value: {err}"),
756            ));
757        }
758    };
759    let parsed = value.trim().parse::<T>().map_err(|error| {
760        config_value_error(name, format!("parse positive integer value: {error}"))
761    })?;
762    if parsed == T::default() {
763        return Err(config_value_error(
764            name,
765            "value must be greater than zero".to_string(),
766        ));
767    }
768    Ok(Some(parsed))
769}
770
771fn config_value_error(setting: &str, reason: String) -> anyhow::Error {
772    anyhow::anyhow!("fatal configuration error for `{setting}`: {reason}")
773}
774
775fn security_config_error(setting: &str, reason: String) -> anyhow::Error {
776    anyhow::anyhow!(
777        "fatal TLS/auth configuration error for `{setting}`: {reason}; refusing to proceed with an ambiguous security posture"
778    )
779}
780
781fn path_missing(err: &anyhow::Error) -> bool {
782    err.downcast_ref::<std::io::Error>()
783        .is_some_and(|io| io.kind() == std::io::ErrorKind::NotFound)
784}
785
786#[cfg(test)]
787mod tests {
788    use std::{
789        ffi::OsString,
790        fs,
791        path::PathBuf,
792        sync::MutexGuard,
793        time::{SystemTime, UNIX_EPOCH},
794    };
795
796    use repo::{FsMonitorMode, RepoConfig};
797
798    use super::{
799        HarnessMode, HarnessTranscriptMode, HarnessTransport, UserAutoCaptureMode,
800        UserCaptureConfig, UserConfig, UserRemoteConfig,
801    };
802
803    static TEST_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
804    const REMOTE_ENV_KEYS: &[&str] = &[
805        "HEDDLE_REMOTE_TLS",
806        "HEDDLE_REMOTE_TLS_DOMAIN",
807        "HEDDLE_REMOTE_TLS_CA_CERT",
808        "HEDDLE_REMOTE_INSECURE",
809        "HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID",
810        "HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
811        "HEDDLE_REMOTE_PROVIDER_GLOBAL_CONCURRENCY",
812        "HEDDLE_REMOTE_PROVIDER_PER_ENDPOINT_CONCURRENCY",
813        "HEDDLE_REMOTE_PROVIDER_MAX_INFLIGHT_BYTES",
814        "HEDDLE_REMOTE_PROVIDER_STALL_TIMEOUT_SECS",
815        "HEDDLE_AUTO_CAPTURE",
816    ];
817
818    struct RemoteEnvGuard {
819        _guard: MutexGuard<'static, ()>,
820        saved: Vec<(&'static str, Option<OsString>)>,
821    }
822
823    impl RemoteEnvGuard {
824        fn clean() -> Self {
825            let guard = TEST_ENV_LOCK
826                .lock()
827                .unwrap_or_else(|poisoned| poisoned.into_inner());
828            let saved = REMOTE_ENV_KEYS
829                .iter()
830                .map(|key| (*key, std::env::var_os(key)))
831                .collect();
832            for key in REMOTE_ENV_KEYS {
833                unsafe { std::env::remove_var(key) };
834            }
835            Self {
836                _guard: guard,
837                saved,
838            }
839        }
840
841        fn set(&self, key: &str, value: impl AsRef<std::ffi::OsStr>) {
842            unsafe { std::env::set_var(key, value) };
843        }
844    }
845
846    impl Drop for RemoteEnvGuard {
847        fn drop(&mut self) {
848            for (key, value) in &self.saved {
849                unsafe {
850                    if let Some(value) = value {
851                        std::env::set_var(key, value);
852                    } else {
853                        std::env::remove_var(key);
854                    }
855                }
856            }
857        }
858    }
859
860    fn unique_temp_path(prefix: &str) -> PathBuf {
861        let unique = SystemTime::now()
862            .duration_since(UNIX_EPOCH)
863            .expect("system time before unix epoch")
864            .as_nanos();
865        std::env::temp_dir().join(format!("{prefix}-{}-{unique}", std::process::id()))
866    }
867
868    #[test]
869    fn user_worktree_status_options_fall_back_to_repo_config() {
870        let mut repo = RepoConfig::default();
871        repo.worktree.fsmonitor.mode = FsMonitorMode::Watchman;
872
873        let config = UserConfig::default();
874        let options = config.worktree_status_options(Some(&repo));
875
876        assert_eq!(options.fsmonitor.mode, FsMonitorMode::Watchman);
877    }
878
879    #[test]
880    fn harness_config_defaults_are_magical_but_safe() {
881        let config = UserConfig::default();
882        assert_eq!(config.harness.mode, HarnessMode::Auto);
883        assert_eq!(config.harness.transport, HarnessTransport::Spool);
884        assert_eq!(config.harness.transcript, HarnessTranscriptMode::Off);
885        assert!(config.harness.auto_infer);
886        assert!(config.harness.harnesses.is_empty());
887    }
888
889    #[test]
890    fn command_auto_capture_defaults_off() {
891        let _env = RemoteEnvGuard::clean();
892
893        let config = UserConfig::default();
894
895        assert!(!config.command_auto_capture_enabled().unwrap());
896    }
897
898    #[test]
899    fn command_auto_capture_reads_user_config() {
900        let _env = RemoteEnvGuard::clean();
901        let config = UserConfig {
902            capture: UserCaptureConfig {
903                auto: UserAutoCaptureMode::Command,
904            },
905            ..UserConfig::default()
906        };
907
908        assert!(config.command_auto_capture_enabled().unwrap());
909    }
910
911    #[test]
912    fn command_auto_capture_env_overrides_user_config() {
913        let env = RemoteEnvGuard::clean();
914        env.set("HEDDLE_AUTO_CAPTURE", "off");
915        let config = UserConfig {
916            capture: UserCaptureConfig {
917                auto: UserAutoCaptureMode::Command,
918            },
919            ..UserConfig::default()
920        };
921
922        assert!(!config.command_auto_capture_enabled().unwrap());
923
924        env.set("HEDDLE_AUTO_CAPTURE", "command");
925        assert!(
926            UserConfig::default()
927                .command_auto_capture_enabled()
928                .unwrap()
929        );
930    }
931
932    #[test]
933    fn user_config_toml_parses_capture_auto_command() {
934        let parsed: UserConfig = toml::from_str(
935            r#"
936                [capture]
937                auto = "command"
938            "#,
939        )
940        .expect("capture auto config should parse");
941
942        assert_eq!(parsed.capture.auto, UserAutoCaptureMode::Command);
943    }
944
945    #[test]
946    fn hosted_runtime_config_absent_security_settings_uses_defaults() {
947        let _env = RemoteEnvGuard::clean();
948        let config = UserConfig::default()
949            .hosted_runtime_config(None)
950            .expect("absent optional settings should not error");
951
952        assert!(!config.tls_enabled);
953        assert!(!config.tls_skip_verify);
954        assert!(config.tls_ca_certificate_pem.is_none());
955        assert!(config.auth_proof_key_pem.is_none());
956        assert!(config.token.is_none());
957        assert!(config.descriptor_key_id.is_none());
958        assert!(config.descriptor_public_key.is_none());
959        assert_eq!(config.provider_global_concurrency, 4);
960        assert_eq!(config.provider_per_endpoint_concurrency, 2);
961        assert_eq!(config.provider_max_inflight_bytes, 8 * 1024 * 1024);
962        assert_eq!(config.provider_stall_timeout_secs, 15);
963    }
964
965    #[test]
966    fn hosted_runtime_config_loads_provider_knobs_from_user_config_and_env() {
967        let env = RemoteEnvGuard::clean();
968        env.set("HEDDLE_REMOTE_PROVIDER_GLOBAL_CONCURRENCY", "16");
969        env.set("HEDDLE_REMOTE_PROVIDER_MAX_INFLIGHT_BYTES", "33554432");
970        let user: UserConfig = toml::from_str(
971            r#"
972                [remote]
973                provider_global_concurrency = 8
974                provider_per_endpoint_concurrency = 4
975                provider_max_inflight_bytes = 16777216
976                provider_stall_timeout_secs = 30
977            "#,
978        )
979        .unwrap();
980
981        let config = user.hosted_runtime_config(None).unwrap();
982
983        assert_eq!(config.provider_global_concurrency, 16);
984        assert_eq!(config.provider_per_endpoint_concurrency, 4);
985        assert_eq!(config.provider_max_inflight_bytes, 32 * 1024 * 1024);
986        assert_eq!(config.provider_stall_timeout_secs, 30);
987    }
988
989    #[test]
990    fn hosted_runtime_config_rejects_invalid_provider_env_knob() {
991        let env = RemoteEnvGuard::clean();
992        env.set("HEDDLE_REMOTE_PROVIDER_STALL_TIMEOUT_SECS", "0");
993
994        let error = UserConfig::default()
995            .hosted_runtime_config(None)
996            .expect_err("zero provider timeout must be rejected");
997
998        assert!(
999            error
1000                .to_string()
1001                .contains("HEDDLE_REMOTE_PROVIDER_STALL_TIMEOUT_SECS")
1002        );
1003    }
1004
1005    #[test]
1006    fn hosted_runtime_config_loads_descriptor_trust_from_environment() {
1007        let env = RemoteEnvGuard::clean();
1008        env.set("HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID", "weft-current");
1009        env.set(
1010            "HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
1011            hex::encode([17; 32]),
1012        );
1013
1014        let config = UserConfig::default().hosted_runtime_config(None).unwrap();
1015
1016        assert_eq!(config.descriptor_key_id.as_deref(), Some("weft-current"));
1017        assert_eq!(config.descriptor_public_key, Some([17; 32]));
1018    }
1019
1020    #[test]
1021    fn hosted_runtime_config_rejects_key_only_environment_descriptor_trust() {
1022        let env = RemoteEnvGuard::clean();
1023        env.set("HEDDLE_REMOTE_IROH_DESCRIPTOR_KEY_ID", "weft-current");
1024
1025        let error = UserConfig::default()
1026            .hosted_runtime_config(None)
1027            .expect_err("partial descriptor trust must fail closed");
1028
1029        assert!(
1030            error
1031                .to_string()
1032                .contains("both non-empty descriptor trust")
1033        );
1034        assert!(error.to_string().contains("ambiguous security posture"));
1035    }
1036
1037    #[test]
1038    fn hosted_runtime_config_rejects_public_key_only_environment_descriptor_trust() {
1039        let env = RemoteEnvGuard::clean();
1040        env.set(
1041            "HEDDLE_REMOTE_IROH_DESCRIPTOR_PUBLIC_KEY",
1042            hex::encode([17; 32]),
1043        );
1044
1045        let error = UserConfig::default()
1046            .hosted_runtime_config(None)
1047            .expect_err("partial descriptor trust must fail closed");
1048
1049        assert!(
1050            error
1051                .to_string()
1052                .contains("both non-empty descriptor trust")
1053        );
1054        assert!(error.to_string().contains("ambiguous security posture"));
1055    }
1056
1057    #[test]
1058    fn hosted_runtime_config_rejects_key_only_file_descriptor_trust() {
1059        let _env = RemoteEnvGuard::clean();
1060        let user = UserConfig {
1061            remote: UserRemoteConfig {
1062                iroh_descriptor_key_id: Some("weft-current".to_string()),
1063                ..UserRemoteConfig::default()
1064            },
1065            ..UserConfig::default()
1066        };
1067
1068        let error = user
1069            .hosted_runtime_config(None)
1070            .expect_err("partial descriptor trust must fail closed");
1071
1072        assert!(error.to_string().contains("both descriptor trust fields"));
1073        assert!(error.to_string().contains("ambiguous security posture"));
1074    }
1075
1076    #[test]
1077    fn hosted_runtime_config_rejects_public_key_only_file_descriptor_trust() {
1078        let _env = RemoteEnvGuard::clean();
1079        let dir = unique_temp_path("heddle-user-config-partial-descriptor");
1080        fs::create_dir_all(&dir).expect("create temp dir");
1081        let public_key_path = dir.join("descriptor-public-key");
1082        fs::write(&public_key_path, hex::encode([17; 32])).expect("write descriptor public key");
1083        let user = UserConfig {
1084            remote: UserRemoteConfig {
1085                iroh_descriptor_public_key_path: Some(public_key_path),
1086                ..UserRemoteConfig::default()
1087            },
1088            ..UserConfig::default()
1089        };
1090
1091        let error = user
1092            .hosted_runtime_config(None)
1093            .expect_err("partial descriptor trust must fail closed");
1094
1095        assert!(error.to_string().contains("both descriptor trust fields"));
1096        assert!(error.to_string().contains("ambiguous security posture"));
1097        fs::remove_dir_all(dir).expect("remove temp dir");
1098    }
1099
1100    #[test]
1101    fn hosted_runtime_config_valid_security_files_are_applied() {
1102        let _env = RemoteEnvGuard::clean();
1103        let dir = unique_temp_path("heddle-user-config-valid-security");
1104        fs::create_dir_all(&dir).expect("create temp dir");
1105        let ca_path = dir.join("ca.pem");
1106        fs::write(&ca_path, "test ca pem").expect("write ca pem");
1107        let user = UserConfig {
1108            remote: UserRemoteConfig {
1109                tls_ca_certificate_path: Some(ca_path),
1110                ..UserRemoteConfig::default()
1111            },
1112            ..UserConfig::default()
1113        };
1114
1115        let config = user
1116            .hosted_runtime_config(None)
1117            .expect("valid TLS/auth files should load");
1118
1119        assert!(config.tls_enabled);
1120        assert_eq!(
1121            config.tls_ca_certificate_pem.as_deref(),
1122            Some("test ca pem")
1123        );
1124
1125        fs::remove_dir_all(dir).expect("remove temp dir");
1126    }
1127
1128    #[test]
1129    fn hosted_runtime_config_missing_tls_ca_path_fails_closed() {
1130        let _env = RemoteEnvGuard::clean();
1131        let missing = unique_temp_path("heddle-user-config-missing-ca").join("ca.pem");
1132        let user = UserConfig {
1133            remote: UserRemoteConfig {
1134                tls_ca_certificate_path: Some(missing),
1135                ..UserRemoteConfig::default()
1136            },
1137            ..UserConfig::default()
1138        };
1139
1140        let err = user
1141            .hosted_runtime_config(None)
1142            .expect_err("missing configured CA path must fail closed");
1143        let message = err.to_string();
1144
1145        assert!(message.contains("fatal TLS/auth configuration error"));
1146        assert!(message.contains("remote.tls_ca_certificate_path"));
1147    }
1148
1149    #[test]
1150    fn hosted_runtime_config_missing_env_tls_ca_path_fails_closed() {
1151        let env = RemoteEnvGuard::clean();
1152        let missing = unique_temp_path("heddle-user-config-missing-env-ca").join("ca.pem");
1153        env.set("HEDDLE_REMOTE_TLS_CA_CERT", missing);
1154
1155        let err = UserConfig::default()
1156            .hosted_runtime_config(None)
1157            .expect_err("missing env CA path must fail closed");
1158        let message = err.to_string();
1159
1160        assert!(message.contains("fatal TLS/auth configuration error"));
1161        assert!(message.contains("HEDDLE_REMOTE_TLS_CA_CERT"));
1162    }
1163
1164    #[test]
1165    fn hosted_runtime_config_invalid_env_tls_value_fails_closed() {
1166        let env = RemoteEnvGuard::clean();
1167        env.set("HEDDLE_REMOTE_TLS", "enabled");
1168
1169        let err = UserConfig::default()
1170            .hosted_runtime_config(None)
1171            .expect_err("invalid TLS env value must fail closed");
1172        let message = err.to_string();
1173
1174        assert!(message.contains("fatal TLS/auth configuration error"));
1175        assert!(message.contains("HEDDLE_REMOTE_TLS"));
1176    }
1177}