1use 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
16pub 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
90#[serde(deny_unknown_fields)]
91pub struct ReviewConfig {
92 #[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 #[serde(default, skip_serializing_if = "Option::is_none")]
101 pub profile: Option<String>,
102 #[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 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 #[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#[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 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
255#[serde(rename_all = "snake_case")]
256pub enum ExecutionPolicy {
257 ConfiguredApprovals,
259 Unconstrained,
261}
262
263#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
265#[serde(rename_all = "lowercase")]
266pub enum PermissionMode {
267 Guardian,
269 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#[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 launch_argument: Option<(&'static str, &'static str)>,
299 session_sandbox: Option<bool>,
301 staged_setting: Option<StagedSetting>,
303}
304
305#[derive(Debug, Clone, Copy, PartialEq, Eq)]
308pub struct StagedSetting {
309 pub file: &'static str,
311 pub path: &'static [&'static str],
313 pub value: &'static str,
314 pub object_version: Option<(&'static str, u64)>,
317}
318
319impl StagedSetting {
320 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 pub const fn label(self) -> &'static str {
353 self.label
354 }
355
356 pub const fn acp_mode(self) -> Option<&'static str> {
358 self.acp_mode
359 }
360
361 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 pub const fn launch_argument(self) -> Option<(&'static str, &'static str)> {
373 self.launch_argument
374 }
375
376 pub const fn session_sandbox(self) -> Option<bool> {
378 self.session_sandbox
379 }
380
381 pub const fn staged_setting(self) -> Option<StagedSetting> {
383 self.staged_setting
384 }
385}
386
387pub 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 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 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 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 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 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 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 (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 (_, 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 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 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 pub const fn effective_execution_policy(self, target: ExecutionPolicy) -> ExecutionPolicy {
664 match self {
665 Self::Muse => ExecutionPolicy::Unconstrained,
666 _ => target,
667 }
668 }
669
670 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 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 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 #[serde(default = "default_true", skip_serializing_if = "is_true")]
719 pub enabled: bool,
720 pub kind: HarnessKind,
721 pub home: PathBuf,
723 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
724 pub environment: BTreeMap<String, String>,
725 #[serde(default, skip_serializing_if = "Option::is_none")]
728 pub context_window_bytes: Option<usize>,
729}
730
731impl HarnessProfile {
732 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 pub id: String,
788 #[serde(default, skip_serializing_if = "Option::is_none")]
790 pub github: Option<String>,
791 #[serde(default, skip_serializing_if = "Option::is_none")]
793 pub local: Option<PathBuf>,
794 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 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 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
1165pub fn is_bare_project_target(template: &TargetTemplate) -> bool {
1169 matches!(
1170 template,
1171 TargetTemplate::LocalBare | TargetTemplate::SshBare { .. }
1172 )
1173}
1174
1175pub 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
1190pub 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
1206pub 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
1231fn 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 Pulse,
1244 Wave,
1246 Bars,
1248 Shimmer,
1250 Globe,
1252 #[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 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#[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#[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 #[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 #[serde(skip)]
1405 pub newer_config_version: Option<u32>,
1406 #[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 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 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 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 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 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 if matches!(config.version, 1..=8) {
1583 config.version = CONFIG_VERSION;
1584 }
1585 config.validate()?;
1586 Ok(config)
1587 }
1588
1589 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 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 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 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 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 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 pub fn save_review(review: ReviewConfig) -> Result<Self> {
1695 Self::save_review_to(&config_path(), review)
1696 }
1697
1698 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 pub fn save_to(&self, path: &Path) -> Result<()> {
1712 let _lock = ConfigLock::acquire(path)?;
1713 self.save_to_locked(path)
1714 }
1715
1716 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 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 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
1779struct 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
1845fn 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
1851fn 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
1858fn 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
1867fn 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
1922pub fn env_override_os(name: &str) -> Option<std::ffi::OsString> {
1925 std::env::var_os(format!("MJ_{name}"))
1926}
1927
1928pub fn env_override(name: &str) -> Option<String> {
1930 std::env::var(format!("MJ_{name}")).ok()
1931}
1932
1933pub const INSTANCE_ENV: &str = "MJ_INSTANCE";
1937
1938const INSTANCE_DIR: &str = "instances";
1941
1942pub 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
1949pub fn is_valid_instance_name(name: &str) -> bool {
1951 validate_id("instance", name).is_ok()
1952}
1953
1954pub fn validate_instance_env() -> Result<()> {
1958 if let Some(name) = instance_name() {
1959 validate_id("instance", &name)?;
1960 }
1961 Ok(())
1962}
1963
1964pub 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 unsafe {
1974 std::env::set_var(INSTANCE_ENV, name);
1975 }
1976 }
1977 validate_instance_env()
1978}
1979
1980fn 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
2023pub 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 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
2084pub fn atomic_write(path: &Path, body: &[u8]) -> Result<()> {
2086 atomic_write_with_parent(path, body, ParentDirectory::Create)
2087}
2088
2089pub 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 #[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 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 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 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 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 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 #[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 #[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 #[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 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 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 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 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 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 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 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 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 assert_eq!(with_instance_dir(base.clone(), Some("../evil")), base);
3853 assert_eq!(with_instance_dir(base.clone(), Some("")), base);
3854 }
3855}