1use 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#[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#[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 #[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 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 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 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
626pub 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
657pub 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}