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