Skip to main content

mj_core/
config.rs

1//! Hel's versioned user configuration and domain model.
2//!
3//! This is intentionally a clean namespace. Nothing in this module reads or
4//! migrates the legacy `mj` configuration tree.
5
6use std::collections::{BTreeMap, BTreeSet};
7use std::ffi::OsString;
8use std::fs::{self, File, OpenOptions};
9use std::io::Write;
10use std::path::{Component, Path, PathBuf};
11
12use anyhow::{Context, Result, anyhow, bail};
13use serde::{Deserialize, Serialize};
14use sha2::{Digest, Sha256};
15
16/// Stable context identity for a bare project at the serialized path boundary.
17pub fn raw_project_context_id(project_directory: &str) -> String {
18    let digest = Sha256::digest(project_directory.trim().as_bytes());
19    let suffix = digest[..8]
20        .iter()
21        .map(|byte| format!("{byte:02x}"))
22        .collect::<String>();
23    format!("remote-project-{suffix}")
24}
25
26fn default_phone_bind() -> String {
27    "127.0.0.1:3765".to_owned()
28}
29
30const fn default_true() -> bool {
31    true
32}
33
34const fn is_true(value: &bool) -> bool {
35    *value
36}
37
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(deny_unknown_fields)]
40pub struct PhoneConfig {
41    #[serde(default = "default_true", skip_serializing_if = "is_true")]
42    pub enabled: bool,
43    #[serde(default = "default_phone_bind")]
44    pub bind: String,
45    #[serde(default = "default_true", skip_serializing_if = "is_true")]
46    pub tailscale_detect: bool,
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub tls_cert: Option<PathBuf>,
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub tls_key: Option<PathBuf>,
51}
52
53impl Default for PhoneConfig {
54    fn default() -> Self {
55        Self {
56            enabled: true,
57            bind: default_phone_bind(),
58            tailscale_detect: true,
59            tls_cert: None,
60            tls_key: None,
61        }
62    }
63}
64
65impl PhoneConfig {
66    fn validate(&self) -> Result<()> {
67        let bind: std::net::SocketAddr = self
68            .bind
69            .parse()
70            .with_context(|| format!("parse phone bind address {:?}", self.bind))?;
71        if self.tls_cert.is_some() != self.tls_key.is_some() {
72            bail!("phone TLS requires both `tls_cert` and `tls_key`");
73        }
74        if !bind.ip().is_loopback() && self.tls_cert.is_none() {
75            bail!("a non-loopback phone bind requires TLS");
76        }
77        Ok(())
78    }
79}
80
81/// Automatic cross-harness review of every completed coding turn.
82///
83/// Review is armed here, in the one file that belongs to the machine rather
84/// than to any surface: a session driven from a phone is reviewed on the same
85/// terms as one driven from the terminal, and the person who set it can see
86/// what they set. `profile` names a harness profile defined in this same file
87/// -- the reviewer runs under that profile, and it must not be the profile the
88/// session under review is using, or the "second opinion" is the same opinion.
89#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct ReviewConfig {
92    /// Whether every completed turn is reviewed automatically. A one-off
93    /// `/review` works whether or not this is set, as long as `profile` names
94    /// a reviewer.
95    #[serde(default, skip_serializing_if = "is_false")]
96    pub enabled: bool,
97    #[serde(default, skip_serializing_if = "is_default_tier")]
98    pub tier: crate::review::lanes::ReviewTier,
99    /// The harness profile the reviewing agents run under.
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub profile: Option<String>,
102    /// Model and effort applied to every reviewing role, when the reviewing
103    /// harness advertises such a selector. Absent means the profile's own
104    /// default, which is what most configurations want.
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub model: Option<String>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub effort: Option<String>,
109}
110
111const fn is_false(value: &bool) -> bool {
112    !*value
113}
114
115fn is_default_tier(tier: &crate::review::lanes::ReviewTier) -> bool {
116    *tier == crate::review::lanes::ReviewTier::default()
117}
118
119impl ReviewConfig {
120    fn is_default(&self) -> bool {
121        self == &Self::default()
122    }
123
124    /// Rejects a configuration that cannot review.
125    ///
126    /// Arming review without naming a reviewer is a configuration mistake with
127    /// no sensible default -- Mjolnir will not pick a profile on the user's behalf,
128    /// because which agent reviews is the most consequential review setting.
129    /// Naming a disabled profile is invalid because neither automatic nor
130    /// one-off reviews may start new work with it.
131    fn validate(&self, profiles: &BTreeMap<String, HarnessProfile>) -> Result<()> {
132        if let Some(profile_id) = self.profile.as_ref()
133            && let Some(profile) = profiles.get(profile_id)
134        {
135            if !profile.enabled {
136                bail!("[review] profile {profile_id:?} is disabled");
137            }
138            if !profile.kind.supports_injected_mcp() {
139                bail!(
140                    "Muse Code cannot be a reviewer because muse-acp does not accept the required MCP tools"
141                );
142            }
143        }
144        if self.enabled && self.profile.is_none() {
145            bail!(
146                "[review] enabled = true needs `profile` naming the harness profile that reviews"
147            );
148        }
149        if let Some(profile) = &self.profile
150            && !profiles.contains_key(profile)
151        {
152            bail!("[review] profile {profile:?} is not a profile defined in this config");
153        }
154        Ok(())
155    }
156
157    /// Whether a turn review can run at all: it needs a reviewer, armed or not.
158    #[must_use]
159    pub fn reviewer_profile(&self) -> Option<&str> {
160        self.profile.as_deref()
161    }
162}
163
164fn default_subagent_limit() -> usize {
165    6
166}
167
168fn is_default_subagent_limit(value: &usize) -> bool {
169    *value == default_subagent_limit()
170}
171
172/// Global policy for Mjolnir-managed child agents.
173#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(default, deny_unknown_fields)]
175pub struct SubagentConfig {
176    #[serde(default = "default_true", skip_serializing_if = "is_true")]
177    pub enabled: bool,
178    #[serde(
179        default = "default_subagent_limit",
180        skip_serializing_if = "is_default_subagent_limit"
181    )]
182    pub max_concurrent: usize,
183    /// Profiles available in addition to the parent's own enabled profile.
184    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
185    pub eligible_profiles: BTreeMap<String, bool>,
186}
187
188impl Default for SubagentConfig {
189    fn default() -> Self {
190        Self {
191            enabled: true,
192            max_concurrent: default_subagent_limit(),
193            eligible_profiles: BTreeMap::new(),
194        }
195    }
196}
197
198impl SubagentConfig {
199    fn is_default(&self) -> bool {
200        self == &Self::default()
201    }
202
203    fn validate(&self, profiles: &BTreeMap<String, HarnessProfile>) -> Result<()> {
204        if !(1..=64).contains(&self.max_concurrent) {
205            bail!("[subagents] `max_concurrent` must be between 1 and 64");
206        }
207        for profile_id in self
208            .eligible_profiles
209            .iter()
210            .filter_map(|(profile_id, eligible)| eligible.then_some(profile_id))
211        {
212            validate_id("sub-agent profile", profile_id)?;
213            match profiles.get(profile_id) {
214                Some(profile) if profile.enabled => {}
215                Some(_) => bail!("[subagents] eligible profile {profile_id:?} is disabled"),
216                None => bail!(
217                    "[subagents] eligible profile {profile_id:?} is not defined in this config"
218                ),
219            }
220        }
221        Ok(())
222    }
223
224    #[must_use]
225    pub fn profile_is_eligible(&self, parent: &str, candidate: &str) -> bool {
226        self.enabled
227            && (parent == candidate
228                || self
229                    .eligible_profiles
230                    .get(candidate)
231                    .copied()
232                    .unwrap_or(false))
233    }
234}
235
236pub const CONFIG_VERSION: u32 = 9;
237pub const PRODUCT_DIR: &str = "mjolnir";
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
240#[serde(rename_all = "kebab-case")]
241pub enum HarnessKind {
242    Codex,
243    Claude,
244    Kimi,
245    Grok,
246    Deepseek,
247    Muse,
248    Zcode,
249}
250
251/// The target-level execution policy Hel applies independently of the selected
252/// harness. Raw targets may preserve configured approvals; isolated targets
253/// force full access because their boundary contains the blast radius.
254#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum ExecutionPolicy {
257    /// Preserve the harness and profile's configured approval behavior.
258    ConfiguredApprovals,
259    /// Run every action without sandboxing or approval checks.
260    Unconstrained,
261}
262
263/// Approval behavior selected for a named raw SSH target.
264#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "lowercase")]
266pub enum PermissionMode {
267    /// Preserve the selected harness profile's approval behavior.
268    Guardian,
269    /// Run every action without sandboxing or approval checks.
270    Yolo,
271}
272
273impl PermissionMode {
274    pub const fn execution_policy(self) -> ExecutionPolicy {
275        match self {
276            Self::Guardian => ExecutionPolicy::ConfiguredApprovals,
277            Self::Yolo => ExecutionPolicy::Unconstrained,
278        }
279    }
280}
281
282impl ExecutionPolicy {
283    pub const fn is_unconstrained(self) -> bool {
284        matches!(self, Self::Unconstrained)
285    }
286}
287
288/// Harness-specific controls that collectively realize a target-level
289/// execution policy. A harness may need more than one launch-time mechanism
290/// in addition to an ACP mode.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub struct ExecutionEnforcement {
293    label: &'static str,
294    acp_mode: Option<&'static str>,
295    launch_flag: Option<&'static str>,
296    launch_environment: Option<(&'static str, &'static str)>,
297}
298
299impl ExecutionEnforcement {
300    /// Name reported to the UI for the mode this session runs in.
301    pub const fn label(self) -> &'static str {
302        self.label
303    }
304
305    /// The ACP mode to select after the session opens, when there is one.
306    pub const fn acp_mode(self) -> Option<&'static str> {
307        self.acp_mode
308    }
309
310    /// The launch flag to add to the bridge command line, when there is one.
311    pub const fn launch_flag(self) -> Option<&'static str> {
312        self.launch_flag
313    }
314
315    pub const fn launch_environment(self) -> Option<(&'static str, &'static str)> {
316        self.launch_environment
317    }
318}
319
320/// The file inside a harness home that proves the harness is logged in.
321///
322/// Setup, quota checks, credential sync, and the target-side worker all read
323/// the same path, so it is decided here beside [`HarnessKind`] rather than in
324/// any one of them.
325pub fn harness_authentication_marker(kind: HarnessKind, home: &Path) -> PathBuf {
326    home.join(match kind {
327        HarnessKind::Codex => "auth.json",
328        HarnessKind::Claude => ".credentials.json",
329        HarnessKind::Kimi => "credentials/kimi-code.json",
330        HarnessKind::Grok => "auth.json",
331        HarnessKind::Deepseek => ".credentials.yaml",
332        HarnessKind::Muse => "auth.json",
333        HarnessKind::Zcode => "v2/config.json",
334    })
335}
336
337/// The Muse permission profile staged for unconstrained targets.
338///
339/// Muse 1.2.1 composes a session's permission profile from
340/// `permissions.default_profile` in its settings file, and nothing on the ACP
341/// wire overrides that choice. A container target must therefore have the
342/// profile written into the staged settings, or Muse refuses the session when
343/// the configured profile needs a facility the container does not have.
344pub const MUSE_UNCONSTRAINED_PERMISSION_PROFILE: &str = ":unrestricted";
345
346impl HarnessKind {
347    /// Translate a harness home into its process environment. Muse's config
348    /// directory must be named `muse`, as required by the XDG directory layout.
349    pub fn configure_home_environment(
350        self,
351        home: &Path,
352        environment: &mut BTreeMap<String, String>,
353    ) {
354        let config_root = if self == Self::Muse {
355            environment.insert(
356                "XDG_DATA_HOME".into(),
357                home.join(".data").to_string_lossy().into_owned(),
358            );
359            home.parent().unwrap_or(home)
360        } else if self == Self::Zcode {
361            environment.insert("ZCODE_HOME".into(), home.to_string_lossy().into_owned());
362            home.parent().unwrap_or(home)
363        } else {
364            home
365        };
366        environment.insert(
367            self.home_env().into(),
368            config_root.to_string_lossy().into_owned(),
369        );
370    }
371
372    pub fn home_from_environment(self, value: impl AsRef<Path>) -> PathBuf {
373        if self == Self::Muse {
374            value.as_ref().join("muse")
375        } else {
376            value.as_ref().to_path_buf()
377        }
378    }
379
380    pub const fn supports_injected_mcp(self) -> bool {
381        !matches!(self, Self::Muse)
382    }
383
384    pub const ALL: [Self; 7] = [
385        Self::Codex,
386        Self::Claude,
387        Self::Kimi,
388        Self::Grok,
389        Self::Deepseek,
390        Self::Muse,
391        Self::Zcode,
392    ];
393
394    /// Environment variable used to isolate this harness's configuration.
395    pub const fn home_env(self) -> &'static str {
396        match self {
397            Self::Codex => "CODEX_HOME",
398            Self::Claude => "CLAUDE_CONFIG_DIR",
399            Self::Kimi => "KIMI_CODE_HOME",
400            Self::Grok => "GROK_HOME",
401            Self::Deepseek => "DSH_HOME",
402            Self::Muse => "XDG_CONFIG_HOME",
403            Self::Zcode => "ZCODE_DATA_BASE_DIR",
404        }
405    }
406
407    /// Directory beneath the user's home the harness uses when `home_env` is
408    /// unset. The single source for both setup discovery and import.
409    pub const fn default_home_leaf(self) -> &'static str {
410        match self {
411            Self::Codex => ".codex",
412            Self::Claude => ".claude",
413            Self::Kimi => ".kimi-code",
414            Self::Grok => ".grok",
415            Self::Deepseek => ".dsh",
416            Self::Muse => ".config/muse",
417            Self::Zcode => ".zcode",
418        }
419    }
420
421    /// Lowercase stable identifier used in config, storage, and the HTTP API.
422    pub const fn id(self) -> &'static str {
423        match self {
424            Self::Codex => "codex",
425            Self::Claude => "claude",
426            Self::Kimi => "kimi",
427            Self::Grok => "grok",
428            Self::Deepseek => "deepseek",
429            Self::Muse => "muse",
430            Self::Zcode => "zcode",
431        }
432    }
433
434    /// Product name shown to people.
435    pub const fn display_name(self) -> &'static str {
436        match self {
437            Self::Codex => "Codex",
438            Self::Claude => "Claude Code",
439            Self::Kimi => "Kimi Code",
440            Self::Grok => "Grok Build",
441            Self::Deepseek => "DSH",
442            Self::Muse => "Muse Code",
443            Self::Zcode => "ZCode",
444        }
445    }
446
447    /// How this harness realizes a target-level execution policy. Configured
448    /// approvals preserve harness configuration; Codex selects guardian explicitly.
449    pub const fn execution_enforcement(
450        self,
451        policy: ExecutionPolicy,
452    ) -> Option<ExecutionEnforcement> {
453        match (self, policy) {
454            (Self::Muse, ExecutionPolicy::Unconstrained) => Some(ExecutionEnforcement {
455                label: "allowAll / sandbox-off",
456                acp_mode: Some("allowAll"),
457                launch_flag: None,
458                launch_environment: Some(("MUSE_APPROVAL_MODE", "allowAll")),
459            }),
460            (Self::Codex, ExecutionPolicy::ConfiguredApprovals) => Some(ExecutionEnforcement {
461                label: "agent / guardian",
462                acp_mode: Some("agent"),
463                launch_flag: None,
464                launch_environment: Some(("INITIAL_AGENT_MODE", "agent")),
465            }),
466            (Self::Codex, ExecutionPolicy::Unconstrained) => Some(ExecutionEnforcement {
467                label: "agent-full-access",
468                acp_mode: Some("agent-full-access"),
469                launch_flag: None,
470                launch_environment: Some(("INITIAL_AGENT_MODE", "agent-full-access")),
471            }),
472            (Self::Zcode, ExecutionPolicy::ConfiguredApprovals) => Some(ExecutionEnforcement {
473                label: "build / guardian",
474                acp_mode: Some("build"),
475                launch_flag: None,
476                launch_environment: Some(("ZCODE_ACP_MODE", "build")),
477            }),
478            (Self::Zcode, ExecutionPolicy::Unconstrained) => Some(ExecutionEnforcement {
479                label: "yolo",
480                acp_mode: Some("yolo"),
481                launch_flag: None,
482                launch_environment: Some(("ZCODE_ACP_MODE", "yolo")),
483            }),
484            (_, ExecutionPolicy::ConfiguredApprovals) => None,
485            (Self::Claude, ExecutionPolicy::Unconstrained) => Some(ExecutionEnforcement {
486                label: "bypassPermissions / sandbox-off",
487                acp_mode: Some("bypassPermissions"),
488                launch_flag: None,
489                launch_environment: None,
490            }),
491            (Self::Kimi, ExecutionPolicy::Unconstrained) => Some(ExecutionEnforcement {
492                label: "auto",
493                acp_mode: Some("auto"),
494                launch_flag: None,
495                launch_environment: None,
496            }),
497            (Self::Grok, ExecutionPolicy::Unconstrained) => Some(ExecutionEnforcement {
498                label: "always-approve / sandbox-off",
499                acp_mode: None,
500                launch_flag: Some("--always-approve"),
501                launch_environment: Some(("GROK_SANDBOX", "off")),
502            }),
503            (Self::Deepseek, ExecutionPolicy::Unconstrained) => Some(ExecutionEnforcement {
504                label: "danger-full-access",
505                acp_mode: None,
506                launch_flag: None,
507                launch_environment: Some(("DSH_PERMISSION_MODE", "danger-full-access")),
508            }),
509        }
510    }
511
512    /// Apply the launch environment required to realize `policy`. The
513    /// controller writes this into new launch configs, and the worker repeats
514    /// it so persisted configs from older Hel versions acquire the same
515    /// enforcement after an upgrade.
516    pub fn configure_execution_environment(
517        self,
518        policy: ExecutionPolicy,
519        environment: &mut BTreeMap<String, String>,
520    ) -> Result<()> {
521        if self == Self::Muse && policy == ExecutionPolicy::Unconstrained {
522            let args = environment.entry("MUSE_SERVE_ARGS".into()).or_default();
523            if !args
524                .split_whitespace()
525                .any(|arg| arg == "--disable-sandbox")
526            {
527                args.push_str(" --disable-sandbox");
528            }
529        }
530        if let Some((key, value)) = self
531            .execution_enforcement(policy)
532            .and_then(ExecutionEnforcement::launch_environment)
533        {
534            environment.insert(key.to_owned(), value.to_owned());
535        }
536        Ok(())
537    }
538
539    /// Whether a checkpoint can capture per-session harness files for this
540    /// harness. ZCode keeps all conversations in one shared live SQLite
541    /// database, so its checkpoints carry repository state only; other
542    /// harnesses have per-session files that the checkpoint captures and
543    /// restores.
544    pub const fn captures_native_session(self) -> bool {
545        !matches!(self, Self::Zcode)
546    }
547
548    pub const fn supports_guardian_approvals(self) -> bool {
549        matches!(
550            self,
551            Self::Codex | Self::Claude | Self::Grok | Self::Muse | Self::Zcode
552        )
553    }
554
555    /// Shared warning for selecting a harness without guardian approvals on a
556    /// raw target. Containers and remote instances run unconstrained by design
557    /// and rely on target isolation instead.
558    pub fn unsandboxed_guardian_warning(self) -> Option<String> {
559        (!self.supports_guardian_approvals()).then(|| {
560            format!(
561                "DANGER: {} has no guardian approval mode. Do not run it on a raw, unsandboxed target.",
562                self.display_name()
563            )
564        })
565    }
566
567    /// The launch flag the bridge command line carries, if any.
568    ///
569    pub const fn launch_flag_for(self, policy: ExecutionPolicy) -> Option<&'static str> {
570        match self.execution_enforcement(policy) {
571            Some(enforcement) => enforcement.launch_flag(),
572            None => None,
573        }
574    }
575
576    /// Harness-specific arguments that start its ACP stdio server.
577    pub fn bridge_args(self, policy: ExecutionPolicy) -> Vec<&'static str> {
578        let flag = self.launch_flag_for(policy);
579        match self {
580            Self::Codex | Self::Claude | Self::Muse | Self::Zcode => Vec::new(),
581            Self::Deepseek => vec!["--profile", "acp"],
582            Self::Kimi => vec!["acp"],
583            Self::Grok => ["agent"].into_iter().chain(flag).chain(["stdio"]).collect(),
584        }
585    }
586}
587
588impl std::str::FromStr for HarnessKind {
589    type Err = anyhow::Error;
590
591    fn from_str(value: &str) -> Result<Self> {
592        Self::ALL
593            .into_iter()
594            .find(|kind| kind.id() == value)
595            .ok_or_else(|| anyhow!("unknown harness kind {value:?}"))
596    }
597}
598
599#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
600#[serde(deny_unknown_fields)]
601pub struct HarnessProfile {
602    /// Whether Mjolnir may select this profile for new work or probe it.
603    #[serde(default = "default_true", skip_serializing_if = "is_true")]
604    pub enabled: bool,
605    pub kind: HarnessKind,
606    /// Controller-side source home. A fresh copy is made for each target.
607    pub home: PathBuf,
608    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
609    pub environment: BTreeMap<String, String>,
610    /// Conservative byte budget for cross-harness transcript compaction.
611    /// Bytes avoid pretending Hel has an accurate tokenizer for every model.
612    #[serde(default, skip_serializing_if = "Option::is_none")]
613    pub context_window_bytes: Option<usize>,
614}
615
616impl HarnessProfile {
617    /// Discovery identifies an installation independently of user settings.
618    pub fn same_installation(&self, other: &Self) -> bool {
619        self.kind == other.kind && self.home == other.home
620    }
621
622    pub fn home_env(&self) -> &'static str {
623        self.kind.home_env()
624    }
625
626    pub fn execution_enforcement(&self, policy: ExecutionPolicy) -> Option<ExecutionEnforcement> {
627        self.kind.execution_enforcement(policy)
628    }
629
630    fn validate(&self, id: &str) -> Result<()> {
631        validate_id("profile", id)?;
632        if self.kind == HarnessKind::Muse {
633            if self.home.file_name().is_none_or(|name| name != "muse") {
634                bail!(
635                    "Muse profile {id:?} home must end in /muse (its XDG configuration directory)"
636                );
637            }
638            if self.environment.contains_key("XDG_DATA_HOME") {
639                bail!("Muse profile {id:?} must not override its managed XDG_DATA_HOME");
640            }
641        }
642        if self.home.as_os_str().is_empty() {
643            bail!("profile {id:?} has an empty home path");
644        }
645        if self
646            .environment
647            .keys()
648            .any(|key| key.trim().is_empty() || key.contains('='))
649        {
650            bail!("profile {id:?} contains an invalid environment variable name");
651        }
652        if self.environment.contains_key(self.kind.home_env()) {
653            bail!(
654                "profile {id:?} must use `home`, not override {} in `environment`",
655                self.kind.home_env()
656            );
657        }
658        if self
659            .context_window_bytes
660            .is_some_and(|bytes| bytes < 32 * 1024)
661        {
662            bail!("profile {id:?}: `context_window_bytes` must be at least 32768");
663        }
664        Ok(())
665    }
666}
667
668#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
669#[serde(deny_unknown_fields)]
670pub struct ProjectRepository {
671    /// Stable name within the bundle, used by `primary_repo`.
672    pub id: String,
673    /// GitHub HTTPS or SSH URL (or `owner/repository` shorthand).
674    #[serde(default, skip_serializing_if = "Option::is_none")]
675    pub github: Option<String>,
676    /// Controller-side repository whose default network remotes seed isolated sessions.
677    #[serde(default, skip_serializing_if = "Option::is_none")]
678    pub local: Option<PathBuf>,
679    /// Safe relative path beneath the target's bundle root.
680    pub destination: PathBuf,
681    #[serde(default, skip_serializing_if = "Option::is_none")]
682    pub git_ref: Option<String>,
683}
684
685#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
686#[serde(deny_unknown_fields)]
687pub struct ProjectBundle {
688    /// Repository id used as the ACP session cwd.
689    pub primary_repo: String,
690    pub repositories: Vec<ProjectRepository>,
691}
692
693impl ProjectBundle {
694    fn validate(&self, bundle_id: &str) -> Result<()> {
695        validate_id("bundle", bundle_id)?;
696        if self.repositories.is_empty() {
697            bail!("bundle {bundle_id:?} must contain at least one repository");
698        }
699
700        let mut ids = BTreeSet::new();
701        let mut destinations = Vec::<PathBuf>::new();
702        for repository in &self.repositories {
703            validate_id("repository", &repository.id)
704                .with_context(|| format!("bundle {bundle_id:?}"))?;
705            if !ids.insert(repository.id.as_str()) {
706                bail!(
707                    "bundle {bundle_id:?} contains duplicate repository id {:?}",
708                    repository.id
709                );
710            }
711            if repository.github.is_some() == repository.local.is_some() {
712                bail!(
713                    "bundle {bundle_id:?} repository {:?} must declare exactly one of `github` or `local`",
714                    repository.id,
715                );
716            }
717            if repository
718                .github
719                .as_deref()
720                .is_some_and(|source| !is_github_source(source))
721            {
722                bail!(
723                    "bundle {bundle_id:?} repository {:?} is not a supported GitHub source",
724                    repository.id,
725                );
726            }
727            if repository
728                .local
729                .as_deref()
730                .is_some_and(|path| !path.is_absolute())
731            {
732                bail!(
733                    "bundle {bundle_id:?} repository {:?} local path must be absolute",
734                    repository.id,
735                );
736            }
737            if repository.git_ref.is_some() {
738                bail!(
739                    "bundle {bundle_id:?} repository {:?}: git_ref is no longer supported; remove it to start from the remote's default branch",
740                    repository.id
741                );
742            }
743            validate_relative_destination(&repository.destination).with_context(|| {
744                format!(
745                    "bundle {bundle_id:?} repository {:?} destination",
746                    repository.id
747                )
748            })?;
749            if let Some(existing) = destinations.iter().find(|existing| {
750                repository.destination.starts_with(existing)
751                    || existing.starts_with(&repository.destination)
752            }) {
753                bail!(
754                    "bundle {bundle_id:?} contains overlapping destinations {} and {}",
755                    existing.display(),
756                    repository.destination.display()
757                );
758            }
759            destinations.push(repository.destination.clone());
760        }
761        if !ids.contains(self.primary_repo.as_str()) {
762            bail!(
763                "bundle {bundle_id:?} primary repository {:?} does not exist",
764                self.primary_repo
765            );
766        }
767        Ok(())
768    }
769
770    pub fn primary(&self) -> Option<&ProjectRepository> {
771        self.repositories
772            .iter()
773            .find(|repository| repository.id == self.primary_repo)
774    }
775}
776
777impl ProjectRepository {
778    pub fn source_label(&self) -> String {
779        self.github
780            .clone()
781            .or_else(|| self.local.as_ref().map(|path| path.display().to_string()))
782            .unwrap_or_else(|| "invalid repository source".into())
783    }
784
785    pub fn is_local(&self) -> bool {
786        self.local.is_some()
787    }
788}
789
790#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
791#[serde(deny_unknown_fields)]
792pub struct ContainerTemplate {
793    pub image: String,
794    #[serde(default, skip_serializing_if = "ImagePullPolicy::is_auto")]
795    pub pull_policy: ImagePullPolicy,
796    #[serde(default, skip_serializing_if = "Option::is_none")]
797    pub platform: Option<String>,
798    #[serde(default, skip_serializing_if = "Option::is_none")]
799    pub cpus: Option<String>,
800    #[serde(default, skip_serializing_if = "Option::is_none")]
801    pub memory: Option<String>,
802    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
803    pub environment: BTreeMap<String, String>,
804    #[serde(default, skip_serializing_if = "PodmanWorkspaceStorage::is_default")]
805    pub workspace_storage: PodmanWorkspaceStorage,
806}
807
808#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
809#[serde(tag = "kind", rename_all = "kebab-case", deny_unknown_fields)]
810pub enum PodmanWorkspaceStorage {
811    #[default]
812    PodmanVolume,
813    HostHelper {
814        root: PathBuf,
815        helper: Vec<String>,
816    },
817    ContainerLayer,
818}
819
820impl PodmanWorkspaceStorage {
821    fn is_default(&self) -> bool {
822        matches!(self, Self::PodmanVolume)
823    }
824
825    fn validate(&self, template_id: &str) -> Result<()> {
826        let Self::HostHelper { root, helper } = self else {
827            return Ok(());
828        };
829        if !root.is_absolute() {
830            bail!("target template {template_id:?} workspace storage root must be absolute");
831        }
832        if helper.is_empty() || helper.iter().any(|argument| argument.is_empty()) {
833            bail!(
834                "target template {template_id:?} workspace storage helper must contain non-empty arguments"
835            );
836        }
837        Ok(())
838    }
839}
840
841#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
842#[serde(rename_all = "kebab-case")]
843pub enum ImagePullPolicy {
844    #[default]
845    Auto,
846    Always,
847    Newer,
848    Missing,
849    Never,
850}
851
852impl ImagePullPolicy {
853    fn is_auto(&self) -> bool {
854        *self == Self::Auto
855    }
856}
857
858impl ContainerTemplate {
859    fn validate(&self, template_id: &str) -> Result<()> {
860        if self.image.trim().is_empty() {
861            bail!("target template {template_id:?} has an empty container image");
862        }
863        validate_environment(template_id, &self.environment)?;
864        Ok(())
865    }
866}
867
868#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
869#[serde(rename_all = "kebab-case")]
870pub enum AwsAddressSource {
871    #[default]
872    PublicDns,
873    PublicIp,
874    PrivateDns,
875    PrivateIp,
876}
877
878#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
879#[serde(deny_unknown_fields)]
880pub struct SshConnection {
881    /// OpenSSH destination such as `builder.example.com` or an SSH config alias.
882    pub host: String,
883    #[serde(default, skip_serializing_if = "Option::is_none")]
884    pub user: Option<String>,
885    #[serde(default, skip_serializing_if = "Option::is_none")]
886    pub identity_file: Option<PathBuf>,
887    #[serde(default, skip_serializing_if = "Vec::is_empty")]
888    pub extra_args: Vec<String>,
889}
890
891impl SshConnection {
892    fn validate(&self, template_id: &str) -> Result<()> {
893        if self.host.trim().is_empty() || self.host.chars().any(char::is_whitespace) {
894            bail!("target template {template_id:?} has an invalid SSH host");
895        }
896        if self.user.as_deref().is_some_and(|user| {
897            user.is_empty() || user.chars().any(|c| c.is_whitespace() || c == '@')
898        }) {
899            bail!("target template {template_id:?} has an invalid SSH user");
900        }
901        Ok(())
902    }
903}
904
905fn default_named_machine_prefix() -> PathBuf {
906    PathBuf::from(".local/share/hel/workspaces")
907}
908
909#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
910#[serde(tag = "kind", rename_all = "kebab-case")]
911pub enum TargetTemplate {
912    LocalBare,
913    LocalPodman {
914        #[serde(flatten)]
915        container: ContainerTemplate,
916    },
917    LocalDocker {
918        #[serde(flatten)]
919        container: ContainerTemplate,
920    },
921    AppleContainer {
922        #[serde(flatten)]
923        container: ContainerTemplate,
924    },
925    AwsEc2 {
926        #[serde(default, skip_serializing_if = "Option::is_none")]
927        aws_profile: Option<String>,
928        region: String,
929        launch_template: String,
930        #[serde(default, skip_serializing_if = "Option::is_none")]
931        launch_template_version: Option<String>,
932        ssh_user: String,
933        #[serde(default)]
934        address_source: AwsAddressSource,
935        #[serde(default, skip_serializing_if = "Option::is_none")]
936        identity_file: Option<PathBuf>,
937        #[serde(default, skip_serializing_if = "Vec::is_empty")]
938        ssh_args: Vec<String>,
939    },
940    SshBare {
941        #[serde(flatten)]
942        ssh: SshConnection,
943        permissions: PermissionMode,
944        #[serde(default = "default_named_machine_prefix")]
945        workspace_prefix: PathBuf,
946    },
947    SshPodman {
948        #[serde(flatten)]
949        ssh: SshConnection,
950        #[serde(flatten)]
951        container: ContainerTemplate,
952    },
953    SshDocker {
954        #[serde(flatten)]
955        ssh: SshConnection,
956        #[serde(flatten)]
957        container: ContainerTemplate,
958    },
959}
960
961impl TargetTemplate {
962    pub const fn execution_policy(&self) -> ExecutionPolicy {
963        match self {
964            Self::LocalBare => ExecutionPolicy::ConfiguredApprovals,
965            Self::SshBare { permissions, .. } => permissions.execution_policy(),
966            _ => ExecutionPolicy::Unconstrained,
967        }
968    }
969
970    pub const fn permission_mode(&self) -> Option<PermissionMode> {
971        match self {
972            Self::SshBare { permissions, .. } => Some(*permissions),
973            _ => None,
974        }
975    }
976
977    fn validate(&self, id: &str) -> Result<()> {
978        validate_id("target template", id)?;
979        match self {
980            Self::LocalBare => Ok(()),
981            Self::LocalPodman { container } => {
982                container.validate(id)?;
983                container.workspace_storage.validate(id)
984            }
985            Self::LocalDocker { container } | Self::AppleContainer { container } => {
986                container.validate(id)?;
987                if !container.workspace_storage.is_default() {
988                    bail!("target template {id:?} workspace storage is only supported by Podman");
989                }
990                Ok(())
991            }
992            Self::AwsEc2 {
993                aws_profile,
994                region,
995                launch_template,
996                launch_template_version,
997                ssh_user,
998                ..
999            } => {
1000                if region.trim().is_empty()
1001                    || launch_template.trim().is_empty()
1002                    || ssh_user.trim().is_empty()
1003                {
1004                    bail!(
1005                        "AWS target template {id:?} requires region, launch_template, and ssh_user"
1006                    );
1007                }
1008                if aws_profile.as_deref().is_some_and(str::is_empty)
1009                    || launch_template_version
1010                        .as_deref()
1011                        .is_some_and(str::is_empty)
1012                {
1013                    bail!("AWS target template {id:?} contains an empty optional value");
1014                }
1015                Ok(())
1016            }
1017            Self::SshBare {
1018                ssh,
1019                workspace_prefix,
1020                ..
1021            } => {
1022                ssh.validate(id)?;
1023                if workspace_prefix.as_os_str().is_empty()
1024                    || workspace_prefix
1025                        .components()
1026                        .any(|part| part == Component::ParentDir)
1027                    || matches!(workspace_prefix.to_str(), Some("/" | "." | "~" | "~/"))
1028                {
1029                    bail!("target template {id:?} has an unsafe workspace prefix");
1030                }
1031                Ok(())
1032            }
1033            Self::SshDocker { ssh, container } => {
1034                ssh.validate(id)?;
1035                container.validate(id)?;
1036                if !container.workspace_storage.is_default() {
1037                    bail!("target template {id:?} workspace storage is only supported by Podman");
1038                }
1039                Ok(())
1040            }
1041            Self::SshPodman { ssh, container, .. } => {
1042                ssh.validate(id)?;
1043                container.validate(id)?;
1044                container.workspace_storage.validate(id)
1045            }
1046        }
1047    }
1048}
1049
1050/// Whether `template` hosts a raw project checkout directly on its machine,
1051/// with no managed workspace. Bare targets take a project directory instead
1052/// of a bundle.
1053pub fn is_bare_project_target(template: &TargetTemplate) -> bool {
1054    matches!(
1055        template,
1056        TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
1057    )
1058}
1059
1060/// The host name that prompt-history mounts on `template` should be filed
1061/// under, or `None` if the target does not support attached mounts.
1062pub fn mount_history_host(template: &TargetTemplate) -> Option<&str> {
1063    match template {
1064        TargetTemplate::LocalPodman { .. }
1065        | TargetTemplate::LocalDocker { .. }
1066        | TargetTemplate::AppleContainer { .. }
1067        | TargetTemplate::AwsEc2 { .. } => Some("local"),
1068        TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
1069            Some(&ssh.host)
1070        }
1071        TargetTemplate::LocalBare | TargetTemplate::SshBare { .. } => None,
1072    }
1073}
1074
1075/// The host key whose project-directory history belongs to `template`.
1076/// Unlike mount history, raw bare targets keep a recent project checkout list
1077/// because their launch wizard selects a directory instead of an attachment.
1078pub fn project_history_host(template: &TargetTemplate) -> Option<&str> {
1079    match template {
1080        TargetTemplate::LocalBare => Some("local"),
1081        TargetTemplate::SshBare { ssh, .. } => Some(&ssh.host),
1082        TargetTemplate::LocalPodman { .. }
1083        | TargetTemplate::LocalDocker { .. }
1084        | TargetTemplate::AppleContainer { .. }
1085        | TargetTemplate::AwsEc2 { .. }
1086        | TargetTemplate::SshPodman { .. }
1087        | TargetTemplate::SshDocker { .. } => None,
1088    }
1089}
1090
1091/// Stable physical-host key for reusable container CPU and memory defaults.
1092pub fn container_size_host(template: &TargetTemplate) -> Option<&str> {
1093    match template {
1094        TargetTemplate::LocalPodman { .. }
1095        | TargetTemplate::LocalDocker { .. }
1096        | TargetTemplate::AppleContainer { .. } => Some("local"),
1097        TargetTemplate::SshPodman { ssh, .. } | TargetTemplate::SshDocker { ssh, .. } => {
1098            Some(&ssh.host)
1099        }
1100        TargetTemplate::LocalBare
1101        | TargetTemplate::SshBare { .. }
1102        | TargetTemplate::AwsEc2 { .. } => None,
1103    }
1104}
1105
1106fn validate_environment(owner: &str, environment: &BTreeMap<String, String>) -> Result<()> {
1107    if environment
1108        .keys()
1109        .any(|key| key.trim().is_empty() || key.contains('='))
1110    {
1111        bail!("{owner:?} contains an invalid environment variable name");
1112    }
1113    Ok(())
1114}
1115
1116// Old config files may still contain [startup]. Accept it without retaining
1117// settings that could recreate automatic first-session behavior on save.
1118fn discard_legacy_startup<'de, D: serde::Deserializer<'de>>(
1119    deserializer: D,
1120) -> std::result::Result<(), D::Error> {
1121    serde::de::IgnoredAny::deserialize(deserializer).map(|_| ())
1122}
1123
1124#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
1125#[serde(rename_all = "kebab-case")]
1126pub enum SpinnerStyle {
1127    /// A bright dot glides across a faint row (typing-indicator feel).
1128    Pulse,
1129    /// An undulating braille ribbon rolls across the strip.
1130    Wave,
1131    /// Vertical bars bounce like an audio equalizer.
1132    Bars,
1133    /// The whole row breathes brightness in unison (calmest).
1134    Shimmer,
1135    /// A lit sphere rotates in place, carrying its dark side into view.
1136    Globe,
1137    /// A lit head sweeps to one wall and back, trailing a fading tail.
1138    #[default]
1139    Scan,
1140}
1141
1142impl SpinnerStyle {
1143    pub const ALL: [Self; 6] = [
1144        Self::Pulse,
1145        Self::Wave,
1146        Self::Bars,
1147        Self::Shimmer,
1148        Self::Globe,
1149        Self::Scan,
1150    ];
1151
1152    pub fn as_str(self) -> &'static str {
1153        match self {
1154            Self::Pulse => "pulse",
1155            Self::Wave => "wave",
1156            Self::Bars => "bars",
1157            Self::Shimmer => "shimmer",
1158            Self::Globe => "globe",
1159            Self::Scan => "scan",
1160        }
1161    }
1162
1163    pub fn is_default(&self) -> bool {
1164        *self == Self::default()
1165    }
1166
1167    /// Next animation in the command palette's stable cycle.
1168    pub fn next(self) -> Self {
1169        let index = Self::ALL
1170            .iter()
1171            .position(|style| *style == self)
1172            .unwrap_or(0);
1173        Self::ALL[(index + 1) % Self::ALL.len()]
1174    }
1175}
1176
1177impl std::fmt::Display for SpinnerStyle {
1178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1179        f.write_str(self.as_str())
1180    }
1181}
1182
1183impl std::str::FromStr for SpinnerStyle {
1184    type Err = String;
1185
1186    fn from_str(value: &str) -> Result<Self, Self::Err> {
1187        match value {
1188            "pulse" => Ok(Self::Pulse),
1189            "wave" => Ok(Self::Wave),
1190            "bars" => Ok(Self::Bars),
1191            "shimmer" => Ok(Self::Shimmer),
1192            "globe" => Ok(Self::Globe),
1193            "scan" => Ok(Self::Scan),
1194            _ => Err(format!(
1195                "unknown spinner {value:?}; expected one of: {}",
1196                Self::ALL
1197                    .iter()
1198                    .map(|style| style.as_str())
1199                    .collect::<Vec<_>>()
1200                    .join(", ")
1201            )),
1202        }
1203    }
1204}
1205
1206/// Color palette for the terminal dashboard and conversation.
1207#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1208#[serde(rename_all = "kebab-case")]
1209pub enum UiTheme {
1210    #[default]
1211    Midnight,
1212    Light,
1213    #[serde(rename = "darcula", alias = "dracula")]
1214    Darcula,
1215    HighContrast,
1216}
1217
1218impl UiTheme {
1219    pub const ALL: [Self; 4] = [
1220        Self::Midnight,
1221        Self::Light,
1222        Self::Darcula,
1223        Self::HighContrast,
1224    ];
1225
1226    pub fn label(self) -> &'static str {
1227        match self {
1228            Self::Midnight => "Midnight",
1229            Self::Light => "Light",
1230            Self::Darcula => "Darcula",
1231            Self::HighContrast => "High Contrast",
1232        }
1233    }
1234
1235    fn is_default(&self) -> bool {
1236        *self == Self::default()
1237    }
1238}
1239
1240#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1241#[serde(rename_all = "lowercase")]
1242pub enum SessionsSide {
1243    #[default]
1244    Left,
1245    Right,
1246}
1247
1248impl SessionsSide {
1249    fn is_default(&self) -> bool {
1250        *self == Self::default()
1251    }
1252}
1253
1254/// Settings that are useful while diagnosing or tuning the client surface.
1255///
1256/// The section is optional on disk so configurations written before it was
1257/// introduced retain their existing representation and behavior.
1258#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1259#[serde(default)]
1260pub struct AdvancedConfig {
1261    #[serde(skip_serializing_if = "is_false")]
1262    pub detailed_activity_clocks: bool,
1263    #[serde(skip_serializing_if = "is_false")]
1264    pub show_stopped_sessions: bool,
1265}
1266
1267impl AdvancedConfig {
1268    fn is_default(&self) -> bool {
1269        self == &Self::default()
1270    }
1271}
1272
1273#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1274#[serde(deny_unknown_fields)]
1275pub struct Config {
1276    /// Deprecated session filtering preference retained for read compatibility.
1277    /// It is ignored and omitted from newly written configurations.
1278    #[serde(default, skip_serializing)]
1279    pub show_stopped_sessions: bool,
1280    #[serde(default, skip_serializing_if = "SessionsSide::is_default")]
1281    pub sessions_side: SessionsSide,
1282    #[serde(default, skip_serializing_if = "AdvancedConfig::is_default")]
1283    pub advanced: AdvancedConfig,
1284    pub version: u32,
1285    /// The version found on disk when it was above this build's
1286    /// [`CONFIG_VERSION`]. Such a config loads best-effort so its settings
1287    /// still work, and it is read-only: [`Config::save_to`] refuses, so an
1288    /// older Hel never overwrites a file a newer Mjolnir maintains.
1289    #[serde(skip)]
1290    pub newer_config_version: Option<u32>,
1291    /// Client-side activity animation; omitted configurations retain the classic scan.
1292    #[serde(default, skip_serializing_if = "SpinnerStyle::is_default")]
1293    pub spinner: SpinnerStyle,
1294    #[serde(default, skip_serializing_if = "UiTheme::is_default")]
1295    pub theme: UiTheme,
1296    #[serde(default, skip_serializing_if = "PhoneConfig::is_default")]
1297    pub phone: PhoneConfig,
1298    #[serde(default, skip_serializing_if = "ReviewConfig::is_default")]
1299    pub review: ReviewConfig,
1300    #[serde(default, skip_serializing_if = "SubagentConfig::is_default")]
1301    pub subagents: SubagentConfig,
1302    #[serde(
1303        default,
1304        rename = "startup",
1305        skip_serializing,
1306        deserialize_with = "discard_legacy_startup"
1307    )]
1308    pub legacy_startup: (),
1309    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1310    pub profiles: BTreeMap<String, HarnessProfile>,
1311    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1312    pub bundles: BTreeMap<String, ProjectBundle>,
1313    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1314    pub targets: BTreeMap<String, TargetTemplate>,
1315}
1316
1317impl Default for Config {
1318    fn default() -> Self {
1319        Self {
1320            sessions_side: SessionsSide::default(),
1321            advanced: AdvancedConfig::default(),
1322            show_stopped_sessions: false,
1323            version: CONFIG_VERSION,
1324            newer_config_version: None,
1325            spinner: SpinnerStyle::default(),
1326            theme: Default::default(),
1327            phone: PhoneConfig::default(),
1328            review: ReviewConfig::default(),
1329            subagents: SubagentConfig::default(),
1330            legacy_startup: (),
1331            profiles: BTreeMap::new(),
1332            bundles: BTreeMap::new(),
1333            targets: BTreeMap::new(),
1334        }
1335    }
1336}
1337
1338impl Config {
1339    /// Suggest additions for setup without replacing existing entries. Both
1340    /// setup surfaces use the same identities and collision-free names.
1341    pub fn setup_additions(&self, discovered: &Self) -> Self {
1342        fn additions<T: Clone>(
1343            existing: &BTreeMap<String, T>,
1344            discovered: &BTreeMap<String, T>,
1345            same: impl Fn(&T, &T) -> bool,
1346            base: impl Fn(&str, &T) -> String,
1347        ) -> BTreeMap<String, T> {
1348            let mut known = existing.clone();
1349            let mut added = BTreeMap::new();
1350            for (id, value) in discovered {
1351                if known.values().any(|entry| same(entry, value)) {
1352                    continue;
1353                }
1354                let id = unique_config_id(&known, &base(id, value));
1355                known.insert(id.clone(), value.clone());
1356                added.insert(id, value.clone());
1357            }
1358            added
1359        }
1360        Self {
1361            profiles: additions(
1362                &self.profiles,
1363                &discovered.profiles,
1364                HarnessProfile::same_installation,
1365                |id, _| id.to_owned(),
1366            ),
1367            bundles: additions(
1368                &self.bundles,
1369                &discovered.bundles,
1370                PartialEq::eq,
1371                |_, bundle| bundle.primary_repo.clone(),
1372            ),
1373            targets: additions(
1374                &self.targets,
1375                &discovered.targets,
1376                PartialEq::eq,
1377                |id, _| id.to_owned(),
1378            ),
1379            ..Self::default()
1380        }
1381    }
1382
1383    pub fn is_unconfigured(&self) -> bool {
1384        self.profiles.is_empty() && self.bundles.is_empty() && self.targets.is_empty()
1385    }
1386
1387    /// Profiles available for new work, user-facing selectors, and probes.
1388    pub fn enabled_profiles(&self) -> impl Iterator<Item = (&str, &HarnessProfile)> {
1389        self.profiles
1390            .iter()
1391            .filter(|(_, profile)| profile.enabled)
1392            .map(|(id, profile)| (id.as_str(), profile))
1393    }
1394
1395    /// One profile when it exists and is available for new work.
1396    pub fn enabled_profile(&self, id: &str) -> Option<&HarnessProfile> {
1397        self.profiles.get(id).filter(|profile| profile.enabled)
1398    }
1399
1400    pub fn validate(&self) -> Result<()> {
1401        if self.version != CONFIG_VERSION {
1402            bail!(
1403                "unsupported Mjolnir config version {}; expected {CONFIG_VERSION}",
1404                self.version
1405            );
1406        }
1407        self.phone.validate()?;
1408        for (id, profile) in &self.profiles {
1409            profile.validate(id)?;
1410        }
1411        // Checked after the profiles, so a review pointing at a malformed
1412        // profile reports the profile's own error first.
1413        self.review.validate(&self.profiles)?;
1414        self.subagents.validate(&self.profiles)?;
1415        for (id, bundle) in &self.bundles {
1416            bundle.validate(id)?;
1417        }
1418        for (id, target) in &self.targets {
1419            target.validate(id)?;
1420        }
1421        Ok(())
1422    }
1423
1424    pub fn load() -> Result<Self> {
1425        Self::load_from(&config_path())
1426    }
1427
1428    /// Read the config from `path`, returning [`Config::default`] when the
1429    /// file is missing or empty and an error when it is malformed.
1430    ///
1431    /// A file written by a *newer* Hel loads best-effort and read-only rather
1432    /// than refusing to start: its settings still work, and every write path
1433    /// refuses, so nothing downgrades the file.
1434    pub fn load_from(path: &Path) -> Result<Self> {
1435        if !path.exists() {
1436            return Ok(Self::default());
1437        }
1438        let contents = fs::read_to_string(path)
1439            .with_context(|| format!("read Mjolnir config {}", path.display()))?;
1440        if contents.trim().is_empty() {
1441            return Ok(Self::default());
1442        }
1443        let document: toml::Value = contents
1444            .parse()
1445            .with_context(|| format!("parse Mjolnir config {}", path.display()))?;
1446        if let Some(found) = newer_version(&document) {
1447            tracing::warn!(
1448                path = %path.display(),
1449                found_version = found,
1450                supported_version = CONFIG_VERSION,
1451                "Mjolnir config was written by a newer build; loading it read-only"
1452            );
1453            return Ok(Self::load_newer(&contents, &document, found));
1454        }
1455        reject_removed_profile_overrides(&contents)?;
1456        reject_non_bare_permissions(&contents)?;
1457        let mut config: Self = toml::from_str(&contents)
1458            .with_context(|| format!("parse Mjolnir config {}", path.display()))?;
1459        // Version 2 adds Podman workspace storage; version 3 restores the
1460        // spinner preference; version 4 adds stopped-session visibility;
1461        // version 5 adds the terminal theme preference; version 6 adds
1462        // optional advanced settings; version 7 restores stopped-session
1463        // visibility as an advanced setting; version 8 lets profiles be
1464        // disabled; version 9 adds sub-agent policy. Earlier configs acquire
1465        // defaults in memory and upgrade on
1466        // the next ordinary save.
1467        if matches!(config.version, 1..=8) {
1468            config.version = CONFIG_VERSION;
1469        }
1470        config.validate()?;
1471        Ok(config)
1472    }
1473
1474    /// Best-effort read of a config a newer Mjolnir maintains. Fields this build
1475    /// does not know drop away, and a section it cannot read falls back on its
1476    /// own instead of costing the whole file, so the profiles, bundles, and
1477    /// targets that still parse keep working. The recorded version is what
1478    /// makes the result read-only.
1479    fn load_newer(contents: &str, document: &toml::Value, found: u32) -> Self {
1480        let parsed = toml::from_str::<Self>(contents).ok().map(|mut config| {
1481            config.version = CONFIG_VERSION;
1482            config
1483        });
1484        let mut config = match parsed {
1485            Some(config) if config.validate().is_ok() => config,
1486            _ => Self::salvage(document),
1487        };
1488        config.newer_config_version = Some(found);
1489        config
1490    }
1491
1492    /// Recover each section on its own when the document as a whole no longer
1493    /// matches this build's schema. Maps recover entry by entry, so one target
1494    /// written in a future shape costs only that target.
1495    fn salvage(document: &toml::Value) -> Self {
1496        let mut config = Self::default();
1497        if let Some(side) = salvage_section::<SessionsSide>(document, "sessions_side") {
1498            config.sessions_side = side;
1499        }
1500        if let Some(theme) = salvage_section::<UiTheme>(document, "theme") {
1501            config.theme = theme;
1502        }
1503        if let Some(spinner) = salvage_section::<SpinnerStyle>(document, "spinner") {
1504            config.spinner = spinner;
1505        }
1506        if let Some(advanced) = salvage_section::<AdvancedConfig>(document, "advanced") {
1507            config.advanced = advanced;
1508        }
1509        if let Some(phone) = salvage_section::<PhoneConfig>(document, "phone")
1510            && phone.validate().is_ok()
1511        {
1512            config.phone = phone;
1513        }
1514        config.profiles = salvage_map(document, "profiles", HarnessProfile::validate);
1515        // Salvaged after the profiles, because whether a review section is
1516        // usable depends on which profiles survived.
1517        if let Some(review) = salvage_section::<ReviewConfig>(document, "review")
1518            && review.validate(&config.profiles).is_ok()
1519        {
1520            config.review = review;
1521        }
1522        if let Some(subagents) = salvage_section::<SubagentConfig>(document, "subagents")
1523            && subagents.validate(&config.profiles).is_ok()
1524        {
1525            config.subagents = subagents;
1526        }
1527        config.bundles = salvage_map(document, "bundles", ProjectBundle::validate);
1528        config.targets = salvage_map(document, "targets", TargetTemplate::validate);
1529        config
1530    }
1531
1532    /// One line for surfaces that show this config when the file on disk
1533    /// belongs to a newer Mjolnir; `None` for a config this build owns.
1534    pub fn newer_build_notice(&self) -> Option<String> {
1535        self.newer_config_version.map(|found| {
1536            format!(
1537                "This config was written by a newer Mjolnir (config version {found}; this build \
1538                 supports {CONFIG_VERSION}), so it is read-only. Update Mjolnir, or change settings \
1539                 with the newer build."
1540            )
1541        })
1542    }
1543
1544    pub fn save(&self) -> Result<()> {
1545        self.save_to(&config_path())
1546    }
1547
1548    /// Load the latest config, apply one edit, validate it, and save it while
1549    /// holding the config lock for the complete transaction.
1550    ///
1551    /// The returned config is the version that was written, and the second
1552    /// value is whatever the edit returned. Keeping the load and edit under
1553    /// the same lock is what lets independent processes update disjoint
1554    /// sections without one stale full-config save erasing the other.
1555    pub fn update<T, F>(edit: F) -> Result<(Self, T)>
1556    where
1557        F: FnOnce(&mut Self) -> Result<T>,
1558    {
1559        Self::update_to(&config_path(), edit)
1560    }
1561
1562    /// As [`Self::update`], using an explicit config path.
1563    pub fn update_to<T, F>(path: &Path, edit: F) -> Result<(Self, T)>
1564    where
1565        F: FnOnce(&mut Self) -> Result<T>,
1566    {
1567        let _lock = ConfigLock::acquire(path)?;
1568        let mut config = Self::load_from(path)?;
1569        config.ensure_writable(path)?;
1570        let value = edit(&mut config)?;
1571        config.save_to_locked(path)?;
1572        Ok((config, value))
1573    }
1574
1575    /// Loads the current file, replaces only its global review section, and
1576    /// writes it atomically. Callers use this for the dashboard editor so a
1577    /// stale dashboard snapshot cannot overwrite profiles, bundles, targets,
1578    /// or phone settings changed concurrently by another client.
1579    pub fn save_review(review: ReviewConfig) -> Result<Self> {
1580        Self::save_review_to(&config_path(), review)
1581    }
1582
1583    /// As [`Self::save_review`], using an explicit path for tests and tools.
1584    pub fn save_review_to(path: &Path, review: ReviewConfig) -> Result<Self> {
1585        let (config, ()) = Self::update_to(path, |config| {
1586            config.review = review;
1587            Ok(())
1588        })?;
1589        Ok(config)
1590    }
1591
1592    /// Refuses when the file belongs to a newer Mjolnir -- judged by the marker
1593    /// this config loaded with *and* a fresh look at the file, since a newer
1594    /// build may have written it since. Overwriting would silently drop
1595    /// settings this build cannot represent.
1596    pub fn save_to(&self, path: &Path) -> Result<()> {
1597        let _lock = ConfigLock::acquire(path)?;
1598        self.save_to_locked(path)
1599    }
1600
1601    /// Save while the caller already owns [`ConfigLock`]. This is separate
1602    /// from [`Self::save_to`] so a transaction does not try to lock the same
1603    /// sibling file recursively.
1604    fn save_to_locked(&self, path: &Path) -> Result<()> {
1605        self.ensure_writable(path)?;
1606        self.validate()?;
1607        let body = toml::to_string_pretty(self).context("serialize Mjolnir config")?;
1608        atomic_write(path, body.as_bytes())
1609    }
1610
1611    /// Rename the setup-generated local bare target without rewriting
1612    /// unrelated configuration. This runs under the controller store lock
1613    /// before SQLite is opened, so config and persisted sessions converge in
1614    /// one startup.
1615    pub fn migrate_legacy_localhost_target() -> Result<bool> {
1616        Self::migrate_legacy_localhost_target_at(&config_path())
1617    }
1618
1619    fn migrate_legacy_localhost_target_at(path: &Path) -> Result<bool> {
1620        let _lock = ConfigLock::acquire(path)?;
1621        if !path.exists() {
1622            return Ok(false);
1623        }
1624        let mut config = Self::load_from(path)?;
1625        if config.newer_config_version.is_some() {
1626            // The newer Mjolnir that owns this file renames its own targets.
1627            tracing::warn!(
1628                path = %path.display(),
1629                "skipping the legacy localhost target rename: the config belongs to a newer Mjolnir"
1630            );
1631            return Ok(false);
1632        }
1633        let Some(legacy) = config.targets.get("raw-localhost").cloned() else {
1634            return Ok(false);
1635        };
1636        if let Some(current) = config.targets.get("localhost")
1637            && current != &legacy
1638        {
1639            bail!(
1640                "cannot rename target `raw-localhost` to `localhost`: both exist with different configurations"
1641            );
1642        }
1643        config.targets.remove("raw-localhost");
1644        config.targets.entry("localhost".into()).or_insert(legacy);
1645        config.save_to_locked(path)?;
1646        Ok(true)
1647    }
1648
1649    fn ensure_writable(&self, path: &Path) -> Result<()> {
1650        if let Some(found) = self
1651            .newer_config_version
1652            .or_else(|| newer_version_on_disk(path))
1653        {
1654            bail!(
1655                "{} was written by a newer Mjolnir (config version {found}; this build writes \
1656                 {CONFIG_VERSION}). Update Mjolnir, or change settings with the newer build",
1657                path.display()
1658            );
1659        }
1660        Ok(())
1661    }
1662}
1663
1664/// Cross-process lock for one config path. The lock has a stable inode beside
1665/// the config because the config itself is replaced atomically after the lock
1666/// is acquired.
1667struct ConfigLock {
1668    _file: File,
1669}
1670
1671impl ConfigLock {
1672    fn acquire(config_path: &Path) -> Result<Self> {
1673        let lock_path = config_lock_path(config_path);
1674        let parent = lock_path
1675            .parent()
1676            .filter(|parent| !parent.as_os_str().is_empty())
1677            .unwrap_or_else(|| Path::new("."));
1678        fs::create_dir_all(parent)
1679            .with_context(|| format!("create config lock directory {}", parent.display()))?;
1680
1681        let mut options = OpenOptions::new();
1682        options.create(true).read(true).write(true);
1683        #[cfg(unix)]
1684        {
1685            use std::os::unix::fs::OpenOptionsExt;
1686            options.mode(0o600);
1687        }
1688        let file = options
1689            .open(&lock_path)
1690            .with_context(|| format!("open config lock {}", lock_path.display()))?;
1691        file.lock()
1692            .with_context(|| format!("lock config {}", config_path.display()))?;
1693        Ok(Self { _file: file })
1694    }
1695}
1696
1697fn config_lock_path(path: &Path) -> PathBuf {
1698    let Some(file_name) = path.file_name() else {
1699        return path.with_extension("lock");
1700    };
1701    let mut lock_name = OsString::from(file_name);
1702    lock_name.push(".lock");
1703    path.with_file_name(lock_name)
1704}
1705
1706impl PhoneConfig {
1707    fn is_default(&self) -> bool {
1708        self == &Self::default()
1709    }
1710}
1711
1712fn reject_non_bare_permissions(contents: &str) -> Result<()> {
1713    let value: toml::Value = contents.parse().context("parse Mjolnir config TOML")?;
1714    let Some(targets) = value.get("targets").and_then(toml::Value::as_table) else {
1715        return Ok(());
1716    };
1717    for (id, target) in targets {
1718        let Some(target) = target.as_table() else {
1719            continue;
1720        };
1721        if target.contains_key("permissions")
1722            && target.get("kind").and_then(toml::Value::as_str) != Some("ssh-bare")
1723        {
1724            bail!("target {id:?} sets `permissions`, which is only valid for ssh-bare targets");
1725        }
1726    }
1727    Ok(())
1728}
1729
1730/// The config version in `document` when it is above this build's.
1731fn newer_version(document: &toml::Value) -> Option<u32> {
1732    let version = document.get("version")?.as_integer()?;
1733    (version > i64::from(CONFIG_VERSION)).then(|| u32::try_from(version).unwrap_or(u32::MAX))
1734}
1735
1736/// The config version at `path` when it is above this build's. Read
1737/// tolerantly: a missing or unreadable file never blocks a save.
1738fn newer_version_on_disk(path: &Path) -> Option<u32> {
1739    let contents = fs::read_to_string(path).ok()?;
1740    newer_version(&contents.parse::<toml::Value>().ok()?)
1741}
1742
1743/// Deserialize one top-level section, or `None` when this build cannot read
1744/// the shape a newer Mjolnir wrote.
1745fn salvage_section<T: for<'de> Deserialize<'de>>(document: &toml::Value, key: &str) -> Option<T> {
1746    document
1747        .get(key)
1748        .cloned()
1749        .and_then(|value| value.try_into().ok())
1750}
1751
1752/// Deserialize one top-level table entry by entry, dropping only the entries
1753/// this build cannot read or accept.
1754fn salvage_map<T, F>(document: &toml::Value, key: &str, validate: F) -> BTreeMap<String, T>
1755where
1756    T: for<'de> Deserialize<'de>,
1757    F: Fn(&T, &str) -> Result<()>,
1758{
1759    let Some(table) = document.get(key).and_then(toml::Value::as_table) else {
1760        return BTreeMap::new();
1761    };
1762    let mut kept = BTreeMap::new();
1763    for (id, value) in table {
1764        match value.clone().try_into::<T>() {
1765            Ok(entry) => match validate(&entry, id) {
1766                Ok(()) => {
1767                    kept.insert(id.clone(), entry);
1768                }
1769                Err(error) => tracing::warn!(
1770                    section = key,
1771                    id,
1772                    %error,
1773                    "dropping a newer Mjolnir config entry this build rejects"
1774                ),
1775            },
1776            Err(error) => tracing::warn!(
1777                section = key,
1778                id,
1779                %error,
1780                "dropping a newer Mjolnir config entry this build cannot read"
1781            ),
1782        }
1783    }
1784    kept
1785}
1786
1787fn reject_removed_profile_overrides(contents: &str) -> Result<()> {
1788    let value: toml::Value = contents.parse().context("parse Mjolnir config TOML")?;
1789    let Some(profiles) = value.get("profiles").and_then(toml::Value::as_table) else {
1790        return Ok(());
1791    };
1792    for (id, profile) in profiles {
1793        let Some(profile) = profile.as_table() else {
1794            continue;
1795        };
1796        for key in ["model", "reasoning_effort"] {
1797            if profile.contains_key(key) {
1798                bail!(
1799                    "profile {id:?}: `{key}` is no longer supported; configure it in the harness home or change it per session with `/config`"
1800                );
1801            }
1802        }
1803    }
1804    Ok(())
1805}
1806
1807/// Read a configuration override under its `MJ_` name. Mjolnir shares no
1808/// state or environment with hel installs; there is no legacy fallback.
1809pub fn env_override_os(name: &str) -> Option<std::ffi::OsString> {
1810    std::env::var_os(format!("MJ_{name}"))
1811}
1812
1813/// String form of [`env_override_os`] for overrides parsed as UTF-8.
1814pub fn env_override(name: &str) -> Option<String> {
1815    std::env::var(format!("MJ_{name}")).ok()
1816}
1817
1818/// Environment variable selecting an isolated Mjolnir instance. An instance
1819/// keeps its own configuration, database, daemon, and logs, so `MJ_INSTANCE=dev`
1820/// never shares state with the default setup.
1821pub const INSTANCE_ENV: &str = "MJ_INSTANCE";
1822
1823/// Directory under the default configuration and data roots holding one
1824/// isolated instance's files (`<root>/mjolnir/instances/<name>`).
1825const INSTANCE_DIR: &str = "instances";
1826
1827/// Instance name from [`INSTANCE_ENV`], trimmed. Empty means no instance.
1828pub fn instance_name() -> Option<String> {
1829    let name = env_override("INSTANCE")?;
1830    let trimmed = name.trim();
1831    (!trimmed.is_empty()).then(|| trimmed.to_owned())
1832}
1833
1834/// Whether `name` is safe to use as a single path segment under [`INSTANCE_DIR`].
1835pub fn is_valid_instance_name(name: &str) -> bool {
1836    validate_id("instance", name).is_ok()
1837}
1838
1839/// Fail when [`INSTANCE_ENV`] names something that cannot be an instance.
1840/// Every shipped binary calls this during startup so a typo fails closed
1841/// instead of silently using the default directories.
1842pub fn validate_instance_env() -> Result<()> {
1843    if let Some(name) = instance_name() {
1844        validate_id("instance", &name)?;
1845    }
1846    Ok(())
1847}
1848
1849/// Record a `--instance` flag value for this process and the daemon and
1850/// workers it spawns. The explicit flag wins over [`INSTANCE_ENV`].
1851pub fn apply_instance_flag(value: Option<&str>) -> Result<()> {
1852    if let Some(raw) = value {
1853        let name = raw.trim();
1854        validate_id("instance", name)?;
1855        // SAFETY: every caller runs this during single-threaded process startup,
1856        // before the Tokio runtime or any other thread exists, so no other
1857        // thread can observe the environment while it is being mutated.
1858        unsafe {
1859            std::env::set_var(INSTANCE_ENV, name);
1860        }
1861    }
1862    validate_instance_env()
1863}
1864
1865/// Nest `base` under [`INSTANCE_DIR`] when an instance is selected. A name that
1866/// fails validation falls back to `base`; startup validation rejects it first,
1867/// so this only guards against future callers that skip that check.
1868fn with_instance_dir(base: PathBuf, instance: Option<&str>) -> PathBuf {
1869    match instance {
1870        Some(name) if is_valid_instance_name(name) => base.join(INSTANCE_DIR).join(name),
1871        _ => base,
1872    }
1873}
1874
1875pub fn config_dir() -> PathBuf {
1876    if let Some(path) = env_override_os("CONFIG_DIR") {
1877        return PathBuf::from(path);
1878    }
1879    with_instance_dir(
1880        dirs::config_dir()
1881            .unwrap_or_else(|| PathBuf::from(".config"))
1882            .join(PRODUCT_DIR),
1883        instance_name().as_deref(),
1884    )
1885}
1886
1887pub fn config_path() -> PathBuf {
1888    config_dir().join("config.toml")
1889}
1890
1891pub fn data_dir() -> PathBuf {
1892    if let Some(path) = env_override_os("DATA_DIR") {
1893        return PathBuf::from(path);
1894    }
1895    with_instance_dir(
1896        dirs::data_local_dir()
1897            .or_else(dirs::data_dir)
1898            .unwrap_or_else(|| PathBuf::from(".local/share"))
1899            .join(PRODUCT_DIR),
1900        instance_name().as_deref(),
1901    )
1902}
1903
1904pub fn sessions_dir() -> PathBuf {
1905    data_dir().join("sessions")
1906}
1907
1908/// The first available configuration identifier, retaining a readable base.
1909pub fn unique_config_id<T>(entries: &BTreeMap<String, T>, base: &str) -> String {
1910    if !entries.contains_key(base) {
1911        return base.to_owned();
1912    }
1913    for number in 2.. {
1914        let suffix = format!("-{number}");
1915        // Configuration identifiers are ASCII and limited to 64 bytes.
1916        let prefix = base.chars().take(64 - suffix.len()).collect::<String>();
1917        let candidate = format!("{prefix}{suffix}");
1918        if !entries.contains_key(&candidate) {
1919            return candidate;
1920        }
1921    }
1922    unreachable!("configuration identifier space exhausted")
1923}
1924
1925pub fn validate_id(kind: &str, id: &str) -> Result<()> {
1926    if id.is_empty()
1927        || id.len() > 64
1928        || !id
1929            .bytes()
1930            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
1931        || matches!(id, "." | "..")
1932    {
1933        bail!("invalid {kind} id {id:?}; use 1-64 ASCII letters, digits, '.', '-' or '_'");
1934    }
1935    Ok(())
1936}
1937
1938pub fn validate_relative_destination(path: &Path) -> Result<()> {
1939    if path.as_os_str().is_empty() || path.is_absolute() {
1940        bail!("destination must be a non-empty relative path");
1941    }
1942    for component in path.components() {
1943        match component {
1944            Component::Normal(_) => {}
1945            Component::CurDir => bail!("destination must not contain '.'"),
1946            Component::ParentDir => bail!("destination must not contain '..'"),
1947            Component::Prefix(_) | Component::RootDir => {
1948                bail!("destination must not be absolute")
1949            }
1950        }
1951    }
1952    Ok(())
1953}
1954
1955fn is_github_source(source: &str) -> bool {
1956    let source = source.trim();
1957    if source.is_empty() || source.starts_with('-') || source.chars().any(char::is_whitespace) {
1958        return false;
1959    }
1960    let repository_path = source
1961        .strip_prefix("https://github.com/")
1962        .or_else(|| source.strip_prefix("git@github.com:"))
1963        .or_else(|| source.strip_prefix("ssh://git@github.com/"))
1964        .unwrap_or(source);
1965    let mut parts = repository_path.trim_end_matches(".git").split('/');
1966    matches!((parts.next(), parts.next(), parts.next()), (Some(owner), Some(repository), None) if !owner.is_empty() && !repository.is_empty())
1967}
1968
1969/// Replace `path` without exposing a partially-written configuration/state file.
1970pub fn atomic_write(path: &Path, body: &[u8]) -> Result<()> {
1971    atomic_write_with_parent(path, body, ParentDirectory::Create)
1972}
1973
1974/// Replace `path` only while its directory still exists.
1975///
1976/// Worker state lives inside a directory that session teardown deletes out
1977/// from under the running daemon. Recreating it here would resurrect a closed
1978/// session's relay state, so a vanished parent must be an error instead.
1979pub fn atomic_write_existing(path: &Path, body: &[u8]) -> Result<()> {
1980    atomic_write_with_parent(path, body, ParentDirectory::Require)
1981}
1982
1983#[derive(Clone, Copy, PartialEq, Eq)]
1984enum ParentDirectory {
1985    Create,
1986    Require,
1987}
1988
1989fn atomic_write_with_parent(
1990    path: &Path,
1991    body: &[u8],
1992    parent_directory: ParentDirectory,
1993) -> Result<()> {
1994    let parent = path
1995        .parent()
1996        .filter(|parent| !parent.as_os_str().is_empty())
1997        .unwrap_or_else(|| Path::new("."));
1998    match parent_directory {
1999        ParentDirectory::Create => {
2000            fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
2001        }
2002        ParentDirectory::Require => {
2003            if !parent.is_dir() {
2004                bail!("directory {} is missing", parent.display());
2005            }
2006        }
2007    }
2008
2009    let mut random = [0u8; 8];
2010    getrandom::fill(&mut random)
2011        .map_err(|error| anyhow!("generate temporary filename: {error}"))?;
2012    let suffix = u64::from_le_bytes(random);
2013    let file_name = path
2014        .file_name()
2015        .and_then(|name| name.to_str())
2016        .unwrap_or("hel");
2017    let temporary = parent.join(format!(
2018        ".{file_name}.{}.{suffix:016x}.tmp",
2019        std::process::id()
2020    ));
2021
2022    let result = (|| -> Result<()> {
2023        let mut file = OpenOptions::new()
2024            .write(true)
2025            .create_new(true)
2026            .open(&temporary)
2027            .with_context(|| format!("create {}", temporary.display()))?;
2028        #[cfg(unix)]
2029        {
2030            use std::os::unix::fs::PermissionsExt;
2031            file.set_permissions(fs::Permissions::from_mode(0o600))?;
2032        }
2033        file.write_all(body)
2034            .with_context(|| format!("write {}", temporary.display()))?;
2035        file.sync_all()
2036            .with_context(|| format!("sync {}", temporary.display()))?;
2037        drop(file);
2038        fs::rename(&temporary, path)
2039            .with_context(|| format!("replace {} with {}", path.display(), temporary.display()))?;
2040        #[cfg(unix)]
2041        OpenOptions::new()
2042            .read(true)
2043            .open(parent)
2044            .and_then(|directory| directory.sync_all())
2045            .with_context(|| format!("sync {}", parent.display()))?;
2046        Ok(())
2047    })();
2048    if result.is_err() {
2049        let _ = fs::remove_file(&temporary);
2050    }
2051    result
2052}
2053
2054#[cfg(test)]
2055mod tests {
2056    use super::*;
2057
2058    #[test]
2059    fn obsolete_startup_settings_are_ignored_and_removed_when_saving() {
2060        let directory = tempfile::tempdir().unwrap();
2061        let path = directory.path().join("config.toml");
2062        let expected = sample_config();
2063        let original = toml::to_string(&expected).unwrap();
2064        for enabled in [true, false] {
2065            fs::write(&path, format!("{original}\n[startup]\nenabled = {enabled}\nprompt = false\nprofile = \"missing\"\ntarget = \"missing\"\n")).unwrap();
2066            let loaded = Config::load_from(&path).unwrap();
2067            assert_eq!(loaded, expected);
2068            assert!(
2069                serde_json::to_value(&loaded)
2070                    .unwrap()
2071                    .get("startup")
2072                    .is_none()
2073            );
2074            loaded.save_to(&path).unwrap();
2075            assert!(!fs::read_to_string(&path).unwrap().contains("[startup]"));
2076        }
2077    }
2078
2079    #[test]
2080    fn stopped_session_visibility_defaults_off_and_uses_the_advanced_section() {
2081        let directory = tempfile::tempdir().unwrap();
2082        let path = directory.path().join("config.toml");
2083        let legacy = "version = 6\nshow_stopped_sessions = true\n";
2084        fs::write(&path, legacy).unwrap();
2085        let config = Config::load_from(&path).unwrap();
2086        assert!(config.show_stopped_sessions);
2087        assert!(!config.advanced.show_stopped_sessions);
2088        assert_eq!(fs::read_to_string(&path).unwrap(), legacy);
2089
2090        config.save_to(&path).unwrap();
2091        let body = fs::read_to_string(&path).unwrap();
2092        assert!(!body.contains("show_stopped_sessions"));
2093
2094        let (saved, ()) = Config::update_to(&path, |config| {
2095            config.advanced.show_stopped_sessions = true;
2096            Ok(())
2097        })
2098        .unwrap();
2099        assert_eq!(Config::load_from(&path).unwrap(), saved);
2100        assert!(saved.advanced.show_stopped_sessions);
2101        let body = fs::read_to_string(&path).unwrap();
2102        assert!(body.contains("[advanced]"));
2103        assert!(body.contains("show_stopped_sessions = true"));
2104        assert_eq!(saved.version, CONFIG_VERSION);
2105    }
2106
2107    #[test]
2108    fn muse_home_mapping_keeps_config_credentials_and_session_data_together() {
2109        let home = Path::new("/private/session/muse");
2110        let mut environment = BTreeMap::from([("XDG_DATA_HOME".into(), "/unrelated".into())]);
2111        HarnessKind::Muse.configure_home_environment(home, &mut environment);
2112        assert_eq!(environment["XDG_CONFIG_HOME"], "/private/session");
2113        assert_eq!(environment["XDG_DATA_HOME"], "/private/session/muse/.data");
2114        assert_eq!(
2115            HarnessKind::Muse.home_from_environment(&environment["XDG_CONFIG_HOME"]),
2116            home
2117        );
2118        assert_eq!(
2119            harness_authentication_marker(HarnessKind::Muse, home),
2120            home.join("auth.json")
2121        );
2122    }
2123
2124    #[test]
2125    fn codex_target_policy_selects_mode_without_replacing_host_config() {
2126        for (policy, mode) in [
2127            (ExecutionPolicy::ConfiguredApprovals, "agent"),
2128            (ExecutionPolicy::Unconstrained, "agent-full-access"),
2129        ] {
2130            let config = r#"{"default_permissions":"project","model":"configured-model"}"#;
2131            let mut environment = BTreeMap::from([("CODEX_CONFIG".into(), config.into())]);
2132            HarnessKind::Codex
2133                .configure_execution_environment(policy, &mut environment)
2134                .unwrap();
2135            assert_eq!(environment["INITIAL_AGENT_MODE"], mode);
2136            assert_eq!(environment["CODEX_CONFIG"], config);
2137        }
2138    }
2139
2140    #[test]
2141    fn muse_guardian_preserves_policy_and_unconstrained_launch_is_explicit() {
2142        let original = BTreeMap::from([
2143            ("MUSE_APPROVAL_MODE".into(), "promptUnmatched".into()),
2144            (
2145                "MUSE_SERVE_ARGS".into(),
2146                "--sandbox-network restricted".into(),
2147            ),
2148        ]);
2149        let mut environment = original.clone();
2150        HarnessKind::Muse
2151            .configure_execution_environment(ExecutionPolicy::ConfiguredApprovals, &mut environment)
2152            .unwrap();
2153        assert_eq!(environment, original);
2154        HarnessKind::Muse
2155            .configure_execution_environment(ExecutionPolicy::Unconstrained, &mut environment)
2156            .unwrap();
2157        HarnessKind::Muse
2158            .configure_execution_environment(ExecutionPolicy::Unconstrained, &mut environment)
2159            .unwrap();
2160        assert_eq!(environment["MUSE_APPROVAL_MODE"], "allowAll");
2161        assert_eq!(
2162            environment["MUSE_SERVE_ARGS"],
2163            "--sandbox-network restricted --disable-sandbox"
2164        );
2165    }
2166
2167    fn sample_config() -> Config {
2168        Config {
2169            version: CONFIG_VERSION,
2170            sessions_side: Default::default(),
2171            advanced: Default::default(),
2172            show_stopped_sessions: false,
2173            newer_config_version: None,
2174            spinner: SpinnerStyle::default(),
2175            theme: Default::default(),
2176            phone: PhoneConfig::default(),
2177            review: ReviewConfig::default(),
2178            subagents: SubagentConfig::default(),
2179            legacy_startup: (),
2180            profiles: BTreeMap::from([(
2181                "codex-1".into(),
2182                HarnessProfile {
2183                    enabled: true,
2184                    context_window_bytes: None,
2185                    kind: HarnessKind::Codex,
2186                    home: PathBuf::from("/home/test/.codex-one"),
2187                    environment: BTreeMap::from([("RUST_LOG".into(), "info".into())]),
2188                },
2189            )]),
2190            bundles: BTreeMap::from([(
2191                "hel".into(),
2192                ProjectBundle {
2193                    primary_repo: "app".into(),
2194                    repositories: vec![ProjectRepository {
2195                        id: "app".into(),
2196                        github: Some("BrokkAi/hel".into()),
2197                        local: None,
2198                        destination: PathBuf::from("app"),
2199                        git_ref: None,
2200                    }],
2201                },
2202            )]),
2203            targets: BTreeMap::from([(
2204                "podman-default".into(),
2205                TargetTemplate::LocalPodman {
2206                    container: ContainerTemplate {
2207                        image: "ubuntu:24.04".into(),
2208                        pull_policy: ImagePullPolicy::Auto,
2209                        platform: None,
2210                        cpus: None,
2211                        memory: None,
2212                        environment: BTreeMap::new(),
2213                        workspace_storage: Default::default(),
2214                    },
2215                },
2216            )]),
2217        }
2218    }
2219
2220    #[test]
2221    fn harness_profiles_reject_the_removed_executable_override() {
2222        let error = toml::from_str::<HarnessProfile>(
2223            "kind = \"codex\"\nhome = \"/profiles/codex\"\nexecutable = \"/opt/codex-acp\"\n",
2224        )
2225        .unwrap_err();
2226        assert!(error.to_string().contains("unknown field `executable`"));
2227    }
2228
2229    #[test]
2230    fn legacy_localhost_target_migration_is_atomic_and_idempotent() {
2231        let directory = tempfile::tempdir().unwrap();
2232        let path = directory.path().join("config.toml");
2233        let mut config = sample_config();
2234        config.targets.clear();
2235        config
2236            .targets
2237            .insert("raw-localhost".into(), TargetTemplate::LocalBare);
2238        config.save_to(&path).unwrap();
2239
2240        assert!(Config::migrate_legacy_localhost_target_at(&path).unwrap());
2241        let migrated = Config::load_from(&path).unwrap();
2242        assert_eq!(
2243            migrated.targets.get("localhost"),
2244            Some(&TargetTemplate::LocalBare)
2245        );
2246        assert!(!migrated.targets.contains_key("raw-localhost"));
2247        assert!(!Config::migrate_legacy_localhost_target_at(&path).unwrap());
2248    }
2249
2250    #[test]
2251    fn conflicting_localhost_target_migration_leaves_config_unchanged() {
2252        let directory = tempfile::tempdir().unwrap();
2253        let path = directory.path().join("config.toml");
2254        let mut config = sample_config();
2255        config
2256            .targets
2257            .insert("raw-localhost".into(), TargetTemplate::LocalBare);
2258        config.targets.insert(
2259            "localhost".into(),
2260            TargetTemplate::LocalPodman {
2261                container: ContainerTemplate {
2262                    image: "different".into(),
2263                    pull_policy: ImagePullPolicy::Auto,
2264                    platform: None,
2265                    cpus: None,
2266                    memory: None,
2267                    environment: BTreeMap::new(),
2268                    workspace_storage: Default::default(),
2269                },
2270            },
2271        );
2272        config.save_to(&path).unwrap();
2273        let before = fs::read(&path).unwrap();
2274
2275        assert!(Config::migrate_legacy_localhost_target_at(&path).is_err());
2276        assert_eq!(fs::read(&path).unwrap(), before);
2277    }
2278
2279    #[test]
2280    fn harness_mapping_and_permission_modes_are_fixed() {
2281        assert_eq!(HarnessKind::Codex.home_env(), "CODEX_HOME");
2282        assert_eq!(HarnessKind::Claude.home_env(), "CLAUDE_CONFIG_DIR");
2283        assert_eq!(HarnessKind::Kimi.home_env(), "KIMI_CODE_HOME");
2284        assert_eq!(HarnessKind::Grok.home_env(), "GROK_HOME");
2285        let codex = HarnessKind::Codex
2286            .execution_enforcement(ExecutionPolicy::Unconstrained)
2287            .unwrap();
2288        assert_eq!(codex.acp_mode(), Some("agent-full-access"));
2289        assert_eq!(codex.label(), "agent-full-access");
2290        let claude = HarnessKind::Claude
2291            .execution_enforcement(ExecutionPolicy::Unconstrained)
2292            .unwrap();
2293        assert_eq!(claude.acp_mode(), Some("bypassPermissions"));
2294        let kimi = HarnessKind::Kimi
2295            .execution_enforcement(ExecutionPolicy::Unconstrained)
2296            .unwrap();
2297        assert_eq!(kimi.acp_mode(), Some("auto"));
2298    }
2299
2300    #[test]
2301    fn unconstrained_enforcement_splits_acp_modes_from_launch_controls() {
2302        for kind in [HarnessKind::Codex, HarnessKind::Kimi] {
2303            let enforcement = kind
2304                .execution_enforcement(ExecutionPolicy::Unconstrained)
2305                .unwrap();
2306            assert_eq!(enforcement.acp_mode(), Some(enforcement.label()));
2307            assert_eq!(enforcement.launch_flag(), None);
2308        }
2309        assert_eq!(
2310            HarnessKind::Codex
2311                .execution_enforcement(ExecutionPolicy::Unconstrained)
2312                .unwrap()
2313                .launch_environment(),
2314            Some(("INITIAL_AGENT_MODE", "agent-full-access"))
2315        );
2316        let grok = HarnessKind::Grok
2317            .execution_enforcement(ExecutionPolicy::Unconstrained)
2318            .unwrap();
2319        assert_eq!(grok.acp_mode(), None);
2320        assert_eq!(grok.launch_flag(), Some("--always-approve"));
2321        assert_eq!(grok.label(), "always-approve / sandbox-off");
2322        assert_eq!(grok.launch_environment(), Some(("GROK_SANDBOX", "off")));
2323        let claude = HarnessKind::Claude
2324            .execution_enforcement(ExecutionPolicy::Unconstrained)
2325            .unwrap();
2326        assert_eq!(claude.acp_mode(), Some("bypassPermissions"));
2327        assert_eq!(claude.label(), "bypassPermissions / sandbox-off");
2328        let deepseek = HarnessKind::Deepseek
2329            .execution_enforcement(ExecutionPolicy::Unconstrained)
2330            .unwrap();
2331        assert_eq!(deepseek.acp_mode(), None);
2332        assert_eq!(deepseek.launch_flag(), None);
2333        assert_eq!(
2334            deepseek.launch_environment(),
2335            Some(("DSH_PERMISSION_MODE", "danger-full-access"))
2336        );
2337        let muse = HarnessKind::Muse
2338            .execution_enforcement(ExecutionPolicy::Unconstrained)
2339            .unwrap();
2340        assert_eq!(muse.acp_mode(), Some("allowAll"));
2341        assert_eq!(muse.label(), "allowAll / sandbox-off");
2342        assert_eq!(
2343            muse.launch_environment(),
2344            Some(("MUSE_APPROVAL_MODE", "allowAll"))
2345        );
2346    }
2347
2348    #[test]
2349    fn configured_approvals_preserve_other_profiles_and_select_codex_guardian() {
2350        let codex = HarnessKind::Codex
2351            .execution_enforcement(ExecutionPolicy::ConfiguredApprovals)
2352            .expect("Codex ACP selects guardian explicitly");
2353        assert_eq!(codex.acp_mode(), Some("agent"));
2354        assert_eq!(
2355            codex.launch_environment(),
2356            Some(("INITIAL_AGENT_MODE", "agent"))
2357        );
2358
2359        for kind in [
2360            HarnessKind::Claude,
2361            HarnessKind::Kimi,
2362            HarnessKind::Grok,
2363            HarnessKind::Deepseek,
2364        ] {
2365            assert_eq!(
2366                kind.execution_enforcement(ExecutionPolicy::ConfiguredApprovals),
2367                None,
2368                "{kind:?}"
2369            );
2370        }
2371    }
2372
2373    #[test]
2374    fn harness_names_and_ids_round_trip() {
2375        for kind in HarnessKind::ALL {
2376            assert_eq!(kind.id().parse::<HarnessKind>().unwrap(), kind);
2377            assert_eq!(
2378                serde_json::to_value(kind).unwrap(),
2379                serde_json::Value::String(kind.id().to_owned())
2380            );
2381            assert!(!kind.display_name().is_empty());
2382            assert!(kind.default_home_leaf().starts_with('.'));
2383        }
2384        assert_eq!(HarnessKind::Grok.id(), "grok");
2385        assert_eq!(HarnessKind::Grok.display_name(), "Grok Build");
2386        assert_eq!(HarnessKind::Grok.default_home_leaf(), ".grok");
2387        assert_eq!(HarnessKind::Deepseek.display_name(), "DSH");
2388        assert_eq!(HarnessKind::Deepseek.home_env(), "DSH_HOME");
2389        assert!("nope".parse::<HarnessKind>().is_err());
2390    }
2391
2392    #[test]
2393    fn bridge_args_carry_the_acp_subcommand_per_harness() {
2394        for policy in [
2395            ExecutionPolicy::ConfiguredApprovals,
2396            ExecutionPolicy::Unconstrained,
2397        ] {
2398            assert!(HarnessKind::Codex.bridge_args(policy).is_empty());
2399            assert!(HarnessKind::Claude.bridge_args(policy).is_empty());
2400            assert_eq!(HarnessKind::Kimi.bridge_args(policy), ["acp"]);
2401            assert_eq!(
2402                HarnessKind::Deepseek.bridge_args(policy),
2403                ["--profile", "acp"]
2404            );
2405            assert_eq!(
2406                HarnessKind::Grok.bridge_args(policy),
2407                if policy.is_unconstrained() {
2408                    vec!["agent", "--always-approve", "stdio"]
2409                } else {
2410                    vec!["agent", "stdio"]
2411                },
2412                "policy: {policy:?}"
2413            );
2414        }
2415    }
2416
2417    #[test]
2418    fn only_unconstrained_grok_carries_the_blanket_approval_flag() {
2419        assert_eq!(
2420            HarnessKind::Grok.launch_flag_for(ExecutionPolicy::ConfiguredApprovals),
2421            None
2422        );
2423        assert_eq!(
2424            HarnessKind::Grok.launch_flag_for(ExecutionPolicy::Unconstrained),
2425            Some("--always-approve")
2426        );
2427        for kind in [
2428            HarnessKind::Codex,
2429            HarnessKind::Claude,
2430            HarnessKind::Kimi,
2431            HarnessKind::Deepseek,
2432        ] {
2433            for policy in [
2434                ExecutionPolicy::ConfiguredApprovals,
2435                ExecutionPolicy::Unconstrained,
2436            ] {
2437                assert_eq!(kind.launch_flag_for(policy), None, "{kind:?}");
2438            }
2439        }
2440    }
2441
2442    #[test]
2443    fn guardian_support_is_declared_per_harness() {
2444        for kind in [HarnessKind::Codex, HarnessKind::Claude, HarnessKind::Grok] {
2445            assert!(kind.supports_guardian_approvals(), "{kind:?}");
2446        }
2447        for kind in [HarnessKind::Kimi, HarnessKind::Deepseek] {
2448            assert!(!kind.supports_guardian_approvals(), "{kind:?}");
2449        }
2450    }
2451
2452    #[test]
2453    fn bundle_rejects_traversal_and_duplicate_destinations() {
2454        let mut config = sample_config();
2455        config.bundles.get_mut("hel").unwrap().repositories[0].destination =
2456            PathBuf::from("../escape");
2457        assert!(format!("{:#}", config.validate().unwrap_err()).contains("'..'"));
2458
2459        let mut config = sample_config();
2460        let bundle = config.bundles.get_mut("hel").unwrap();
2461        bundle.repositories.push(ProjectRepository {
2462            id: "docs".into(),
2463            github: Some("BrokkAi/docs".into()),
2464            local: None,
2465            destination: PathBuf::from("app"),
2466            git_ref: None,
2467        });
2468        assert!(
2469            config
2470                .validate()
2471                .unwrap_err()
2472                .to_string()
2473                .contains("overlapping destinations")
2474        );
2475    }
2476
2477    #[test]
2478    fn bundle_requires_existing_primary_repository() {
2479        let mut config = sample_config();
2480        config.bundles.get_mut("hel").unwrap().primary_repo = "missing".into();
2481        assert!(
2482            config
2483                .validate()
2484                .unwrap_err()
2485                .to_string()
2486                .contains("does not exist")
2487        );
2488    }
2489
2490    #[test]
2491    fn bundle_rejects_non_github_sources() {
2492        let mut config = sample_config();
2493        config.bundles.get_mut("hel").unwrap().repositories[0].github =
2494            Some("https://example.com/owner/repo".into());
2495        assert!(
2496            config
2497                .validate()
2498                .unwrap_err()
2499                .to_string()
2500                .contains("not a supported GitHub source")
2501        );
2502    }
2503
2504    #[test]
2505    fn bundle_accepts_one_absolute_local_source() {
2506        let mut config = sample_config();
2507        {
2508            let repository = &mut config.bundles.get_mut("hel").unwrap().repositories[0];
2509            repository.github = None;
2510            repository.local = Some(PathBuf::from("/home/test/src/app"));
2511        }
2512        config.validate().unwrap();
2513
2514        config.bundles.get_mut("hel").unwrap().repositories[0].local =
2515            Some(PathBuf::from("relative/app"));
2516        assert!(
2517            config
2518                .validate()
2519                .unwrap_err()
2520                .to_string()
2521                .contains("absolute")
2522        );
2523    }
2524
2525    #[test]
2526    fn bundle_requires_exactly_one_repository_source() {
2527        let mut config = sample_config();
2528        config.bundles.get_mut("hel").unwrap().repositories[0].local =
2529            Some(PathBuf::from("/home/test/src/app"));
2530        assert!(
2531            config
2532                .validate()
2533                .unwrap_err()
2534                .to_string()
2535                .contains("exactly one")
2536        );
2537    }
2538
2539    #[test]
2540    fn config_toml_round_trip_is_atomic() {
2541        let directory = tempfile::tempdir().unwrap();
2542        let path = directory.path().join("nested/config.toml");
2543        let config = sample_config();
2544        config.save_to(&path).unwrap();
2545        assert_eq!(Config::load_from(&path).unwrap(), config);
2546        assert!(!fs::read_to_string(&path).unwrap().contains("pull_policy"));
2547        assert_eq!(
2548            fs::read_to_string(path)
2549                .unwrap()
2550                .matches("kind = \"local-podman\"")
2551                .count(),
2552            1
2553        );
2554        assert!(
2555            fs::read_dir(directory.path().join("nested"))
2556                .unwrap()
2557                .all(|entry| {
2558                    !entry
2559                        .unwrap()
2560                        .file_name()
2561                        .to_string_lossy()
2562                        .ends_with(".tmp")
2563                })
2564        );
2565    }
2566
2567    #[test]
2568    fn save_review_reloads_latest_config_and_preserves_unrelated_sections() {
2569        let directory = tempfile::tempdir().unwrap();
2570        let path = directory.path().join("config.toml");
2571        let initial = sample_config();
2572        initial.save_to(&path).unwrap();
2573
2574        // Simulate a concurrent dashboard changing an unrelated section after
2575        // the editor opened. The review save must start from this newer file.
2576        let mut latest = Config::load_from(&path).unwrap();
2577        latest.phone.enabled = false;
2578        latest
2579            .profiles
2580            .get_mut("codex-1")
2581            .unwrap()
2582            .environment
2583            .insert("LATEST_SETTING".into(), "kept".into());
2584        latest.save_to(&path).unwrap();
2585
2586        let review = ReviewConfig {
2587            enabled: true,
2588            tier: crate::review::lanes::ReviewTier::Extended,
2589            profile: Some("codex-1".into()),
2590            model: Some("review-model".into()),
2591            effort: Some("high".into()),
2592        };
2593        let saved = Config::save_review_to(&path, review.clone()).unwrap();
2594        assert_eq!(saved.review, review);
2595        assert!(!saved.phone.enabled);
2596        assert_eq!(
2597            saved.profiles["codex-1"].environment.get("LATEST_SETTING"),
2598            Some(&"kept".to_owned())
2599        );
2600        assert_eq!(Config::load_from(&path).unwrap(), saved);
2601    }
2602
2603    #[test]
2604    fn update_to_serializes_disjoint_process_edits() {
2605        const CHILD: &str = "HEL_CONFIG_UPDATE_CHILD";
2606        const PATH: &str = "HEL_CONFIG_UPDATE_PATH";
2607        const READY: &str = "HEL_CONFIG_UPDATE_READY";
2608        const SECOND_STARTED: &str = "HEL_CONFIG_UPDATE_SECOND_STARTED";
2609        const RELEASE: &str = "HEL_CONFIG_UPDATE_RELEASE";
2610        let Some(role) = std::env::var_os(CHILD) else {
2611            let directory = tempfile::tempdir().unwrap();
2612            let path = directory.path().join("config.toml");
2613            sample_config().save_to(&path).unwrap();
2614            let ready = directory.path().join("ready");
2615            let second_started = directory.path().join("second-started");
2616            let release = directory.path().join("release");
2617
2618            let executable = std::env::current_exe().unwrap();
2619            let mut first = std::process::Command::new(&executable)
2620                .args([
2621                    "--exact",
2622                    "config::tests::update_to_serializes_disjoint_process_edits",
2623                    "--nocapture",
2624                ])
2625                .env(CHILD, "phone")
2626                .env(PATH, &path)
2627                .env(READY, &ready)
2628                .env(SECOND_STARTED, &second_started)
2629                .env(RELEASE, &release)
2630                .spawn()
2631                .unwrap();
2632            // The first child writes this only after it has acquired the
2633            // sibling lock and entered its edit closure.
2634            let first_entered = (0..1000).any(|_| {
2635                if ready.exists() {
2636                    return true;
2637                }
2638                std::thread::sleep(std::time::Duration::from_millis(10));
2639                false
2640            }) || ready.exists();
2641
2642            let mut second = std::process::Command::new(&executable)
2643                .args([
2644                    "--exact",
2645                    "config::tests::update_to_serializes_disjoint_process_edits",
2646                    "--nocapture",
2647                ])
2648                .env(CHILD, "profile")
2649                .env(PATH, &path)
2650                .env(READY, &ready)
2651                .env(SECOND_STARTED, &second_started)
2652                .env(RELEASE, &release)
2653                .spawn()
2654                .unwrap();
2655            let second_reached_update = (0..1000).any(|_| {
2656                if second_started.exists() {
2657                    return true;
2658                }
2659                std::thread::sleep(std::time::Duration::from_millis(10));
2660                false
2661            }) || second_started.exists();
2662            let second_blocked = second.try_wait().unwrap().is_none();
2663            // Always release the first child before asserting, so a failed
2664            // observation cannot leave a child waiting after this test exits.
2665            fs::write(&release, b"release").unwrap();
2666            let first_status = first.wait().unwrap();
2667            let second_status = second.wait().unwrap();
2668            assert!(first_entered, "first config update child never entered");
2669            assert!(
2670                second_reached_update,
2671                "second config update child never reached its update"
2672            );
2673            assert!(second_blocked, "second config update child was not blocked");
2674            assert!(
2675                first_status.success(),
2676                "first config update child failed: {first_status}"
2677            );
2678            assert!(
2679                second_status.success(),
2680                "second config update child failed: {second_status}"
2681            );
2682
2683            let config = Config::load_from(&path).unwrap();
2684            assert!(!config.phone.enabled);
2685            assert_eq!(
2686                config.profiles["codex-1"].environment.get("CONCURRENT"),
2687                Some(&"kept".to_owned())
2688            );
2689            return;
2690        };
2691
2692        let path = PathBuf::from(std::env::var_os(PATH).unwrap());
2693        let role = role.to_string_lossy();
2694        let ready = PathBuf::from(std::env::var_os(READY).unwrap());
2695        let second_started = PathBuf::from(std::env::var_os(SECOND_STARTED).unwrap());
2696        let release = PathBuf::from(std::env::var_os(RELEASE).unwrap());
2697        if role == "profile" {
2698            // Confirm the first process owns the stable lock before telling
2699            // the parent it can release it. This cannot pass merely because
2700            // the second process was slow to reach update_to.
2701            let lock = OpenOptions::new()
2702                .read(true)
2703                .write(true)
2704                .open(config_lock_path(&path))
2705                .unwrap();
2706            assert!(matches!(
2707                lock.try_lock(),
2708                Err(std::fs::TryLockError::WouldBlock)
2709            ));
2710            fs::write(&second_started, b"started").unwrap();
2711        }
2712        Config::update_to(&path, |config| {
2713            match role.as_ref() {
2714                "phone" => {
2715                    fs::write(&ready, b"entered").unwrap();
2716                    while !release.exists() {
2717                        std::thread::sleep(std::time::Duration::from_millis(10));
2718                    }
2719                    config.phone.enabled = false;
2720                }
2721                "profile" => {
2722                    config
2723                        .profiles
2724                        .get_mut("codex-1")
2725                        .unwrap()
2726                        .environment
2727                        .insert("CONCURRENT".into(), "kept".into());
2728                }
2729                other => panic!("unknown config update child role {other:?}"),
2730            }
2731            Ok(())
2732        })
2733        .unwrap();
2734    }
2735
2736    #[test]
2737    fn update_to_failure_leaves_the_previous_file_unchanged() {
2738        let directory = tempfile::tempdir().unwrap();
2739        let path = directory.path().join("config.toml");
2740        sample_config().save_to(&path).unwrap();
2741        let before = fs::read(&path).unwrap();
2742
2743        let error = Config::update_to(&path, |config| {
2744            config.phone.bind = "not-an-address".into();
2745            Ok(())
2746        })
2747        .unwrap_err();
2748
2749        assert!(error.to_string().contains("parse phone bind"));
2750        assert_eq!(fs::read(&path).unwrap(), before);
2751    }
2752
2753    #[test]
2754    fn update_to_refuses_a_newer_config_before_editing() {
2755        let directory = tempfile::tempdir().unwrap();
2756        let path = directory.path().join("config.toml");
2757        let body = format!("version = {}\nfuture = true\n", CONFIG_VERSION + 1);
2758        fs::write(&path, &body).unwrap();
2759
2760        let error = Config::update_to(&path, |config| {
2761            config.phone.enabled = false;
2762            Ok(())
2763        })
2764        .unwrap_err();
2765
2766        assert!(error.to_string().contains("newer Mjolnir"));
2767        assert_eq!(fs::read_to_string(&path).unwrap(), body);
2768    }
2769
2770    #[test]
2771    fn version_one_podman_config_upgrades_to_isolated_workspace_storage() {
2772        let directory = tempfile::tempdir().unwrap();
2773        let path = directory.path().join("config.toml");
2774        fs::write(
2775            &path,
2776            "version = 1\n\n[targets.podman]\nkind = \"local-podman\"\nimage = \"ubuntu:24.04\"\n",
2777        )
2778        .unwrap();
2779
2780        let config = Config::load_from(&path).unwrap();
2781        assert_eq!(config.version, CONFIG_VERSION);
2782        let TargetTemplate::LocalPodman { container } = &config.targets["podman"] else {
2783            panic!("version-one Podman target changed kind")
2784        };
2785        assert_eq!(
2786            container.workspace_storage,
2787            PodmanWorkspaceStorage::PodmanVolume
2788        );
2789        assert!(fs::read_to_string(path).unwrap().starts_with("version = 1"));
2790    }
2791
2792    #[test]
2793    fn old_config_restores_scan_without_rewriting_until_save() {
2794        let directory = tempfile::tempdir().unwrap();
2795        let path = directory.path().join("config.toml");
2796        fs::write(&path, "version = 2\n").unwrap();
2797
2798        let config = Config::load_from(&path).unwrap();
2799        assert_eq!(config.spinner, SpinnerStyle::Scan);
2800        assert_eq!(config.version, CONFIG_VERSION);
2801        assert_eq!(fs::read_to_string(&path).unwrap(), "version = 2\n");
2802        config.save_to(&path).unwrap();
2803        let saved = fs::read_to_string(&path).unwrap();
2804        assert!(saved.starts_with(&format!("version = {CONFIG_VERSION}")));
2805        assert!(!saved.contains("spinner"));
2806    }
2807
2808    #[test]
2809    fn detailed_activity_clocks_default_off_and_round_trip_without_breaking_old_configs() {
2810        let directory = tempfile::tempdir().unwrap();
2811        let path = directory.path().join("config.toml");
2812        fs::write(&path, "version = 2\n").unwrap();
2813        let old = Config::load_from(&path).unwrap();
2814        assert!(!old.advanced.detailed_activity_clocks);
2815
2816        let mut config = old;
2817        config.advanced.detailed_activity_clocks = true;
2818        config.save_to(&path).unwrap();
2819        let saved = fs::read_to_string(&path).unwrap();
2820        assert!(saved.contains("[advanced]"));
2821        assert!(saved.contains("detailed_activity_clocks = true"));
2822        assert!(
2823            Config::load_from(&path)
2824                .unwrap()
2825                .advanced
2826                .detailed_activity_clocks
2827        );
2828    }
2829
2830    #[test]
2831    fn every_previous_config_version_upgrades_with_compatible_defaults() {
2832        let directory = tempfile::tempdir().unwrap();
2833        let path = directory.path().join("config.toml");
2834        for version in 1..CONFIG_VERSION {
2835            fs::write(&path, format!("version = {version}\n")).unwrap();
2836            let config = Config::load_from(&path).unwrap();
2837            assert_eq!(config.version, CONFIG_VERSION);
2838            assert!(!config.show_stopped_sessions);
2839            assert_eq!(config.theme, UiTheme::Midnight);
2840            assert!(!config.advanced.detailed_activity_clocks);
2841            assert!(!config.advanced.show_stopped_sessions);
2842        }
2843    }
2844
2845    #[test]
2846    fn spinner_preferences_round_trip_without_replacing_other_settings() {
2847        let directory = tempfile::tempdir().unwrap();
2848        let path = directory.path().join("config.toml");
2849        let mut config = sample_config();
2850        config.phone.enabled = false;
2851        config.save_to(&path).unwrap();
2852
2853        for spinner in SpinnerStyle::ALL {
2854            Config::update_to(&path, |config| {
2855                config.spinner = spinner;
2856                Ok(())
2857            })
2858            .unwrap();
2859            let reloaded = Config::load_from(&path).unwrap();
2860            assert_eq!(reloaded.spinner, spinner);
2861            assert_eq!(reloaded.phone, config.phone);
2862            assert_eq!(reloaded.profiles, config.profiles);
2863        }
2864    }
2865
2866    #[test]
2867    fn theme_preferences_upgrade_and_round_trip_without_replacing_other_settings() {
2868        let directory = tempfile::tempdir().unwrap();
2869        let path = directory.path().join("config.toml");
2870        let old = "version = 4\nshow_stopped_sessions = false\n";
2871        fs::write(&path, old).unwrap();
2872        let config = Config::load_from(&path).unwrap();
2873        assert_eq!(config.theme, UiTheme::Midnight);
2874        assert_eq!(config.version, CONFIG_VERSION);
2875        assert_eq!(fs::read_to_string(&path).unwrap(), old);
2876
2877        for theme in UiTheme::ALL {
2878            Config::update_to(&path, |config| {
2879                config.theme = theme;
2880                Ok(())
2881            })
2882            .unwrap();
2883            let mut expected = config.clone();
2884            expected.theme = theme;
2885            assert_eq!(Config::load_from(&path).unwrap(), expected);
2886        }
2887    }
2888
2889    #[test]
2890    fn legacy_dracula_theme_loads_and_saves_as_darcula() {
2891        let directory = tempfile::tempdir().unwrap();
2892        let path = directory.path().join("config.toml");
2893        fs::write(
2894            &path,
2895            format!("version = {CONFIG_VERSION}\ntheme = \"dracula\"\n"),
2896        )
2897        .unwrap();
2898
2899        let config = Config::load_from(&path).unwrap();
2900        assert_eq!(config.theme, UiTheme::Darcula);
2901        assert_eq!(UiTheme::ALL.len(), 4);
2902        config.save_to(&path).unwrap();
2903        let saved = fs::read_to_string(&path).unwrap();
2904        assert!(saved.contains("theme = \"darcula\""), "{saved}");
2905        assert!(!saved.contains("dracula"), "{saved}");
2906    }
2907
2908    #[test]
2909    fn unknown_theme_is_rejected_but_newer_configs_salvage_known_themes() {
2910        let directory = tempfile::tempdir().unwrap();
2911        let path = directory.path().join("config.toml");
2912        fs::write(
2913            &path,
2914            format!("version = {CONFIG_VERSION}\ntheme = \"unknown\"\n"),
2915        )
2916        .unwrap();
2917        assert!(Config::load_from(&path).is_err());
2918
2919        let newer = format!(
2920            "version = {}\ntheme = \"light\"\nfuture = true\n",
2921            CONFIG_VERSION + 1
2922        );
2923        fs::write(&path, &newer).unwrap();
2924        let config = Config::load_from(&path).unwrap();
2925        assert_eq!(config.theme, UiTheme::Light);
2926        assert!(config.save_to(&path).is_err());
2927        assert_eq!(fs::read_to_string(&path).unwrap(), newer);
2928    }
2929
2930    #[test]
2931    fn explicit_container_layer_and_host_helper_storage_round_trip() {
2932        let directory = tempfile::tempdir().unwrap();
2933        let path = directory.path().join("config.toml");
2934        let mut config = sample_config();
2935        if let TargetTemplate::LocalPodman { container } =
2936            config.targets.get_mut("podman-default").unwrap()
2937        {
2938            container.workspace_storage = PodmanWorkspaceStorage::HostHelper {
2939                root: PathBuf::from("/srv/mj-workspaces"),
2940                helper: vec!["sudo".into(), "-n".into(), "/opt/mj-helper".into()],
2941            };
2942        }
2943        config.save_to(&path).unwrap();
2944        assert_eq!(Config::load_from(&path).unwrap(), config);
2945
2946        if let TargetTemplate::LocalPodman { container } =
2947            config.targets.get_mut("podman-default").unwrap()
2948        {
2949            container.workspace_storage = PodmanWorkspaceStorage::ContainerLayer;
2950        }
2951        config.save_to(&path).unwrap();
2952        assert_eq!(Config::load_from(&path).unwrap(), config);
2953    }
2954
2955    #[test]
2956    fn local_docker_target_round_trips_with_its_public_kind() {
2957        let directory = tempfile::tempdir().unwrap();
2958        let path = directory.path().join("config.toml");
2959        let mut config = sample_config();
2960        let container = match config.targets.remove("podman-default").unwrap() {
2961            TargetTemplate::LocalPodman { container } => container,
2962            _ => unreachable!(),
2963        };
2964        config
2965            .targets
2966            .insert("docker".into(), TargetTemplate::LocalDocker { container });
2967
2968        config.save_to(&path).unwrap();
2969
2970        let rendered = fs::read_to_string(&path).unwrap();
2971        assert!(rendered.contains("kind = \"local-docker\""), "{rendered}");
2972        assert_eq!(Config::load_from(&path).unwrap(), config);
2973    }
2974
2975    #[test]
2976    fn setup_can_add_an_alternative_to_a_maximum_length_target_name() {
2977        let id = "x".repeat(64);
2978        let mut original = Config::default();
2979        original
2980            .targets
2981            .insert(id.clone(), TargetTemplate::LocalBare);
2982        let mut discovered = Config::default();
2983        discovered
2984            .targets
2985            .insert(id, sample_config().targets["podman-default"].clone());
2986        let additions = original.setup_additions(&discovered);
2987        additions.validate().unwrap();
2988        assert_eq!(additions.targets.len(), 1);
2989        original.targets.extend(additions.targets);
2990        assert_eq!(original.targets.len(), 2);
2991        assert!(original.setup_additions(&discovered).targets.is_empty());
2992    }
2993
2994    #[test]
2995    fn explicit_image_pull_policy_round_trips() {
2996        let directory = tempfile::tempdir().unwrap();
2997        let path = directory.path().join("config.toml");
2998        let mut config = sample_config();
2999        let TargetTemplate::LocalPodman { container } =
3000            config.targets.get_mut("podman-default").unwrap()
3001        else {
3002            unreachable!()
3003        };
3004        container.pull_policy = ImagePullPolicy::Never;
3005
3006        config.save_to(&path).unwrap();
3007
3008        assert!(
3009            fs::read_to_string(&path)
3010                .unwrap()
3011                .contains("pull_policy = \"never\"")
3012        );
3013        assert_eq!(Config::load_from(&path).unwrap(), config);
3014    }
3015
3016    #[test]
3017    fn raw_ssh_permissions_are_required_and_podman_rejects_them() {
3018        let directory = tempfile::tempdir().unwrap();
3019        let path = directory.path().join("config.toml");
3020        let mut config = sample_config();
3021        let container = match config.targets.remove("podman-default").unwrap() {
3022            TargetTemplate::LocalPodman { container } => container,
3023            _ => unreachable!(),
3024        };
3025        let ssh = SshConnection {
3026            host: "builder".into(),
3027            user: None,
3028            identity_file: None,
3029            extra_args: Vec::new(),
3030        };
3031        config.targets = BTreeMap::from([
3032            (
3033                "builder-guardian".into(),
3034                TargetTemplate::SshBare {
3035                    ssh: ssh.clone(),
3036                    permissions: PermissionMode::Guardian,
3037                    workspace_prefix: default_named_machine_prefix(),
3038                },
3039            ),
3040            (
3041                "builder-yolo".into(),
3042                TargetTemplate::SshBare {
3043                    ssh: ssh.clone(),
3044                    permissions: PermissionMode::Yolo,
3045                    workspace_prefix: default_named_machine_prefix(),
3046                },
3047            ),
3048            (
3049                "builder-podman".into(),
3050                TargetTemplate::SshPodman { ssh, container },
3051            ),
3052        ]);
3053
3054        config.save_to(&path).unwrap();
3055
3056        let body = fs::read_to_string(&path).unwrap();
3057        assert!(body.contains("permissions = \"guardian\""), "{body}");
3058        assert!(body.contains("permissions = \"yolo\""), "{body}");
3059        assert_eq!(body.matches("permissions = ").count(), 2, "{body}");
3060        assert_eq!(Config::load_from(&path).unwrap(), config);
3061
3062        fs::write(
3063            &path,
3064            "version = 1\n[targets.builder]\nkind = \"ssh-bare\"\nhost = \"builder\"\n",
3065        )
3066        .unwrap();
3067        let error = format!("{:#}", Config::load_from(&path).unwrap_err());
3068        assert!(error.contains("permissions"), "{error}");
3069
3070        fs::write(
3071            &path,
3072            "version = 1\n[targets.builder]\nkind = \"ssh-podman\"\nhost = \"builder\"\npermissions = \"guardian\"\nimage = \"example.invalid/agent:latest\"\n",
3073        )
3074        .unwrap();
3075        let error = format!("{:#}", Config::load_from(&path).unwrap_err());
3076        assert!(error.contains("only valid for ssh-bare"), "{error}");
3077    }
3078
3079    #[test]
3080    fn missing_config_uses_clean_v1_defaults() {
3081        let directory = tempfile::tempdir().unwrap();
3082        let config = Config::load_from(&directory.path().join("missing.toml")).unwrap();
3083        assert_eq!(config, Config::default());
3084        assert!(config.phone.enabled);
3085        assert!(config.phone.tailscale_detect);
3086    }
3087
3088    #[test]
3089    fn omitted_phone_fields_enable_the_web_viewer_and_tailscale_detection() {
3090        let directory = tempfile::tempdir().unwrap();
3091        let path = directory.path().join("config.toml");
3092        fs::write(&path, "version = 1\n[phone]\nbind = \"127.0.0.1:4765\"\n").unwrap();
3093
3094        let config = Config::load_from(&path).unwrap();
3095
3096        assert!(config.phone.enabled);
3097        assert!(config.phone.tailscale_detect);
3098        assert_eq!(config.phone.bind, "127.0.0.1:4765");
3099    }
3100
3101    #[test]
3102    fn version_seven_profiles_upgrade_enabled_and_disabled_round_trips_explicitly() {
3103        let directory = tempfile::tempdir().unwrap();
3104        let path = directory.path().join("config.toml");
3105        fs::write(
3106            &path,
3107            "version = 7\n[profiles.work]\nkind = \"codex\"\nhome = \"/profiles/work\"\n",
3108        )
3109        .unwrap();
3110
3111        let mut config = Config::load_from(&path).unwrap();
3112        assert_eq!(config.version, CONFIG_VERSION);
3113        assert!(config.profiles["work"].enabled);
3114        assert_eq!(
3115            config
3116                .enabled_profiles()
3117                .map(|(id, _)| id)
3118                .collect::<Vec<_>>(),
3119            vec!["work"]
3120        );
3121
3122        config.save_to(&path).unwrap();
3123        let enabled = fs::read_to_string(&path).unwrap();
3124        assert!(
3125            enabled.starts_with(&format!("version = {CONFIG_VERSION}")),
3126            "{enabled}"
3127        );
3128        assert!(!enabled.contains("enabled = true"), "{enabled}");
3129
3130        config.profiles.get_mut("work").unwrap().enabled = false;
3131        config.save_to(&path).unwrap();
3132        let disabled = fs::read_to_string(&path).unwrap();
3133        assert!(disabled.contains("enabled = false"), "{disabled}");
3134        assert!(!Config::load_from(&path).unwrap().profiles["work"].enabled);
3135    }
3136
3137    #[test]
3138    fn review_rejects_disabled_profile_references() {
3139        let profile =
3140            "[profiles.work]\nenabled = false\nkind = \"claude\"\nhome = \"/profiles/work\"\n";
3141        let reference = "[review]\nprofile = \"work\"\n";
3142        let error =
3143            toml::from_str::<Config>(&format!("version = {CONFIG_VERSION}\n{reference}{profile}"))
3144                .unwrap()
3145                .validate()
3146                .unwrap_err()
3147                .to_string();
3148        assert!(error.contains("disabled"), "{error}");
3149    }
3150
3151    #[test]
3152    fn version_eight_enables_parent_only_subagents_by_default() {
3153        let directory = tempfile::tempdir().unwrap();
3154        let path = directory.path().join("config.toml");
3155        fs::write(
3156            &path,
3157            "version = 8\n[profiles.work]\nkind = \"codex\"\nhome = \"/profiles/work\"\n",
3158        )
3159        .unwrap();
3160
3161        let config = Config::load_from(&path).unwrap();
3162
3163        assert_eq!(config.version, CONFIG_VERSION);
3164        assert!(config.subagents.enabled);
3165        assert_eq!(config.subagents.max_concurrent, 6);
3166        assert!(config.subagents.eligible_profiles.is_empty());
3167        assert!(config.subagents.profile_is_eligible("work", "work"));
3168        assert!(!config.subagents.profile_is_eligible("work", "other"));
3169    }
3170
3171    #[test]
3172    fn subagents_reject_invalid_limits_and_unavailable_profiles() {
3173        let profile =
3174            "[profiles.work]\nenabled = false\nkind = \"grok\"\nhome = \"/profiles/work\"\n";
3175        for section in [
3176            "[subagents]\nmax_concurrent = 0\n",
3177            "[subagents.eligible_profiles]\nmissing = true\n",
3178            "[subagents.eligible_profiles]\nwork = true\n",
3179        ] {
3180            let error = toml::from_str::<Config>(&format!(
3181                "version = {CONFIG_VERSION}\n{section}{profile}"
3182            ))
3183            .unwrap()
3184            .validate()
3185            .unwrap_err()
3186            .to_string();
3187            assert!(
3188                error.contains("max_concurrent")
3189                    || error.contains("not defined")
3190                    || error.contains("disabled"),
3191                "{error}"
3192            );
3193        }
3194    }
3195
3196    /// A profile that exists, so a `[review]` section has something to name.
3197    fn config_with_profile(profile: &str) -> String {
3198        format!(
3199            "version = 1\n\n[profiles.{profile}]\nkind = \"claude\"\nhome = \"/home/u/.claude\"\n"
3200        )
3201    }
3202
3203    #[test]
3204    fn review_is_off_and_quick_until_the_config_says_otherwise() {
3205        let directory = tempfile::tempdir().unwrap();
3206        let path = directory.path().join("config.toml");
3207        fs::write(&path, config_with_profile("reviewer")).unwrap();
3208
3209        let config = Config::load_from(&path).unwrap();
3210
3211        assert!(!config.review.enabled, "review is opt-in");
3212        assert_eq!(config.review.tier, crate::review::lanes::ReviewTier::Quick);
3213        assert_eq!(config.review.reviewer_profile(), None);
3214    }
3215
3216    #[test]
3217    fn a_review_section_names_the_profile_that_reviews() {
3218        let directory = tempfile::tempdir().unwrap();
3219        let path = directory.path().join("config.toml");
3220        fs::write(
3221            &path,
3222            format!(
3223                "{}\n[review]\nenabled = true\ntier = \"extended\"\nprofile = \"reviewer\"\nmodel = \"opus\"\n",
3224                config_with_profile("reviewer")
3225            ),
3226        )
3227        .unwrap();
3228
3229        let config = Config::load_from(&path).unwrap();
3230
3231        assert!(config.review.enabled);
3232        assert_eq!(
3233            config.review.tier,
3234            crate::review::lanes::ReviewTier::Extended
3235        );
3236        assert_eq!(config.review.reviewer_profile(), Some("reviewer"));
3237        assert_eq!(config.review.model.as_deref(), Some("opus"));
3238        assert_eq!(config.review.effort, None);
3239    }
3240
3241    /// Arming review without naming a reviewer has no sensible default: Mjolnir
3242    /// will not choose which agent reviews on the user's behalf.
3243    #[test]
3244    fn arming_review_without_a_profile_is_refused() {
3245        let config = Config {
3246            review: ReviewConfig {
3247                enabled: true,
3248                ..ReviewConfig::default()
3249            },
3250            ..Config::default()
3251        };
3252        let error = config
3253            .validate()
3254            .expect_err("armed review needs a reviewer");
3255        assert!(
3256            format!("{error:#}").contains("needs `profile`"),
3257            "unexpected error: {error:#}"
3258        );
3259    }
3260
3261    #[test]
3262    fn a_review_profile_that_names_nothing_is_refused() {
3263        let config = Config {
3264            review: ReviewConfig {
3265                profile: Some("missing".into()),
3266                ..ReviewConfig::default()
3267            },
3268            ..Config::default()
3269        };
3270        let error = config
3271            .validate()
3272            .expect_err("a reviewer must be a profile in this file");
3273        assert!(
3274            format!("{error:#}").contains("not a profile defined in this config"),
3275            "unexpected error: {error:#}"
3276        );
3277    }
3278
3279    /// A one-off `/review` needs a reviewer without automatic review, so a
3280    /// profile with `enabled = false` is a valid configuration.
3281    #[test]
3282    fn a_reviewer_without_automatic_review_is_valid() {
3283        let directory = tempfile::tempdir().unwrap();
3284        let path = directory.path().join("config.toml");
3285        fs::write(
3286            &path,
3287            format!(
3288                "{}\n[review]\nprofile = \"reviewer\"\n",
3289                config_with_profile("reviewer")
3290            ),
3291        )
3292        .unwrap();
3293
3294        let config = Config::load_from(&path).unwrap();
3295        assert!(!config.review.enabled);
3296        assert_eq!(config.review.reviewer_profile(), Some("reviewer"));
3297    }
3298
3299    /// A review section that survives salvage is one whose profile also
3300    /// survived: the section is only usable if its reviewer exists.
3301    #[test]
3302    fn salvage_keeps_a_review_section_whose_profile_survived() {
3303        let directory = tempfile::tempdir().unwrap();
3304        let path = directory.path().join("config.toml");
3305        fs::write(
3306            &path,
3307            format!(
3308                "version = 9999\n{}\n[review]\nenabled = true\nprofile = \"reviewer\"\n",
3309                config_with_profile("reviewer")
3310                    .strip_prefix("version = 1\n")
3311                    .unwrap()
3312            ),
3313        )
3314        .unwrap();
3315
3316        let config = Config::load_from(&path).unwrap();
3317        assert_eq!(config.newer_config_version, Some(9999));
3318        assert!(config.review.enabled);
3319        assert_eq!(config.review.reviewer_profile(), Some("reviewer"));
3320    }
3321
3322    #[test]
3323    fn salvage_drops_a_review_section_whose_profile_did_not_survive() {
3324        let directory = tempfile::tempdir().unwrap();
3325        let path = directory.path().join("config.toml");
3326        fs::write(
3327            &path,
3328            "version = 9999\n[review]\nenabled = true\nprofile = \"gone\"\n",
3329        )
3330        .unwrap();
3331
3332        let config = Config::load_from(&path).unwrap();
3333        assert_eq!(config.review, ReviewConfig::default());
3334    }
3335
3336    #[test]
3337    fn explicit_web_viewer_opt_out_survives_serialization() {
3338        let directory = tempfile::tempdir().unwrap();
3339        let path = directory.path().join("config.toml");
3340        let mut config = Config::default();
3341        config.phone.enabled = false;
3342        config.phone.tailscale_detect = false;
3343
3344        config.save_to(&path).unwrap();
3345        let body = fs::read_to_string(&path).unwrap();
3346
3347        assert!(body.contains("enabled = false"), "{body}");
3348        assert!(body.contains("tailscale_detect = false"), "{body}");
3349        assert_eq!(Config::load_from(&path).unwrap(), config);
3350    }
3351
3352    #[test]
3353    fn phone_config_requires_tls_off_loopback_and_complete_key_pairs() {
3354        let mut config = Config::default();
3355        config.phone.enabled = true;
3356        config.phone.bind = "0.0.0.0:3765".into();
3357        assert!(config.validate().unwrap_err().to_string().contains("TLS"));
3358
3359        config.phone.tls_cert = Some(PathBuf::from("certificate.pem"));
3360        assert!(config.validate().unwrap_err().to_string().contains("both"));
3361        config.phone.tls_key = Some(PathBuf::from("private-key.pem"));
3362        config.validate().unwrap();
3363    }
3364
3365    #[test]
3366    fn empty_config_uses_clean_v1_defaults() {
3367        let directory = tempfile::tempdir().unwrap();
3368        let path = directory.path().join("config.toml");
3369        fs::write(&path, "\n\t").unwrap();
3370        assert_eq!(Config::load_from(&path).unwrap(), Config::default());
3371    }
3372
3373    #[test]
3374    fn newer_config_loads_read_only_instead_of_blocking_startup() {
3375        // Running a newer Mjolnir and then downgrading must not lock the user out
3376        // of the older build.
3377        let directory = tempfile::tempdir().unwrap();
3378        let path = directory.path().join("config.toml");
3379        let body = format!(
3380            "version = {}\nsetting_from_the_future = true\n\n[targets.localhost]\nkind = \
3381             \"local-bare\"\n",
3382            CONFIG_VERSION + 1
3383        );
3384        fs::write(&path, &body).unwrap();
3385
3386        let config = Config::load_from(&path).unwrap();
3387
3388        // The settings the newer build saved still work.
3389        assert_eq!(
3390            config.targets.get("localhost"),
3391            Some(&TargetTemplate::LocalBare)
3392        );
3393        assert_eq!(config.newer_config_version, Some(CONFIG_VERSION + 1));
3394        assert!(
3395            config
3396                .newer_build_notice()
3397                .is_some_and(|notice| notice.contains("newer Mjolnir"))
3398        );
3399
3400        // Saving would downgrade the newer build's file, so it must refuse and
3401        // leave the file byte for byte as it was.
3402        let error = config.save_to(&path).unwrap_err().to_string();
3403        assert!(error.contains("newer Mjolnir"), "{error}");
3404        assert_eq!(fs::read_to_string(&path).unwrap(), body);
3405    }
3406
3407    #[test]
3408    fn newer_config_keeps_the_sections_this_build_still_understands() {
3409        // A future release reshapes one target and adds a section. Only the
3410        // reshaped target is lost; everything else still loads.
3411        let directory = tempfile::tempdir().unwrap();
3412        let path = directory.path().join("config.toml");
3413        fs::write(
3414            &path,
3415            format!(
3416                "version = {}\n\n[future_section]\nwhatever = 1\n\n[profiles.codex-1]\nkind \
3417                 = \"codex\"\nhome = \"/home/test/.codex-one\"\n\n[targets.localhost]\nkind \
3418                 = \"local-bare\"\n\n[targets.future]\nkind = \"quantum-sandbox\"\n",
3419                CONFIG_VERSION + 1
3420            ),
3421        )
3422        .unwrap();
3423
3424        let config = Config::load_from(&path).unwrap();
3425
3426        assert!(config.profiles.contains_key("codex-1"));
3427        assert_eq!(
3428            config.targets.get("localhost"),
3429            Some(&TargetTemplate::LocalBare)
3430        );
3431        assert!(!config.targets.contains_key("future"));
3432        assert_eq!(config.newer_config_version, Some(CONFIG_VERSION + 1));
3433    }
3434
3435    #[test]
3436    fn a_newer_config_written_after_load_still_blocks_a_save() {
3437        // Another Hel may upgrade the file between this build's load and its
3438        // save; the save must re-check the file rather than trust its marker.
3439        let directory = tempfile::tempdir().unwrap();
3440        let path = directory.path().join("config.toml");
3441        let config = sample_config();
3442        config.save_to(&path).unwrap();
3443
3444        let body = format!("version = {}\n", CONFIG_VERSION + 1);
3445        fs::write(&path, &body).unwrap();
3446
3447        let error = config.save_to(&path).unwrap_err().to_string();
3448        assert!(error.contains("newer Mjolnir"), "{error}");
3449        assert_eq!(fs::read_to_string(&path).unwrap(), body);
3450    }
3451
3452    #[test]
3453    fn the_legacy_localhost_rename_leaves_a_newer_config_alone() {
3454        // The rename runs at daemon startup and used to be a save; against a
3455        // read-only config it must skip instead of failing startup.
3456        let directory = tempfile::tempdir().unwrap();
3457        let path = directory.path().join("config.toml");
3458        let body = format!(
3459            "version = {}\n\n[targets.raw-localhost]\nkind = \"local-bare\"\n",
3460            CONFIG_VERSION + 1
3461        );
3462        fs::write(&path, &body).unwrap();
3463
3464        assert!(!Config::migrate_legacy_localhost_target_at(&path).unwrap());
3465        assert_eq!(fs::read_to_string(&path).unwrap(), body);
3466    }
3467
3468    #[test]
3469    fn an_older_config_version_is_still_rejected() {
3470        // Hel has no downgrade migration, so an unrecognized older schema
3471        // keeps reporting an error rather than guessing.
3472        let directory = tempfile::tempdir().unwrap();
3473        let path = directory.path().join("config.toml");
3474        fs::write(&path, "version = 0\n").unwrap();
3475
3476        let error = Config::load_from(&path).unwrap_err().to_string();
3477        assert!(
3478            error.contains("unsupported Mjolnir config version 0"),
3479            "{error}"
3480        );
3481    }
3482
3483    #[test]
3484    fn a_malformed_newer_config_is_still_an_error() {
3485        let directory = tempfile::tempdir().unwrap();
3486        let path = directory.path().join("config.toml");
3487        fs::write(&path, "version = 2\nthis is not toml\n").unwrap();
3488
3489        let error = Config::load_from(&path).unwrap_err().to_string();
3490        assert!(error.contains("parse Mjolnir config"), "{error}");
3491    }
3492
3493    #[test]
3494    fn removed_profile_overrides_have_an_actionable_error() {
3495        let directory = tempfile::tempdir().unwrap();
3496        let path = directory.path().join("config.toml");
3497        fs::write(
3498            &path,
3499            "version = 1\n[profiles.codex]\nkind = \"codex\"\nhome = \"/tmp/codex\"\nmodel = \"gpt-old\"\n",
3500        )
3501        .unwrap();
3502        let error = Config::load_from(&path).unwrap_err().to_string();
3503        assert!(error.contains("`model` is no longer supported"));
3504        assert!(error.contains("/config"));
3505    }
3506
3507    #[test]
3508    fn profile_cannot_override_its_isolated_home() {
3509        let mut config = sample_config();
3510        config
3511            .profiles
3512            .get_mut("codex-1")
3513            .unwrap()
3514            .environment
3515            .insert("CODEX_HOME".into(), "/shared-and-racy".into());
3516        assert!(
3517            config
3518                .validate()
3519                .unwrap_err()
3520                .to_string()
3521                .contains("must use `home`")
3522        );
3523    }
3524
3525    #[test]
3526    fn container_size_hosts_group_local_runtimes_and_exact_ssh_hosts() {
3527        let container = ContainerTemplate {
3528            image: "agent:latest".into(),
3529            pull_policy: Default::default(),
3530            platform: None,
3531            cpus: None,
3532            memory: None,
3533            environment: BTreeMap::new(),
3534            workspace_storage: Default::default(),
3535        };
3536        let podman = TargetTemplate::LocalPodman {
3537            container: container.clone(),
3538        };
3539        let apple = TargetTemplate::AppleContainer {
3540            container: container.clone(),
3541        };
3542        let ssh = TargetTemplate::SshPodman {
3543            ssh: SshConnection {
3544                host: "builder.example.test".into(),
3545                user: Some("dev".into()),
3546                identity_file: None,
3547                extra_args: Vec::new(),
3548            },
3549            container,
3550        };
3551
3552        assert_eq!(container_size_host(&podman), Some("local"));
3553        assert_eq!(container_size_host(&apple), Some("local"));
3554        assert_eq!(container_size_host(&ssh), Some("builder.example.test"));
3555        assert_eq!(container_size_host(&TargetTemplate::LocalBare), None);
3556    }
3557    #[test]
3558    fn ssh_docker_target_round_trips_and_rejects_podman_storage() {
3559        let text = r#"kind = "ssh-docker"
3560host = "builder"
3561user = "ubuntu"
3562image = "ubuntu:24.04"
3563"#;
3564        let target: TargetTemplate = toml::from_str(text).unwrap();
3565        target.validate("remote-docker").unwrap();
3566        assert_eq!(
3567            toml::from_str::<TargetTemplate>(&toml::to_string(&target).unwrap()).unwrap(),
3568            target
3569        );
3570        assert_eq!(container_size_host(&target), Some("builder"));
3571        let TargetTemplate::SshDocker { ssh, mut container } = target else {
3572            panic!("wrong kind")
3573        };
3574        container.workspace_storage = PodmanWorkspaceStorage::ContainerLayer;
3575        assert!(
3576            TargetTemplate::SshDocker { ssh, container }
3577                .validate("remote-docker")
3578                .unwrap_err()
3579                .to_string()
3580                .contains("only supported by Podman")
3581        );
3582    }
3583
3584    #[test]
3585    fn instance_names_accept_single_segment_identifiers() {
3586        for valid in ["dev", "dev-2", "x.y_z", "A1", "a".repeat(64).as_str()] {
3587            assert!(is_valid_instance_name(valid), "rejects valid {valid:?}");
3588        }
3589    }
3590
3591    #[test]
3592    fn instance_names_reject_empty_and_path_escapes() {
3593        for invalid in [
3594            "",
3595            "   ",
3596            ".",
3597            "..",
3598            "dev/dev",
3599            "../evil",
3600            "..\\evil",
3601            "has space",
3602            "semi;colon",
3603            "uniçode",
3604            "a".repeat(65).as_str(),
3605        ] {
3606            assert!(
3607                !is_valid_instance_name(invalid),
3608                "accepts invalid {invalid:?}"
3609            );
3610        }
3611    }
3612
3613    #[test]
3614    fn apply_instance_flag_rejects_bad_names_without_touching_the_environment() {
3615        // Validation runs before any environment mutation, so these cases
3616        // cannot leak state even though the environment is process-global.
3617        for invalid in ["", "../evil", "has space"] {
3618            let error = apply_instance_flag(Some(invalid)).unwrap_err();
3619            assert!(
3620                error.to_string().contains("invalid instance id"),
3621                "unexpected error for {invalid:?}: {error:#}"
3622            );
3623        }
3624    }
3625
3626    #[test]
3627    fn instance_directories_nest_under_instances_and_reject_escapes() {
3628        let base = PathBuf::from("/base/mjolnir");
3629        assert_eq!(
3630            with_instance_dir(base.clone(), Some("dev")),
3631            PathBuf::from("/base/mjolnir/instances/dev")
3632        );
3633        assert_eq!(with_instance_dir(base.clone(), None), base);
3634        // An invalid name never becomes a path segment, even if a future
3635        // caller skips startup validation: it falls back to the base directory.
3636        assert_eq!(with_instance_dir(base.clone(), Some("../evil")), base);
3637        assert_eq!(with_instance_dir(base.clone(), Some("")), base);
3638    }
3639}