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