1use std::collections::{BTreeMap, BTreeSet};
4use std::io::{self, BufRead, Write};
5use std::path::{Path, PathBuf};
6use std::time::{Duration, SystemTime, UNIX_EPOCH};
7
8use anyhow::{Context, Result, ensure};
9
10use crate::doctor::{
11 CheckStatus, DoctorCheck, DoctorOptions, all_ready, apple_container_daemon_check,
12 current_apple_platform, local_docker_runtime_check, local_podman_runtime_check, probe_executor,
13 render_human, run_with_config_path,
14};
15use crate::targets::{
16 CancellableProcessExecutor, CommandExecutor, CommandSpec,
17 ContainerTemplate as RuntimeContainerTemplate, ProcessExecutor,
18 TargetTemplate as RuntimeTargetTemplate, run_setup_smoke_test,
19};
20use mj_core::config::{
21 AwsAddressSource, Config, ContainerTemplate, HarnessKind, HarnessProfile, PermissionMode,
22 ProjectBundle, ProjectRepository, SshConnection, TargetTemplate, unique_config_id as unique_id,
23 validate_id,
24};
25
26const AWS_PROBE_TIMEOUT: Duration = Duration::from_secs(8);
29
30const DEFAULT_AWS_SSH_USER: &str = "ubuntu";
33const AWS_TARGET_ID: &str = "aws";
34
35pub use mj_client::target::DEFAULT_IMAGE;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct DiscoveredHome {
43 pub kind: HarnessKind,
44 pub path: PathBuf,
45 pub authenticated: bool,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq)]
49pub struct GithubRepository {
50 pub owner: String,
51 pub repository: String,
52}
53
54impl GithubRepository {
55 fn source(&self) -> String {
56 format!("{}/{}", self.owner, self.repository)
57 }
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum RuntimeKind {
62 Podman,
63 Docker,
64 AppleContainer,
65}
66
67impl RuntimeKind {
68 fn id(self) -> &'static str {
69 match self {
70 Self::Podman => "podman",
71 Self::Docker => "docker",
72 Self::AppleContainer => "apple-container",
73 }
74 }
75
76 fn label(self) -> &'static str {
77 match self {
78 Self::Podman => "Podman",
79 Self::Docker => "Docker",
80 Self::AppleContainer => "Apple container",
81 }
82 }
83}
84
85#[derive(Debug, Clone, PartialEq, Eq)]
86pub struct RuntimeProbe {
87 pub kind: RuntimeKind,
88 pub usable: bool,
89 pub detail: String,
90 pub remediation: Option<String>,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct AwsAccount {
98 pub account: String,
99 pub arn: String,
100 pub region: Option<String>,
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct AwsTargetInput {
107 pub launch_template: String,
108 pub region: String,
109 pub ssh_user: String,
110 pub identity_file: Option<PathBuf>,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum SshTargetKind {
116 Bare { permissions: PermissionMode },
117 Podman { image: String },
118 Docker { image: String },
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct SshTargetInput {
124 pub name: String,
125 pub host: String,
126 pub kind: SshTargetKind,
127}
128
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct SetupDiscovery {
131 pub homes: Vec<DiscoveredHome>,
132 pub repository: Option<GithubRepository>,
133 pub runtimes: Vec<RuntimeProbe>,
134 pub aws: Option<AwsAccount>,
137 pub ssh_hosts: Vec<String>,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum SetupOutcome {
144 Written,
145 Cancelled,
146}
147
148pub fn initialize_local_startup_config(config_path: &Path) -> Result<()> {
152 #[cfg(unix)]
153 {
154 let config = Config::load_from(config_path)?;
155 if config.is_unconfigured() && config.newer_config_version.is_none() {
156 let kind = HarnessKind::Codex;
157 let home = std::env::var_os(kind.home_env())
158 .map(|value| kind.home_from_environment(value))
159 .or_else(|| dirs::home_dir().map(|home| home.join(kind.default_home_leaf())))
160 .context("locate Codex home for the default local profile")?;
161 let home = std::path::absolute(home).context("resolve Codex profile home")?;
162 Config::update_to(config_path, |fresh| {
163 if fresh.is_unconfigured() {
164 configure_local_startup(fresh, home);
165 }
166 Ok(())
167 })?;
168 }
169 }
170 #[cfg(not(unix))]
172 let _ = config_path;
173 Ok(())
174}
175
176#[cfg(unix)]
177fn configure_local_startup(config: &mut Config, codex_home: PathBuf) {
178 config.profiles.insert(
179 "codex".into(),
180 HarnessProfile {
181 enabled: true,
182 kind: HarnessKind::Codex,
183 home: codex_home,
184 environment: BTreeMap::new(),
185 context_window_bytes: None,
186 guardian_review_model: None,
187 },
188 );
189 config
190 .targets
191 .insert("localhost".into(), TargetTemplate::LocalBare);
192}
193
194pub fn run_setup_dialog(config_path: &Path) -> Result<SetupOutcome> {
196 let probes = probe_executor();
200 let discovery = discover_current(&probes);
201 let stdout = io::stdout();
202 let mut input = ReadlinePrompter::default();
203 run_setup_dialog_inner(
204 &mut input,
205 &mut stdout.lock(),
206 config_path,
207 &discovery,
208 &ProcessExecutor,
209 &probes,
210 )
211}
212
213pub fn discover_current(executor: &impl CommandExecutor) -> SetupDiscovery {
214 let home = dirs::home_dir();
215 let overrides = HarnessKind::ALL
216 .into_iter()
217 .filter_map(|kind| {
218 std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
219 })
220 .collect::<BTreeMap<_, _>>();
221 let mut homes =
222 discover_harness_homes_with_executor(home.as_deref(), overrides.clone(), executor);
223 discover_installed_harnesses(home.as_deref(), &overrides, &mut homes, executor);
224 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
225
226 SetupDiscovery {
227 homes,
228 repository: discover_github_repository(executor, &cwd),
229 runtimes: probe_local_runtimes(executor, cfg!(target_os = "macos")),
230 aws: detect_aws(&CancellableProcessExecutor::with_timeout(AWS_PROBE_TIMEOUT)),
231 ssh_hosts: discover_ssh_hosts(home.as_deref()),
232 }
233}
234
235pub fn discover_ssh_hosts(home: Option<&Path>) -> Vec<String> {
243 let Some(home) = home else {
244 return Vec::new();
245 };
246 let Ok(contents) = std::fs::read_to_string(home.join(".ssh").join("config")) else {
247 return Vec::new();
248 };
249 ssh_config_aliases(&contents)
250}
251
252pub fn ssh_config_aliases(contents: &str) -> Vec<String> {
257 let mut aliases: Vec<String> = Vec::new();
258 for line in contents.lines() {
259 let line = line.trim();
260 if line.is_empty() || line.starts_with('#') {
261 continue;
262 }
263 let Some((keyword, rest)) = line.split_once(char::is_whitespace) else {
264 continue;
265 };
266 if !keyword.eq_ignore_ascii_case("host") {
267 continue;
268 }
269 for alias in rest.split_whitespace() {
270 let alias = alias.trim_matches('"');
271 if alias.is_empty() || alias.contains(['*', '?', '!']) {
272 continue;
273 }
274 if !aliases.iter().any(|existing| existing == alias) {
275 aliases.push(alias.to_owned());
276 }
277 }
278 }
279 aliases
280}
281
282fn discover_installed_harnesses(
285 user_home: Option<&Path>,
286 overrides: &BTreeMap<HarnessKind, PathBuf>,
287 homes: &mut Vec<DiscoveredHome>,
288 executor: &impl CommandExecutor,
289) {
290 for kind in HarnessKind::ALL {
291 if homes.iter().any(|home| home.kind == kind) {
292 continue;
293 }
294 let Some(home) = overrides
295 .get(&kind)
296 .cloned()
297 .or_else(|| user_home.map(|home| home.join(kind.default_home_leaf())))
298 else {
299 continue;
300 };
301 let profile = HarnessProfile {
302 enabled: true,
303 kind,
304 home: home.clone(),
305 environment: BTreeMap::new(),
306 context_window_bytes: None,
307 guardian_review_model: None,
308 };
309 let (program, _) = mj_core::credentials::native_login_command(&profile);
310 let probe = CommandSpec::new(program, ["--version"])
311 .purpose("detect installed harness before first login");
312 match executor.execute(&probe) {
313 Ok(output) if output.status == 0 => homes.push(DiscoveredHome {
314 kind,
315 path: home,
316 authenticated: false,
317 }),
318 Ok(_) => {}
319 Err(error) => tracing::debug!(
320 harness = kind.id(),
321 "installation probe unavailable: {error:#}"
322 ),
323 }
324 }
325}
326
327pub fn discover_harness_homes(
328 home: Option<&Path>,
329 overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
330) -> Vec<DiscoveredHome> {
331 discover_harness_homes_with_executor(home, overrides, &probe_executor())
332}
333
334pub(crate) fn discover_harness_homes_with_executor(
335 home: Option<&Path>,
336 overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
337 executor: &impl CommandExecutor,
338) -> Vec<DiscoveredHome> {
339 let mut candidates = Vec::new();
340 if let Some(home) = home {
341 candidates.extend(
342 HarnessKind::ALL
343 .into_iter()
344 .map(|kind| (kind, home.join(kind.default_home_leaf()), true)),
345 );
346 }
347 candidates.extend(
348 overrides
349 .into_iter()
350 .map(|(kind, path)| (kind, path, false)),
351 );
352
353 let mut seen = BTreeSet::new();
354 candidates
355 .into_iter()
356 .filter(|(kind, path, _)| seen.insert((*kind, path.clone())) && path.is_dir())
357 .map(|(kind, path, is_default_home)| DiscoveredHome {
358 authenticated: harness_is_authenticated_with(
359 &probe_profile(kind, &path),
360 is_default_home,
361 executor,
362 ),
363 kind,
364 path,
365 })
366 .collect()
367}
368
369fn probe_profile(kind: HarnessKind, home: &Path) -> HarnessProfile {
373 HarnessProfile {
374 enabled: true,
375 kind,
376 home: home.to_path_buf(),
377 environment: BTreeMap::new(),
378 context_window_bytes: None,
379 guardian_review_model: None,
380 }
381}
382
383pub fn harness_is_authenticated(kind: HarnessKind, home: &Path) -> bool {
384 harness_is_authenticated_with_executor(&probe_profile(kind, home), &probe_executor())
385}
386
387pub(crate) fn harness_is_authenticated_with_executor(
388 profile: &HarnessProfile,
389 executor: &impl CommandExecutor,
390) -> bool {
391 let is_default_home = dirs::home_dir()
392 .is_some_and(|user_home| profile.home == user_home.join(profile.kind.default_home_leaf()));
393 harness_is_authenticated_with(profile, is_default_home, executor)
394}
395
396fn harness_is_authenticated_with(
403 profile: &HarnessProfile,
404 is_default_home: bool,
405 executor: &impl CommandExecutor,
406) -> bool {
407 let kind = profile.kind;
408 let home = profile.home.as_path();
409 if profile.authentication_marker().is_file()
410 || (kind == HarnessKind::Kimi && home.join("credentials").is_file())
411 {
412 return true;
413 }
414 if kind != HarnessKind::Claude {
415 return false;
416 }
417 if is_default_home && claude_keychain_reports_authenticated(executor) {
418 return true;
419 }
420 if !is_default_home && claude_cli_reports_authenticated(home, executor) {
421 return true;
422 }
423 false
424}
425
426fn claude_cli_reports_authenticated(home: &Path, executor: &impl CommandExecutor) -> bool {
430 let mut command = CommandSpec::new("claude", ["auth", "status", "--json"])
431 .purpose("check Claude Code authentication");
432 command.env.insert(
433 HarnessKind::Claude.home_env().to_owned(),
434 home.to_string_lossy().into_owned(),
435 );
436 let Ok(output) = executor.execute(&command) else {
437 return false;
438 };
439 if output.status != 0 {
440 return false;
441 }
442 serde_json::from_slice::<serde_json::Value>(&output.stdout)
443 .ok()
444 .and_then(|status| status.get("loggedIn").and_then(serde_json::Value::as_bool))
445 == Some(true)
446}
447
448#[cfg(target_os = "macos")]
452fn claude_keychain_reports_authenticated(executor: &impl CommandExecutor) -> bool {
453 let command = CommandSpec::new(
454 "security",
455 [
456 "find-generic-password",
457 "-s",
458 "Claude Code-credentials",
459 "-w",
460 ],
461 )
462 .purpose("check Claude Code authentication in the macOS Keychain");
463 let Ok(output) = executor.execute(&command) else {
464 return false;
465 };
466 output.status == 0 && claude_credentials_contain_login(&output.stdout)
467}
468
469#[cfg(not(target_os = "macos"))]
470fn claude_keychain_reports_authenticated(_executor: &impl CommandExecutor) -> bool {
471 false
472}
473
474#[cfg(any(target_os = "macos", test))]
475fn claude_credentials_contain_login(credentials: &[u8]) -> bool {
476 let Ok(document) = serde_json::from_slice::<serde_json::Value>(credentials) else {
477 return false;
478 };
479 [
480 "/claudeAiOauth/accessToken",
481 "/claudeAiOauth/refreshToken",
482 "/oauth/accessToken",
483 "/apiKey",
484 ]
485 .into_iter()
486 .any(|pointer| {
487 document
488 .pointer(pointer)
489 .and_then(serde_json::Value::as_str)
490 .is_some_and(|value| !value.trim().is_empty())
491 })
492}
493
494pub fn github_repository_from_origin(origin: &str) -> Option<GithubRepository> {
495 let origin = origin.trim();
496 let path = origin
497 .strip_prefix("https://github.com/")
498 .or_else(|| origin.strip_prefix("http://github.com/"))
499 .or_else(|| origin.strip_prefix("git@github.com:"))
500 .or_else(|| origin.strip_prefix("ssh://git@github.com/"))
501 .unwrap_or(origin);
504 let path = path.trim_end_matches(".git");
505 let mut parts = path.split('/');
506 let owner = parts.next()?;
507 let repository = parts.next()?;
508 if owner.is_empty()
509 || repository.is_empty()
510 || parts.next().is_some()
511 || owner.chars().any(char::is_whitespace)
512 || repository.chars().any(char::is_whitespace)
513 {
514 return None;
515 }
516 Some(GithubRepository {
517 owner: owner.to_owned(),
518 repository: repository.to_owned(),
519 })
520}
521
522fn discover_github_repository(
528 executor: &impl CommandExecutor,
529 cwd: &Path,
530) -> Option<GithubRepository> {
531 let command = CommandSpec::new(
532 "git",
533 [
534 "-C".to_owned(),
535 cwd.to_string_lossy().into_owned(),
536 "remote".to_owned(),
537 "get-url".to_owned(),
538 "origin".to_owned(),
539 ],
540 )
541 .purpose("detect the current repository's GitHub origin");
542 let output = executor.execute(&command).ok()?;
543 if output.status != 0 {
544 return None;
545 }
546 github_repository_from_origin(&String::from_utf8_lossy(&output.stdout))
547}
548
549pub fn probe_local_runtimes(executor: &impl CommandExecutor, is_macos: bool) -> Vec<RuntimeProbe> {
552 let mut probes = vec![
553 runtime_probe_from_check(RuntimeKind::Podman, local_podman_runtime_check(executor)),
554 runtime_probe_from_check(RuntimeKind::Docker, local_docker_runtime_check(executor)),
555 ];
556 if is_macos {
557 probes.push(runtime_probe_from_check(
558 RuntimeKind::AppleContainer,
559 apple_container_daemon_check(executor),
560 ));
561 }
562 probes
563}
564
565fn runtime_probe_from_check(kind: RuntimeKind, check: crate::doctor::DoctorCheck) -> RuntimeProbe {
566 RuntimeProbe {
567 kind,
568 usable: check.status == CheckStatus::Ready,
569 detail: check.detail,
570 remediation: check.remediation,
571 }
572}
573
574pub fn detect_aws(executor: &impl CommandExecutor) -> Option<AwsAccount> {
580 let identity = CommandSpec::new("aws", ["sts", "get-caller-identity", "--output", "json"])
581 .purpose("detect AWS credentials");
582 let output = executor.execute(&identity).ok()?;
583 if output.status != 0 {
584 return None;
585 }
586 let identity: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
587 let account = identity.get("Account")?.as_str()?.to_owned();
588 let arn = identity.get("Arn")?.as_str()?.to_owned();
589 Some(AwsAccount {
590 account,
591 arn,
592 region: configured_aws_region(executor),
593 })
594}
595
596fn configured_aws_region(executor: &impl CommandExecutor) -> Option<String> {
597 let command = CommandSpec::new("aws", ["configure", "get", "region"])
598 .purpose("read the default AWS region");
599 let output = executor.execute(&command).ok()?;
600 if output.status != 0 {
601 return None;
602 }
603 let region = String::from_utf8_lossy(&output.stdout).trim().to_owned();
604 (!region.is_empty()).then_some(region)
605}
606
607pub fn build_config(
608 homes: &[DiscoveredHome],
609 repository: Option<&GithubRepository>,
610 runtime: RuntimeKind,
611 image: &str,
612) -> Config {
613 build_config_with_runtime(homes, repository, Some((runtime, image)), None, None)
614}
615
616fn build_config_with_runtime(
617 homes: &[DiscoveredHome],
618 repository: Option<&GithubRepository>,
619 runtime: Option<(RuntimeKind, &str)>,
620 aws: Option<&AwsTargetInput>,
621 ssh: Option<&SshTargetInput>,
622) -> Config {
623 build_config_with_runtimes(
624 homes,
625 repository,
626 &runtime.into_iter().collect::<Vec<_>>(),
627 aws,
628 ssh,
629 )
630}
631
632fn build_config_with_runtimes(
633 homes: &[DiscoveredHome],
634 repository: Option<&GithubRepository>,
635 runtimes: &[(RuntimeKind, &str)],
636 aws: Option<&AwsTargetInput>,
637 ssh: Option<&SshTargetInput>,
638) -> Config {
639 let mut config = Config::default();
640 for home in homes {
641 let id = unique_id(&config.profiles, home.kind.id());
642 config.profiles.insert(
643 id,
644 HarnessProfile {
645 enabled: true,
646 kind: home.kind,
647 home: home.path.clone(),
648 environment: BTreeMap::new(),
649 context_window_bytes: None,
650 guardian_review_model: None,
651 },
652 );
653 }
654
655 if let Some(repository) = repository {
656 let repository_id = config_id(&repository.repository);
657 config.bundles.insert(
658 "current-repository".to_owned(),
659 ProjectBundle {
660 primary_repo: repository_id.clone(),
661 repositories: vec![ProjectRepository {
662 id: repository_id.clone(),
663 github: Some(repository.source()),
664 local: None,
665 destination: PathBuf::from(repository_id),
666 git_ref: None,
667 }],
668 },
669 );
670 }
671
672 #[cfg(unix)]
673 config
674 .targets
675 .insert("localhost".to_owned(), TargetTemplate::LocalBare);
676 for (runtime, image) in runtimes {
677 let (target_id, target) = local_runtime_target(*runtime, image);
678 config.targets.insert(target_id.to_owned(), target);
679 }
680 if let Some(aws) = aws {
681 config.targets.insert(
682 AWS_TARGET_ID.to_owned(),
683 TargetTemplate::AwsEc2 {
684 aws_profile: None,
685 region: aws.region.clone(),
686 launch_template: aws.launch_template.clone(),
687 launch_template_version: None,
688 ssh_user: aws.ssh_user.clone(),
689 address_source: AwsAddressSource::default(),
690 identity_file: aws.identity_file.clone(),
691 ssh_args: vec![],
692 },
693 );
694 }
695 if let Some(ssh) = ssh {
696 let connection = SshConnection {
699 host: ssh.host.clone(),
700 user: None,
701 identity_file: None,
702 extra_args: vec![],
703 };
704 let target = match &ssh.kind {
705 SshTargetKind::Bare { permissions } => TargetTemplate::SshBare {
706 ssh: connection,
707 permissions: *permissions,
708 workspace_prefix: default_ssh_workspace_prefix(),
709 },
710 SshTargetKind::Podman { image } => TargetTemplate::SshPodman {
711 ssh: connection,
712 container: ContainerTemplate {
713 image: image.clone(),
714 pull_policy: Default::default(),
715 platform: None,
716 cpus: None,
717 memory: None,
718 environment: BTreeMap::new(),
719 workspace_storage: Default::default(),
720 },
721 },
722 SshTargetKind::Docker { image } => TargetTemplate::SshDocker {
723 ssh: connection,
724 container: ContainerTemplate {
725 image: image.clone(),
726 pull_policy: Default::default(),
727 platform: None,
728 cpus: None,
729 memory: None,
730 environment: BTreeMap::new(),
731 workspace_storage: Default::default(),
732 },
733 },
734 };
735 config
739 .targets
740 .insert(unique_id(&config.targets, &ssh.name), target);
741 }
742 config
743}
744
745pub fn local_runtime_target(runtime: RuntimeKind, image: &str) -> (&'static str, TargetTemplate) {
747 let container = ContainerTemplate {
748 image: image.trim().to_owned(),
749 pull_policy: Default::default(),
750 platform: None,
751 cpus: None,
752 memory: None,
753 environment: BTreeMap::new(),
754 workspace_storage: Default::default(),
755 };
756 match runtime {
757 RuntimeKind::Podman => ("podman", TargetTemplate::LocalPodman { container }),
758 RuntimeKind::Docker => ("docker", TargetTemplate::LocalDocker { container }),
759 RuntimeKind::AppleContainer => (
760 "apple-container",
761 TargetTemplate::AppleContainer { container },
762 ),
763 }
764}
765
766fn default_ssh_workspace_prefix() -> PathBuf {
768 PathBuf::from(".local/share/hel/workspaces")
769}
770
771fn config_id(value: &str) -> String {
772 let mut id = value
773 .chars()
774 .filter(|character| {
775 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
776 })
777 .take(64)
778 .collect::<String>();
779 if id.is_empty() || matches!(id.as_str(), "." | "..") {
780 id = "repository".to_owned();
781 }
782 id
783}
784
785pub fn run_setup_dialog_with(
792 input: &mut impl BufRead,
793 output: &mut impl Write,
794 config_path: &Path,
795 discovery: &SetupDiscovery,
796 smoke_executor: &impl CommandExecutor,
797 probe_executor: &impl CommandExecutor,
798) -> Result<SetupOutcome> {
799 run_setup_dialog_inner(
800 input,
801 output,
802 config_path,
803 discovery,
804 smoke_executor,
805 probe_executor,
806 )
807}
808
809fn run_setup_dialog_inner(
810 input: &mut impl SetupPrompter,
811 output: &mut impl Write,
812 config_path: &Path,
813 discovery: &SetupDiscovery,
814 smoke_executor: &impl CommandExecutor,
815 probe_executor: &impl CommandExecutor,
816) -> Result<SetupOutcome> {
817 let existing = Config::load_from(config_path)?;
818 writeln!(output, "Welcome to Mjolnir setup.")?;
819 writeln!(output)?;
820 write_discovered_homes(output, &discovery.homes)?;
821 write_repository(output, discovery.repository.as_ref())?;
822 write_runtimes(output, &discovery.runtimes)?;
823
824 let runtimes = if discovery.runtimes.iter().any(|runtime| runtime.usable) {
825 let image = prompt(
826 input,
827 output,
828 &format!("Container image [{DEFAULT_IMAGE}]: "),
829 )?;
830 let image = if image.is_empty() {
831 DEFAULT_IMAGE.to_owned()
832 } else {
833 image
834 };
835 discovery
836 .runtimes
837 .iter()
838 .filter(|runtime| runtime.usable)
839 .map(|runtime| (runtime.kind, image.clone()))
840 .collect::<Vec<_>>()
841 } else {
842 writeln!(
843 output,
844 "No usable container runtime found; raw localhost will still be configured."
845 )?;
846 Vec::new()
847 };
848 let aws = prompt_aws_target(input, output, discovery.aws.as_ref())?;
849 let runtime_choices = runtimes
850 .iter()
851 .map(|(runtime, image)| (*runtime, image.as_str()))
852 .collect::<Vec<_>>();
853 let configured = build_config_with_runtimes(
856 &discovery.homes,
857 discovery.repository.as_ref(),
858 &runtime_choices,
859 aws.as_ref(),
860 None,
861 );
862 let ssh = prompt_ssh_target(input, output, &discovery.ssh_hosts, &configured.targets)?;
863 let config = build_config_with_runtimes(
864 &discovery.homes,
865 discovery.repository.as_ref(),
866 &runtime_choices,
867 aws.as_ref(),
868 ssh.as_ref(),
869 );
870 config.validate()?;
871 let additions = reconcile_setup(input, output, &existing, config)?;
872 let runtimes = runtimes
873 .into_iter()
874 .filter(|(runtime, image)| {
875 let (_, target) = local_runtime_target(*runtime, image);
876 additions.targets.values().any(|added| added == &target)
877 })
878 .collect::<Vec<_>>();
879
880 writeln!(output)?;
881 write_summary(output, config_path, &additions, &runtimes)?;
882 let confirmation = prompt(input, output, "Write this configuration? [y/N]: ")?;
883 if !matches!(confirmation.to_ascii_lowercase().as_str(), "y" | "yes") {
884 writeln!(output, "Setup cancelled.")?;
885 return Ok(SetupOutcome::Cancelled);
886 }
887
888 writeln!(output, "Writing {}...", config_path.display())?;
889 Config::update_to(config_path, |latest| {
890 apply_setup_additions(latest, &additions)
891 })?;
892 let smoke_failures = runtimes
896 .iter()
897 .filter_map(|(runtime, image)| {
898 let target = smoke_target(*runtime, image);
899 run_smoke_test(output, &target, smoke_executor)
900 .err()
901 .map(|error| smoke_failure_check(*runtime, image, &error))
902 })
903 .collect();
904 write_doctor_report(output, config_path, probe_executor, smoke_failures)?;
905 writeln!(
906 output,
907 "Advanced users can edit TOML for extra profiles, virtual monorepos, SSH, and AWS."
908 )?;
909 writeln!(output, "Press n to start your first session.")?;
910 Ok(SetupOutcome::Written)
911}
912
913fn reconcile_setup(
915 input: &mut impl SetupPrompter,
916 output: &mut impl Write,
917 existing: &Config,
918 discovered: Config,
919) -> Result<Config> {
920 let mut additions = existing.setup_additions(&discovered);
921 for id in additions.profiles.keys() {
922 writeln!(output, "Adding discovered profile {id}.")?;
923 }
924 for id in additions.bundles.keys() {
925 writeln!(output, "Adding repository bundle {id}.")?;
926 }
927 for (id, target) in &discovered.targets {
928 if !existing.targets.contains_key(id) {
929 continue;
930 }
931 let alternate = additions
932 .targets
933 .iter()
934 .find(|(_, added)| *added == target)
935 .map(|(id, _)| id.clone());
936 let Some(alternate) = alternate else { continue };
937 writeln!(
938 output,
939 "Target {id} already has different settings. Existing sessions will keep using it."
940 )?;
941 let answer = prompt(
942 input,
943 output,
944 &format!("Keep {id}, or add the discovered settings as {alternate}? [K/a]: "),
945 )?;
946 if !matches!(answer.to_ascii_lowercase().as_str(), "a" | "add") {
947 additions.targets.remove(&alternate);
948 writeln!(
949 output,
950 "Keeping target {id}; discovered settings were not added."
951 )?;
952 }
953 }
954 Ok(additions)
955}
956
957fn apply_setup_additions(latest: &mut Config, additions: &Config) -> Result<()> {
958 fn add<T: Clone>(
959 section: &str,
960 latest: &mut BTreeMap<String, T>,
961 additions: &BTreeMap<String, T>,
962 same: impl Fn(&T, &T) -> bool,
963 ) -> Result<()> {
964 for (id, value) in additions {
965 if latest.values().any(|existing| same(existing, value)) {
966 continue;
967 }
968 ensure!(
969 !latest.contains_key(id),
970 "{section} {id:?} changed while setup was open; no configuration was written. Rerun mj setup to review the current settings"
971 );
972 latest.insert(id.clone(), value.clone());
973 }
974 Ok(())
975 }
976 add(
977 "profile",
978 &mut latest.profiles,
979 &additions.profiles,
980 HarnessProfile::same_installation,
981 )?;
982 add(
983 "bundle",
984 &mut latest.bundles,
985 &additions.bundles,
986 PartialEq::eq,
987 )?;
988 add(
989 "target",
990 &mut latest.targets,
991 &additions.targets,
992 PartialEq::eq,
993 )?;
994 latest.validate()
995}
996
997fn write_discovered_homes(output: &mut impl Write, homes: &[DiscoveredHome]) -> Result<()> {
998 writeln!(output, "Harness homes:")?;
999 if homes.is_empty() {
1000 writeln!(
1001 output,
1002 " No existing Codex, Claude Code, Kimi Code, or Grok Build homes found."
1003 )?;
1004 }
1005 for home in homes {
1006 let authentication = if home.authenticated {
1007 "authenticated"
1008 } else {
1009 "not authenticated"
1010 };
1011 writeln!(
1012 output,
1013 " {}: {} ({authentication}){}",
1014 home.kind.display_name(),
1015 home.path.display(),
1016 match home.kind.unsandboxed_guardian_warning() {
1017 Some(warning) => format!(" — {warning}"),
1018 None => String::new(),
1019 }
1020 )?;
1021 }
1022 Ok(())
1023}
1024
1025fn write_repository(output: &mut impl Write, repository: Option<&GithubRepository>) -> Result<()> {
1026 match repository {
1027 Some(repository) => writeln!(
1028 output,
1029 "GitHub origin: {} (a one-repository bundle will be created)",
1030 repository.source()
1031 )?,
1032 None => writeln!(
1033 output,
1034 "GitHub origin: none detected in the current directory."
1035 )?,
1036 }
1037 Ok(())
1038}
1039
1040fn write_runtimes(output: &mut impl Write, runtimes: &[RuntimeProbe]) -> Result<()> {
1041 writeln!(output, "Local runtimes:")?;
1042 for runtime in runtimes {
1043 let state = if runtime.usable {
1044 "usable"
1045 } else {
1046 "unavailable"
1047 };
1048 if runtime.detail.is_empty() {
1049 writeln!(output, " {}: {state}", runtime.kind.label())?;
1050 } else {
1051 writeln!(
1052 output,
1053 " {}: {state} ({})",
1054 runtime.kind.label(),
1055 runtime.detail
1056 )?;
1057 }
1058 if let Some(remediation) = &runtime.remediation {
1059 writeln!(output, " remediation: {remediation}")?;
1060 }
1061 }
1062 Ok(())
1063}
1064
1065fn prompt_aws_target(
1068 input: &mut impl SetupPrompter,
1069 output: &mut impl Write,
1070 account: Option<&AwsAccount>,
1071) -> Result<Option<AwsTargetInput>> {
1072 let Some(account) = account else {
1073 writeln!(
1074 output,
1075 "AWS: no working `aws` CLI credentials found; skipping the AWS target."
1076 )?;
1077 return Ok(None);
1078 };
1079 writeln!(
1080 output,
1081 "AWS: credentials are valid for account {} ({}).",
1082 account.account, account.arn
1083 )?;
1084 let answer = prompt(input, output, "Add an AWS EC2 target? [y/N]: ")?;
1085 if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1086 return Ok(None);
1087 }
1088
1089 let launch_template = prompt(input, output, "Launch template name: ")?;
1090 if launch_template.is_empty() {
1091 writeln!(
1092 output,
1093 "A launch template name is required; skipping the AWS target."
1094 )?;
1095 return Ok(None);
1096 }
1097
1098 let region_label = match &account.region {
1099 Some(region) => format!("Region [{region}]: "),
1100 None => "Region: ".to_owned(),
1101 };
1102 let region = prompt(input, output, ®ion_label)?;
1103 let region = if region.is_empty() {
1104 match &account.region {
1105 Some(region) => region.clone(),
1106 None => {
1107 writeln!(output, "A region is required; skipping the AWS target.")?;
1108 return Ok(None);
1109 }
1110 }
1111 } else {
1112 region
1113 };
1114
1115 let ssh_user = prompt(
1116 input,
1117 output,
1118 &format!("SSH user [{DEFAULT_AWS_SSH_USER}]: "),
1119 )?;
1120 let ssh_user = if ssh_user.is_empty() {
1121 DEFAULT_AWS_SSH_USER.to_owned()
1122 } else {
1123 ssh_user
1124 };
1125 let identity_file = prompt(input, output, "SSH identity file (optional): ")?;
1126
1127 Ok(Some(AwsTargetInput {
1128 launch_template,
1129 region,
1130 ssh_user,
1131 identity_file: (!identity_file.is_empty()).then(|| PathBuf::from(identity_file)),
1132 }))
1133}
1134
1135fn prompt_ssh_target(
1140 input: &mut impl SetupPrompter,
1141 output: &mut impl Write,
1142 aliases: &[String],
1143 configured: &BTreeMap<String, TargetTemplate>,
1144) -> Result<Option<SshTargetInput>> {
1145 if aliases.is_empty() {
1146 writeln!(
1147 output,
1148 "SSH: no host aliases found in ~/.ssh/config; skipping the SSH target."
1149 )?;
1150 return Ok(None);
1151 }
1152 writeln!(output, "SSH: hosts found in ~/.ssh/config:")?;
1153 for (index, alias) in aliases.iter().enumerate() {
1154 writeln!(output, " {}) {alias}", index + 1)?;
1155 }
1156 let answer = prompt(input, output, "Add an SSH target? [y/N]: ")?;
1157 if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1158 return Ok(None);
1159 }
1160
1161 let choice = prompt(
1162 input,
1163 output,
1164 &format!("Host number 1-{} or a host name: ", aliases.len()),
1165 )?;
1166 let host = match choice.parse::<usize>() {
1167 Ok(index) if (1..=aliases.len()).contains(&index) => aliases[index - 1].clone(),
1168 _ if !choice.is_empty() => choice,
1169 _ => {
1170 writeln!(output, "A host is required; skipping the SSH target.")?;
1171 return Ok(None);
1172 }
1173 };
1174
1175 let kind = loop {
1176 let runtime = prompt(
1177 input,
1178 output,
1179 "Container runtime on that host, podman, docker, or bare [podman]: ",
1180 )?;
1181 let runtime = runtime.to_ascii_lowercase();
1182 if matches!(runtime.as_str(), "n" | "no" | "bare") {
1183 let permissions = loop {
1184 let mode = prompt(
1185 input,
1186 output,
1187 "Raw-host permissions, guardian or yolo [guardian]: ",
1188 )?;
1189 match mode.to_ascii_lowercase().as_str() {
1190 "" | "guardian" => break PermissionMode::Guardian,
1191 "yolo" => break PermissionMode::Yolo,
1192 _ => writeln!(output, "Permissions must be `guardian` or `yolo`.")?,
1193 }
1194 };
1195 break SshTargetKind::Bare { permissions };
1196 }
1197 let kind = if matches!(runtime.as_str(), "" | "y" | "yes" | "podman") {
1198 SshTargetKind::Podman {
1199 image: String::new(),
1200 }
1201 } else if runtime == "docker" {
1202 SshTargetKind::Docker {
1203 image: String::new(),
1204 }
1205 } else {
1206 writeln!(
1207 output,
1208 "Runtime must be `podman`, `docker`, or `bare`; please choose again."
1209 )?;
1210 continue;
1211 };
1212 let image = prompt(
1213 input,
1214 output,
1215 &format!("Container image [{DEFAULT_IMAGE}]: "),
1216 )?;
1217 let image = if image.is_empty() {
1218 DEFAULT_IMAGE.to_owned()
1219 } else {
1220 image
1221 };
1222 break match kind {
1223 SshTargetKind::Podman { .. } => SshTargetKind::Podman { image },
1224 SshTargetKind::Docker { .. } => SshTargetKind::Docker { image },
1225 SshTargetKind::Bare { .. } => unreachable!("bare runtime returned above"),
1226 };
1227 };
1228
1229 let Some(name) = prompt_ssh_target_name(input, output, &host, configured)? else {
1230 return Ok(None);
1231 };
1232
1233 Ok(Some(SshTargetInput { name, host, kind }))
1234}
1235
1236fn prompt_ssh_target_name(
1243 input: &mut impl SetupPrompter,
1244 output: &mut impl Write,
1245 host: &str,
1246 configured: &BTreeMap<String, TargetTemplate>,
1247) -> Result<Option<String>> {
1248 loop {
1249 let Some(answer) = prompt_line(input, output, &format!("Target name [{host}]: "))? else {
1250 writeln!(output, "Input ended; skipping the SSH target.")?;
1251 return Ok(None);
1252 };
1253 let name = if answer.is_empty() {
1254 host.to_owned()
1255 } else {
1256 answer
1257 };
1258 if let Err(error) = validate_id("target", &name) {
1259 writeln!(output, "{error}")?;
1260 continue;
1261 }
1262 if configured.contains_key(&name) {
1263 writeln!(
1264 output,
1265 "Target {name} is already configured; choose another name."
1266 )?;
1267 continue;
1268 }
1269 return Ok(Some(name));
1270 }
1271}
1272
1273fn smoke_failure_check(runtime: RuntimeKind, image: &str, error: &anyhow::Error) -> DoctorCheck {
1276 let scope = match runtime {
1277 RuntimeKind::Docker => "Disposable run/exec/remove and OverlayFS attachment smoke test",
1278 RuntimeKind::Podman | RuntimeKind::AppleContainer => {
1279 "Disposable run/exec/remove smoke test"
1280 }
1281 };
1282 DoctorCheck::fixable(
1283 format!("runtime.{}.smoke", runtime.id()),
1284 format!("{} smoke test", runtime.label()),
1285 format!("{scope} failed for image {image}: {error:#}"),
1286 format!(
1287 "Fix the configured image or the {} runtime, then run `mj doctor --smoke` again.",
1288 runtime.label()
1289 ),
1290 )
1291}
1292
1293fn write_doctor_report(
1299 output: &mut impl Write,
1300 config_path: &Path,
1301 executor: &impl CommandExecutor,
1302 extra: Vec<DoctorCheck>,
1303) -> Result<()> {
1304 writeln!(output)?;
1305 writeln!(output, "Running `mj doctor` checks on the new config...")?;
1306 let mut checks = run_with_config_path(
1307 config_path,
1308 executor,
1309 current_apple_platform(executor),
1310 DoctorOptions { smoke: false },
1311 );
1312 checks.extend(extra);
1313 render_human(&checks, output)?;
1314 if all_ready(&checks) {
1315 writeln!(output, "Every check is ready.")?;
1316 } else {
1317 writeln!(
1318 output,
1319 "Apply the remediations above, then rerun `mj doctor`."
1320 )?;
1321 }
1322 Ok(())
1323}
1324
1325fn prompt(input: &mut impl SetupPrompter, output: &mut impl Write, label: &str) -> Result<String> {
1326 Ok(prompt_line(input, output, label)?.unwrap_or_default())
1327}
1328
1329fn prompt_line(
1335 input: &mut impl SetupPrompter,
1336 output: &mut impl Write,
1337 label: &str,
1338) -> Result<Option<String>> {
1339 input.read_prompt(output, label)
1340}
1341
1342trait SetupPrompter {
1343 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>>;
1344}
1345
1346impl<R: BufRead> SetupPrompter for R {
1347 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1348 write!(output, "{label}")?;
1349 output.flush()?;
1350 let mut answer = String::new();
1351 let read = self.read_line(&mut answer).context("read setup response")?;
1352 Ok((read > 0).then(|| answer.trim().to_owned()))
1353 }
1354}
1355
1356#[derive(Default)]
1357struct ReadlinePrompter(crate::readline::LineReader);
1358
1359impl SetupPrompter for ReadlinePrompter {
1360 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1361 output.flush()?;
1362 self.0.read_line(label).context("read setup response")
1363 }
1364}
1365
1366fn write_summary(
1367 output: &mut impl Write,
1368 config_path: &Path,
1369 config: &Config,
1370 runtimes: &[(RuntimeKind, String)],
1371) -> Result<()> {
1372 writeln!(output, "Mjolnir will add to {}:", config_path.display())?;
1373 writeln!(output, " {} profile(s)", config.profiles.len())?;
1374 writeln!(output, " {} bundle(s)", config.bundles.len())?;
1375 if config
1376 .targets
1377 .values()
1378 .any(|target| matches!(target, TargetTemplate::LocalBare))
1379 {
1380 writeln!(
1381 output,
1382 " raw localhost target using configured harness homes directly"
1383 )?;
1384 }
1385 for (runtime, image) in runtimes {
1386 writeln!(output, " {} target using {image}", runtime.label())?;
1387 }
1388 for id in config.targets.keys() {
1389 writeln!(output, " target id: {id}")?;
1390 }
1391 if let Some(TargetTemplate::AwsEc2 {
1392 launch_template,
1393 region,
1394 ..
1395 }) = config.targets.get(AWS_TARGET_ID)
1396 {
1397 writeln!(
1398 output,
1399 " AWS EC2 target using launch template {launch_template} in {region}"
1400 )?;
1401 }
1402 for (id, target) in &config.targets {
1403 match target {
1404 TargetTemplate::SshBare { ssh, .. } => {
1405 writeln!(output, " SSH target {id} on {} (no container)", ssh.host)?;
1406 }
1407 TargetTemplate::SshPodman { ssh, container, .. } => {
1408 writeln!(
1409 output,
1410 " SSH target {id} on {} using Podman image {}",
1411 ssh.host, container.image
1412 )?;
1413 }
1414 TargetTemplate::SshDocker { ssh, container } => {
1415 writeln!(
1416 output,
1417 " SSH target {id} on {} using Docker image {}",
1418 ssh.host, container.image
1419 )?;
1420 }
1421 _ => {}
1422 }
1423 }
1424 if config_path.exists() {
1425 writeln!(
1426 output,
1427 " Existing profiles, bundles, targets, and preferences will be preserved."
1428 )?;
1429 }
1430 Ok(())
1431}
1432
1433fn smoke_target(runtime: RuntimeKind, image: &str) -> RuntimeTargetTemplate {
1434 let container = RuntimeContainerTemplate {
1435 image: image.to_owned(),
1436 pull_policy: Default::default(),
1437 extra_run_args: vec![],
1438 workspace_storage: Default::default(),
1439 };
1440 match runtime {
1441 RuntimeKind::Podman => RuntimeTargetTemplate::LocalPodman(container),
1442 RuntimeKind::Docker => RuntimeTargetTemplate::LocalDocker(container),
1443 RuntimeKind::AppleContainer => RuntimeTargetTemplate::AppleContainer(container),
1444 }
1445}
1446
1447fn run_smoke_test(
1448 output: &mut impl Write,
1449 target: &RuntimeTargetTemplate,
1450 executor: &impl CommandExecutor,
1451) -> Result<()> {
1452 let smoke_id = format!(
1453 "setup-{}-{:x}",
1454 std::process::id(),
1455 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1456 );
1457 let description = match target {
1458 RuntimeTargetTemplate::LocalDocker(_) => {
1459 "Smoke test: verifying a disposable container and writable OverlayFS attachment..."
1460 }
1461 _ => "Smoke test: verifying a disposable container...",
1462 };
1463 writeln!(output, "{description}")?;
1464 run_setup_smoke_test(target, &smoke_id, executor)
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469 use std::cell::RefCell;
1470 use std::fs;
1471
1472 use super::*;
1473 use crate::targets::CommandOutput;
1474
1475 #[test]
1476 fn an_api_key_codex_profile_is_authenticated_by_its_configuration_file() {
1477 let home = tempfile::tempdir().unwrap();
1478 let executor = FakeExecutor::succeeds();
1479 let profile = HarnessProfile {
1480 enabled: true,
1481 kind: HarnessKind::Codex,
1482 home: home.path().to_path_buf(),
1483 environment: [("ZAI_API_KEY".to_owned(), "key".to_owned())]
1484 .into_iter()
1485 .collect(),
1486 context_window_bytes: None,
1487 guardian_review_model: None,
1488 };
1489
1490 assert!(
1491 !harness_is_authenticated_with_executor(&profile, &executor),
1492 "an empty home is not set up"
1493 );
1494 fs::write(
1495 home.path().join("config.toml"),
1496 "model = \"glm-5.3\"\n\
1497 model_provider = \"zai\"\n\
1498 [model_providers.zai]\n\
1499 base_url = \"https://api.z.ai/api/v1\"\n\
1500 env_key = \"ZAI_API_KEY\"\n\
1501 wire_api = \"responses\"\n",
1502 )
1503 .unwrap();
1504 assert!(
1505 harness_is_authenticated_with_executor(&profile, &executor),
1506 "the key lives in the profile environment, so the configuration is the proof"
1507 );
1508 assert!(
1509 !home.path().join("auth.json").exists(),
1510 "no ChatGPT login is involved"
1511 );
1512 }
1513
1514 #[cfg(unix)]
1515 #[test]
1516 fn first_terminal_launch_writes_a_local_codex_config_once() {
1517 let directory = tempfile::tempdir().unwrap();
1518 let path = directory.path().join("config.toml");
1519 initialize_local_startup_config(&path).unwrap();
1520 let config = Config::load_from(&path).unwrap();
1521 assert_eq!(config.profiles["codex"].kind, HarnessKind::Codex);
1522 assert!(config.profiles["codex"].home.is_absolute());
1523 assert!(matches!(
1524 config.targets["localhost"],
1525 TargetTemplate::LocalBare
1526 ));
1527 assert!(config.bundles.is_empty());
1528 let written = fs::read(&path).unwrap();
1529 initialize_local_startup_config(&path).unwrap();
1530 assert_eq!(fs::read(&path).unwrap(), written);
1531 }
1532
1533 #[cfg(unix)]
1534 #[test]
1535 fn local_startup_preserves_existing_settings_and_ignores_disabled_startup() {
1536 let directory = tempfile::tempdir().unwrap();
1537 let path = directory.path().join("config.toml");
1538 let mut config = Config::default();
1539 config.phone.enabled = false;
1540 config.save_to(&path).unwrap();
1541 initialize_local_startup_config(&path).unwrap();
1542 assert!(!Config::load_from(&path).unwrap().phone.enabled);
1543
1544 let configured =
1546 "version = 2\n# keep this comment\n[targets.custom]\nkind = 'local-bare'\n";
1547 fs::write(&path, configured).unwrap();
1548 initialize_local_startup_config(&path).unwrap();
1549 assert_eq!(fs::read_to_string(&path).unwrap(), configured);
1550
1551 let disabled = "version = 2\n[startup]\nenabled = false\n";
1552 fs::write(&path, disabled).unwrap();
1553 initialize_local_startup_config(&path).unwrap();
1554 let bootstrapped = Config::load_from(&path).unwrap();
1555 assert!(
1556 serde_json::to_value(&bootstrapped)
1557 .unwrap()
1558 .get("startup")
1559 .is_none()
1560 );
1561 assert_eq!(bootstrapped.profiles["codex"].kind, HarnessKind::Codex);
1562 assert!(matches!(
1563 bootstrapped.targets["localhost"],
1564 TargetTemplate::LocalBare
1565 ));
1566
1567 let newer = "version = 999\nfuture_field = true\n";
1568 fs::write(&path, newer).unwrap();
1569 initialize_local_startup_config(&path).unwrap();
1570 assert_eq!(fs::read_to_string(&path).unwrap(), newer);
1571 }
1572
1573 struct FakeExecutor {
1574 commands: RefCell<Vec<CommandSpec>>,
1575 statuses: Vec<i32>,
1576 }
1577
1578 impl FakeExecutor {
1579 fn succeeds() -> Self {
1580 Self {
1581 commands: RefCell::new(vec![]),
1582 statuses: vec![0, 0, 0],
1583 }
1584 }
1585 }
1586
1587 impl CommandExecutor for FakeExecutor {
1588 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1589 let index = self.commands.borrow().len();
1590 self.commands.borrow_mut().push(command.clone());
1591 Ok(CommandOutput {
1592 status: self.statuses.get(index).copied().unwrap_or(0),
1593 stdout: b"available".to_vec(),
1594 stderr: b"failed".to_vec(),
1595 })
1596 }
1597 }
1598
1599 struct RuntimeProbeExecutor {
1600 commands: RefCell<Vec<CommandSpec>>,
1601 outputs: RefCell<Vec<CommandOutput>>,
1602 }
1603
1604 impl RuntimeProbeExecutor {
1605 fn new(outputs: impl IntoIterator<Item = CommandOutput>) -> Self {
1606 Self {
1607 commands: RefCell::new(vec![]),
1608 outputs: RefCell::new(outputs.into_iter().collect()),
1609 }
1610 }
1611 }
1612
1613 impl CommandExecutor for RuntimeProbeExecutor {
1614 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1615 self.commands.borrow_mut().push(command.clone());
1616 if self.outputs.borrow().is_empty() {
1617 anyhow::bail!("no canned output for {}", command.program);
1618 }
1619 Ok(self.outputs.borrow_mut().remove(0))
1620 }
1621 }
1622
1623 fn ok(stdout: &[u8]) -> CommandOutput {
1624 CommandOutput {
1625 status: 0,
1626 stdout: stdout.to_vec(),
1627 stderr: vec![],
1628 }
1629 }
1630
1631 fn failed(stderr: &[u8]) -> CommandOutput {
1632 CommandOutput {
1633 status: 1,
1634 stdout: vec![],
1635 stderr: stderr.to_vec(),
1636 }
1637 }
1638
1639 const CALLER_IDENTITY: &[u8] =
1640 br#"{"UserId":"AIDA","Account":"123456789012","Arn":"arn:aws:iam::123456789012:user/dev"}"#;
1641
1642 fn discovery_without_runtimes() -> SetupDiscovery {
1643 SetupDiscovery {
1644 homes: vec![],
1645 repository: None,
1646 runtimes: vec![],
1647 aws: None,
1648 ssh_hosts: vec![],
1649 }
1650 }
1651
1652 #[test]
1653 fn newly_installed_harness_is_discovered_before_its_first_login() {
1654 struct InstalledMuse;
1655 impl CommandExecutor for InstalledMuse {
1656 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1657 assert_eq!(command.args, ["--version"]);
1658 Ok(CommandOutput {
1659 status: if command.program == "muse" { 0 } else { 127 },
1660 stdout: Vec::new(),
1661 stderr: Vec::new(),
1662 })
1663 }
1664 }
1665 let directory = tempfile::tempdir().unwrap();
1666 let path = directory.path().join("custom-muse-home");
1667 let overrides = BTreeMap::from([(HarnessKind::Muse, path.clone())]);
1668 let mut homes = Vec::new();
1669 for _ in 0..2 {
1670 discover_installed_harnesses(
1671 Some(directory.path()),
1672 &overrides,
1673 &mut homes,
1674 &InstalledMuse,
1675 );
1676 assert_eq!(
1677 homes,
1678 vec![DiscoveredHome {
1679 kind: HarnessKind::Muse,
1680 path: path.clone(),
1681 authenticated: false
1682 }]
1683 );
1684 assert!(!path.exists(), "discovery must not create a profile");
1685 }
1686 }
1687
1688 #[test]
1689 fn discovers_default_and_overridden_homes_with_authentication_markers() {
1690 let directory = tempfile::tempdir().unwrap();
1691 let home = directory.path().join("home");
1692 let codex = home.join(".codex");
1693 let kimi = home.join(".kimi-code");
1694 let grok = home.join(".grok");
1695 let claude = directory.path().join("claude-override");
1696 fs::create_dir_all(&codex).unwrap();
1697 fs::create_dir_all(kimi.join("credentials")).unwrap();
1698 fs::create_dir_all(&grok).unwrap();
1699 fs::create_dir_all(&claude).unwrap();
1700 fs::write(codex.join("auth.json"), "{}").unwrap();
1701 fs::write(kimi.join("credentials/kimi-code.json"), "{}").unwrap();
1702 fs::write(grok.join("auth.json"), "{}").unwrap();
1703 fs::write(claude.join(".credentials.json"), "{}").unwrap();
1704
1705 let executor = FakeExecutor::succeeds();
1706 let homes = discover_harness_homes_with_executor(
1707 Some(&home),
1708 [(HarnessKind::Claude, claude.clone())],
1709 &executor,
1710 );
1711
1712 assert_eq!(homes.len(), 4);
1713 assert!(homes.iter().all(|home| home.authenticated));
1714 assert!(homes.iter().any(|home| home.path == codex));
1715 assert!(homes.iter().any(|home| home.path == claude));
1716 assert!(homes.iter().any(|home| home.path == kimi));
1717 assert!(
1718 homes
1719 .iter()
1720 .any(|home| home.path == grok && home.kind == HarnessKind::Grok)
1721 );
1722 }
1723
1724 #[test]
1725 fn every_harness_has_a_discoverable_default_home() {
1726 let directory = tempfile::tempdir().unwrap();
1727 let home = directory.path().to_path_buf();
1728 for kind in HarnessKind::ALL {
1729 fs::create_dir_all(home.join(kind.default_home_leaf())).unwrap();
1730 }
1731
1732 let executor = FakeExecutor::succeeds();
1733 let homes = discover_harness_homes_with_executor(Some(&home), [], &executor);
1734
1735 assert_eq!(homes.len(), HarnessKind::ALL.len());
1736 for kind in HarnessKind::ALL {
1737 assert!(
1738 homes
1739 .iter()
1740 .any(|home| home.kind == kind && !home.authenticated),
1741 "{kind:?} default home"
1742 );
1743 }
1744 }
1745
1746 #[cfg(target_os = "macos")]
1747 #[test]
1748 fn claude_keychain_marks_the_default_home_authenticated_without_a_marker() {
1749 let directory = tempfile::tempdir().unwrap();
1750 let home = directory.path().join("home");
1751 let claude = home.join(".claude");
1752 fs::create_dir_all(&claude).unwrap();
1753 let executor = RuntimeProbeExecutor::new([ok(
1754 br#"{"claudeAiOauth":{"accessToken":"access","refreshToken":"refresh"}}"#,
1755 )]);
1756
1757 let homes = discover_harness_homes_with_executor(Some(&home), [], &executor);
1758
1759 assert_eq!(
1760 homes,
1761 vec![DiscoveredHome {
1762 kind: HarnessKind::Claude,
1763 path: claude.clone(),
1764 authenticated: true,
1765 }]
1766 );
1767 let commands = executor.commands.borrow();
1768 assert_eq!(commands.len(), 1);
1769 assert_eq!(commands[0].program, "security");
1770 assert_eq!(
1771 commands[0].args,
1772 [
1773 "find-generic-password",
1774 "-s",
1775 "Claude Code-credentials",
1776 "-w"
1777 ]
1778 );
1779 assert!(commands[0].env.is_empty());
1780 }
1781
1782 #[test]
1783 fn claude_status_checks_a_custom_home_without_a_marker() {
1784 let directory = tempfile::tempdir().unwrap();
1785 let claude = directory.path().join("claude-custom");
1786 fs::create_dir_all(&claude).unwrap();
1787 let executor =
1788 RuntimeProbeExecutor::new([ok(br#"{"loggedIn":true,"authMethod":"claude.ai"}"#)]);
1789
1790 let homes = discover_harness_homes_with_executor(
1791 None,
1792 [(HarnessKind::Claude, claude.clone())],
1793 &executor,
1794 );
1795
1796 assert_eq!(
1797 homes,
1798 vec![DiscoveredHome {
1799 kind: HarnessKind::Claude,
1800 path: claude.clone(),
1801 authenticated: true,
1802 }]
1803 );
1804 let commands = executor.commands.borrow();
1805 assert_eq!(commands.len(), 1);
1806 assert_eq!(commands[0].program, "claude");
1807 assert_eq!(commands[0].args, ["auth", "status", "--json"]);
1808 assert_eq!(
1809 commands[0].env.get("CLAUDE_CONFIG_DIR"),
1810 Some(&claude.to_string_lossy().into_owned())
1811 );
1812 }
1813
1814 #[test]
1815 fn claude_credential_evidence_requires_a_nonempty_login_secret() {
1816 assert!(claude_credentials_contain_login(
1817 br#"{"claudeAiOauth":{"refreshToken":"refresh"}}"#
1818 ));
1819 assert!(!claude_credentials_contain_login(
1820 br#"{"claudeAiOauth":{"refreshToken":" "}}"#
1821 ));
1822 assert!(!claude_credentials_contain_login(b"not json"));
1823 }
1824
1825 #[test]
1826 fn github_origin_parser_accepts_standard_https_and_ssh_forms() {
1827 for origin in [
1828 "https://github.com/BrokkAi/hel.git",
1829 "git@github.com:BrokkAi/hel.git",
1830 "ssh://git@github.com/BrokkAi/hel.git",
1831 ] {
1832 assert_eq!(
1833 github_repository_from_origin(origin),
1834 Some(GithubRepository {
1835 owner: "BrokkAi".into(),
1836 repository: "hel".into(),
1837 })
1838 );
1839 }
1840 assert_eq!(
1841 github_repository_from_origin("https://example.com/hel"),
1842 None
1843 );
1844 }
1845
1846 #[test]
1847 fn config_contains_discovered_profiles_current_repository_and_selected_target() {
1848 let homes = vec![
1849 DiscoveredHome {
1850 kind: HarnessKind::Codex,
1851 path: PathBuf::from("/profiles/codex"),
1852 authenticated: true,
1853 },
1854 DiscoveredHome {
1855 kind: HarnessKind::Codex,
1856 path: PathBuf::from("/profiles/codex-two"),
1857 authenticated: false,
1858 },
1859 ];
1860 let repository = GithubRepository {
1861 owner: "BrokkAi".into(),
1862 repository: "hel".into(),
1863 };
1864
1865 let config = build_config(
1866 &homes,
1867 Some(&repository),
1868 RuntimeKind::Podman,
1869 "ubuntu:24.04",
1870 );
1871
1872 config.validate().unwrap();
1873 assert!(config.profiles.contains_key("codex"));
1874 assert!(config.profiles.contains_key("codex-2"));
1875 assert_eq!(
1876 config.bundles["current-repository"].repositories[0]
1877 .github
1878 .as_deref(),
1879 Some("BrokkAi/hel")
1880 );
1881 assert!(matches!(
1882 config.targets["podman"],
1883 TargetTemplate::LocalPodman { .. }
1884 ));
1885 assert!(matches!(
1886 config.targets["localhost"],
1887 TargetTemplate::LocalBare
1888 ));
1889
1890 let docker = build_config(
1891 &homes,
1892 Some(&repository),
1893 RuntimeKind::Docker,
1894 "ubuntu:24.04",
1895 );
1896 assert!(matches!(
1897 docker.targets["docker"],
1898 TargetTemplate::LocalDocker { .. }
1899 ));
1900 }
1901
1902 #[test]
1903 fn runtime_probe_requires_podman_rootless_preflight_and_checks_apple_on_macos() {
1904 let executor = RuntimeProbeExecutor::new([
1905 ok(b"podman version 5.4.2\n"),
1906 ok(b"true\n"),
1907 ok(b"0 1000 1\n1 100000 65536\n"),
1908 ok(b"29.0.1 linux\n"),
1909 ok(b"container version 1\n"),
1910 ok(b"running\n"),
1911 ]);
1912 let runtimes = probe_local_runtimes(&executor, true);
1913
1914 assert_eq!(runtimes.len(), 3);
1915 assert_eq!(executor.commands.borrow()[0].program, "podman");
1916 assert_eq!(executor.commands.borrow()[0].args, ["--version"]);
1917 assert_eq!(
1918 executor.commands.borrow()[1].args,
1919 ["info", "--format", "{{.Host.Security.Rootless}}"]
1920 );
1921 assert_eq!(
1922 executor.commands.borrow()[2].args,
1923 ["unshare", "cat", "/proc/self/uid_map"]
1924 );
1925 assert_eq!(executor.commands.borrow()[3].program, "docker");
1926 assert_eq!(executor.commands.borrow()[4].program, "container");
1927 assert!(runtimes.iter().all(|runtime| runtime.usable));
1928 }
1929
1930 #[test]
1931 fn unusable_podman_carries_the_doctor_remediation_into_the_runtime_list() {
1932 let executor = RuntimeProbeExecutor::new([
1933 ok(b"podman version 3.4.7\n"),
1934 failed(b"docker is unavailable"),
1935 ]);
1936
1937 let runtimes = probe_local_runtimes(&executor, false);
1938
1939 assert_eq!(runtimes.len(), 2);
1940 assert!(!runtimes[0].usable);
1941 let remediation = runtimes[0].remediation.as_deref().unwrap();
1942 assert!(remediation.contains("Upgrade Podman"), "{remediation}");
1943
1944 let mut output = Vec::new();
1945 write_runtimes(&mut output, &runtimes).unwrap();
1946 let output = String::from_utf8(output).unwrap();
1947 assert!(output.contains("Podman: unavailable"), "{output}");
1948 assert!(output.contains("Docker: unavailable"), "{output}");
1949 assert!(output.contains("remediation: Upgrade Podman"), "{output}");
1950 }
1951
1952 #[test]
1953 fn aws_is_detected_only_when_the_caller_identity_call_succeeds() {
1954 let missing = RuntimeProbeExecutor::new([]);
1955 assert_eq!(detect_aws(&missing), None);
1956
1957 let denied = RuntimeProbeExecutor::new([failed(b"ExpiredToken")]);
1958 assert_eq!(detect_aws(&denied), None);
1959
1960 let working = RuntimeProbeExecutor::new([ok(CALLER_IDENTITY), ok(b"us-east-1\n")]);
1961 assert_eq!(
1962 detect_aws(&working),
1963 Some(AwsAccount {
1964 account: "123456789012".into(),
1965 arn: "arn:aws:iam::123456789012:user/dev".into(),
1966 region: Some("us-east-1".into()),
1967 })
1968 );
1969 assert_eq!(working.commands.borrow()[0].args[0], "sts");
1970 assert_eq!(
1971 working.commands.borrow()[1].args,
1972 ["configure", "get", "region"]
1973 );
1974 }
1975
1976 #[test]
1977 fn aws_detection_without_a_configured_region_leaves_the_region_unset() {
1978 let executor = RuntimeProbeExecutor::new([ok(CALLER_IDENTITY), failed(b"")]);
1979
1980 assert_eq!(detect_aws(&executor).unwrap().region, None);
1981 }
1982
1983 #[test]
1984 fn the_aws_step_asks_nothing_when_no_aws_credentials_were_detected() {
1985 let mut input = b"".as_slice();
1986 let mut output = Vec::new();
1987
1988 let aws = prompt_aws_target(&mut input, &mut output, None).unwrap();
1989
1990 assert_eq!(aws, None);
1991 let output = String::from_utf8(output).unwrap();
1992 assert!(output.contains("skipping the AWS target"), "{output}");
1993 assert!(!output.contains("[y/N]"), "{output}");
1994 }
1995
1996 #[test]
1997 fn the_aws_step_defaults_region_and_ssh_user_when_the_answers_are_blank() {
1998 let account = AwsAccount {
1999 account: "123456789012".into(),
2000 arn: "arn:aws:iam::123456789012:user/dev".into(),
2001 region: Some("us-east-1".into()),
2002 };
2003 let mut input = b"y\nhel-runson\n\n\n\n".as_slice();
2004 let mut output = Vec::new();
2005
2006 let aws = prompt_aws_target(&mut input, &mut output, Some(&account))
2007 .unwrap()
2008 .unwrap();
2009
2010 assert_eq!(
2011 aws,
2012 AwsTargetInput {
2013 launch_template: "hel-runson".into(),
2014 region: "us-east-1".into(),
2015 ssh_user: DEFAULT_AWS_SSH_USER.into(),
2016 identity_file: None,
2017 }
2018 );
2019 let config = build_config_with_runtime(&[], None, None, Some(&aws), None);
2020 let TargetTemplate::AwsEc2 {
2021 region,
2022 launch_template,
2023 ssh_user,
2024 ..
2025 } = &config.targets[AWS_TARGET_ID]
2026 else {
2027 panic!("setup must write an aws-ec2 target");
2028 };
2029 assert_eq!(region, "us-east-1");
2030 assert_eq!(launch_template, "hel-runson");
2031 assert_eq!(ssh_user, DEFAULT_AWS_SSH_USER);
2032 config.validate().unwrap();
2033 }
2034
2035 const SSH_CONFIG_FIXTURE: &str = r#"
2036# Personal hosts
2037Host *
2038 ServerAliveInterval 60
2039
2040Host builder build.example.com
2041 HostName build.example.com
2042 User dev
2043
2044Host bastion
2045 HostName 10.0.0.1
2046 IdentityFile ~/.ssh/id_ed25519
2047
2048Host prod-*
2049 User deploy
2050
2051Host !staging *.internal
2052 User deploy
2053
2054Host builder
2055 Compression yes
2056"#;
2057
2058 #[test]
2059 fn ssh_config_parsing_keeps_concrete_aliases_and_drops_pattern_blocks() {
2060 let aliases = ssh_config_aliases(SSH_CONFIG_FIXTURE);
2061
2062 assert_eq!(
2063 aliases,
2064 vec!["builder", "build.example.com", "bastion"],
2065 "wildcard, negated, and duplicate entries must not appear"
2066 );
2067 }
2068
2069 #[test]
2070 fn ssh_config_parsing_returns_nothing_for_a_config_of_only_wildcards() {
2071 assert!(
2072 ssh_config_aliases(
2073 "Host *
2074 User dev
2075"
2076 )
2077 .is_empty()
2078 );
2079 assert!(ssh_config_aliases("").is_empty());
2080 }
2081
2082 #[test]
2083 fn the_ssh_step_asks_nothing_when_the_ssh_config_has_no_aliases() {
2084 let mut input = b"".as_slice();
2085 let mut output = Vec::new();
2086
2087 assert_eq!(
2088 prompt_ssh_target(&mut input, &mut output, &[], &BTreeMap::new()).unwrap(),
2089 None
2090 );
2091 let output = String::from_utf8(output).unwrap();
2092 assert!(output.contains("skipping the SSH target"), "{output}");
2093 assert!(!output.contains("[y/N]"), "{output}");
2094 }
2095
2096 #[test]
2097 fn declining_the_ssh_step_writes_no_ssh_target() {
2098 let aliases = vec!["builder".to_owned()];
2099 let mut input = b"\n".as_slice();
2100 let mut output = Vec::new();
2101
2102 assert_eq!(
2103 prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new()).unwrap(),
2104 None
2105 );
2106 let config = build_config_with_runtime(&[], None, None, None, None);
2107 assert!(!config.targets.values().any(|target| matches!(
2108 target,
2109 TargetTemplate::SshBare { .. }
2110 | TargetTemplate::SshPodman { .. }
2111 | TargetTemplate::SshDocker { .. }
2112 )));
2113 }
2114
2115 #[test]
2116 fn accepting_the_ssh_step_can_write_a_docker_target() {
2117 let aliases = vec!["builder".to_owned()];
2118 let mut input = b"y\n1\ndocker\n\n\n".as_slice();
2120 let mut output = Vec::new();
2121
2122 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2123 .unwrap()
2124 .unwrap();
2125 assert_eq!(
2126 ssh.kind,
2127 SshTargetKind::Docker {
2128 image: DEFAULT_IMAGE.into()
2129 }
2130 );
2131 let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
2132 let TargetTemplate::SshDocker { ssh, container } = &config.targets["builder"] else {
2133 panic!("setup must write an ssh-docker target");
2134 };
2135 assert_eq!(ssh.host, "builder");
2136 assert_eq!(container.image, DEFAULT_IMAGE);
2137 config.validate().unwrap();
2138 }
2139
2140 #[test]
2141 fn the_ssh_step_rejects_an_unknown_runtime_before_asking_for_an_image() {
2142 let aliases = vec!["builder".to_owned()];
2143 let mut input = b"y\n1\ncontainerd\ndocker\n\n\n".as_slice();
2144 let mut output = Vec::new();
2145
2146 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2147 .unwrap()
2148 .unwrap();
2149
2150 assert!(matches!(ssh.kind, SshTargetKind::Docker { .. }));
2151 let output = String::from_utf8(output).unwrap();
2152 assert!(output.contains("Runtime must be"), "{output}");
2153 assert_eq!(output.matches("Container image").count(), 1);
2154 }
2155
2156 #[test]
2157 fn accepting_the_ssh_step_writes_an_ssh_podman_target_with_the_default_image() {
2158 let aliases = vec!["builder".to_owned(), "bastion".to_owned()];
2159 let mut input = b"y\n1\n\n\n\n".as_slice();
2161 let mut output = Vec::new();
2162
2163 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2164 .unwrap()
2165 .unwrap();
2166
2167 assert_eq!(
2168 ssh,
2169 SshTargetInput {
2170 name: "builder".into(),
2171 host: "builder".into(),
2172 kind: SshTargetKind::Podman {
2173 image: DEFAULT_IMAGE.into()
2174 },
2175 }
2176 );
2177 let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
2178 let TargetTemplate::SshPodman { ssh, container, .. } = &config.targets["builder"] else {
2179 panic!("setup must write an ssh-podman target");
2180 };
2181 assert_eq!(ssh.host, "builder");
2182 assert_eq!(ssh.user, None);
2183 assert_eq!(ssh.identity_file, None);
2184 assert_eq!(container.image, DEFAULT_IMAGE);
2185 config.validate().unwrap();
2186 }
2187
2188 #[test]
2189 fn accepting_the_ssh_step_writes_an_ssh_bare_target_under_a_chosen_name() {
2190 let aliases = vec!["builder".to_owned()];
2191 let mut input = b"y\nother.example.com\nn\n\nremote\n".as_slice();
2193 let mut output = Vec::new();
2194
2195 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
2196 .unwrap()
2197 .unwrap();
2198
2199 assert_eq!(
2200 ssh,
2201 SshTargetInput {
2202 name: "remote".into(),
2203 host: "other.example.com".into(),
2204 kind: SshTargetKind::Bare {
2205 permissions: PermissionMode::Guardian,
2206 },
2207 }
2208 );
2209 let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
2210 let TargetTemplate::SshBare {
2211 ssh, permissions, ..
2212 } = &config.targets["remote"]
2213 else {
2214 panic!("setup must write an ssh-bare target");
2215 };
2216 assert_eq!(ssh.host, "other.example.com");
2217 assert_eq!(*permissions, PermissionMode::Guardian);
2218 config.validate().unwrap();
2219 }
2220
2221 #[test]
2222 fn the_ssh_step_reasks_until_the_name_is_a_free_and_valid_target_id() {
2223 let aliases = vec!["builder".to_owned()];
2224 let configured = build_config_with_runtime(
2225 &[],
2226 None,
2227 Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2228 None,
2229 None,
2230 )
2231 .targets;
2232 let mut input = b"y\n1\nn\n\npodman\nbuild host\nbuilder\n".as_slice();
2235 let mut output = Vec::new();
2236
2237 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &configured)
2238 .unwrap()
2239 .unwrap();
2240
2241 assert_eq!(ssh.name, "builder");
2242 let output = String::from_utf8(output).unwrap();
2243 assert!(
2244 output.contains("Target podman is already configured"),
2245 "{output}"
2246 );
2247 assert!(output.contains("invalid target id"), "{output}");
2248 }
2249
2250 #[test]
2251 fn the_ssh_step_stops_asking_for_a_name_once_the_input_ends() {
2252 let aliases = vec!["podman".to_owned()];
2253 let configured = build_config_with_runtime(
2254 &[],
2255 None,
2256 Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2257 None,
2258 None,
2259 )
2260 .targets;
2261 let mut input = b"y\n1\nn\n\n".as_slice();
2264 let mut output = Vec::new();
2265
2266 assert_eq!(
2267 prompt_ssh_target(&mut input, &mut output, &aliases, &configured).unwrap(),
2268 None
2269 );
2270 let output = String::from_utf8(output).unwrap();
2271 assert!(output.contains("Input ended; skipping"), "{output}");
2272 }
2273
2274 #[test]
2275 fn an_ssh_target_never_replaces_a_target_configured_earlier() {
2276 let ssh = SshTargetInput {
2277 name: "podman".into(),
2278 host: "builder".into(),
2279 kind: SshTargetKind::Bare {
2280 permissions: PermissionMode::Guardian,
2281 },
2282 };
2283
2284 let config = build_config_with_runtime(
2285 &[],
2286 None,
2287 Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2288 None,
2289 Some(&ssh),
2290 );
2291
2292 assert!(matches!(
2293 config.targets["podman"],
2294 TargetTemplate::LocalPodman { .. }
2295 ));
2296 assert!(matches!(
2297 config.targets["podman-2"],
2298 TargetTemplate::SshBare { .. }
2299 ));
2300 config.validate().unwrap();
2301 }
2302
2303 #[test]
2304 fn the_github_origin_is_discovered_through_the_shared_executor() {
2305 let executor = RuntimeProbeExecutor::new([ok(b"git@github.com:BrokkAi/hel.git\n")]);
2306
2307 let repository = discover_github_repository(&executor, Path::new("/work/hel")).unwrap();
2308
2309 assert_eq!(repository.source(), "BrokkAi/hel");
2310 let commands = executor.commands.borrow();
2311 assert_eq!(commands[0].program, "git");
2312 assert_eq!(
2313 commands[0].args,
2314 ["-C", "/work/hel", "remote", "get-url", "origin"]
2315 );
2316 }
2317
2318 #[test]
2319 fn no_github_origin_is_reported_when_the_probe_fails() {
2320 let failing = RuntimeProbeExecutor::new([failed(b"not a git repository")]);
2321 assert_eq!(
2322 discover_github_repository(&failing, Path::new("/work/plain")),
2323 None
2324 );
2325
2326 let missing = RuntimeProbeExecutor::new([]);
2327 assert_eq!(
2328 discover_github_repository(&missing, Path::new("/work/plain")),
2329 None
2330 );
2331 }
2332
2333 #[test]
2334 fn declining_the_aws_step_writes_no_aws_target() {
2335 let account = AwsAccount {
2336 account: "123456789012".into(),
2337 arn: "arn:aws:iam::123456789012:user/dev".into(),
2338 region: None,
2339 };
2340 let mut input = b"\n".as_slice();
2341 let mut output = Vec::new();
2342
2343 assert_eq!(
2344 prompt_aws_target(&mut input, &mut output, Some(&account)).unwrap(),
2345 None
2346 );
2347 let config = build_config_with_runtime(&[], None, None, None, None);
2348 assert!(!config.targets.contains_key(AWS_TARGET_ID));
2349 }
2350
2351 #[test]
2352 fn smoke_test_removes_the_container_after_a_failed_command() {
2353 let executor = FakeExecutor {
2354 commands: RefCell::new(vec![]),
2355 statuses: vec![0, 1, 0],
2356 };
2357 let mut output = Vec::new();
2358
2359 assert!(
2360 run_smoke_test(
2361 &mut output,
2362 &smoke_target(RuntimeKind::Podman, "ubuntu:24.04"),
2363 &executor
2364 )
2365 .is_err()
2366 );
2367 let commands = executor.commands.borrow();
2368 assert_eq!(commands.len(), 3);
2369 assert_eq!(commands[2].args[0], "rm");
2370 }
2371
2372 #[test]
2373 fn docker_smoke_test_exercises_the_managed_overlay_attachment_path() {
2374 let executor = FakeExecutor::succeeds();
2375 let mut output = Vec::new();
2376
2377 run_smoke_test(
2378 &mut output,
2379 &smoke_target(RuntimeKind::Docker, "ubuntu:24.04"),
2380 &executor,
2381 )
2382 .unwrap();
2383
2384 let commands = executor.commands.borrow();
2385 assert_eq!(commands.len(), 3);
2386 assert_eq!(commands[0].program, "sh");
2387 assert!(commands[0].args[1].contains("docker volume create"));
2388 assert!(commands[0].args[1].contains("type=overlay"));
2389 assert_eq!(commands[1].program, "docker");
2390 assert_eq!(commands[1].args[0], "exec");
2391 assert_eq!(commands[2].program, "sh");
2392 assert!(commands[2].args[1].contains("docker volume rm --force"));
2393 assert!(
2394 String::from_utf8(output)
2395 .unwrap()
2396 .contains("writable OverlayFS attachment")
2397 );
2398 }
2399
2400 #[test]
2401 fn setup_preserves_working_configuration_and_discovers_new_installations() {
2402 let directory = tempfile::tempdir().unwrap();
2403 let path = directory.path().join("config.toml");
2404 let home = DiscoveredHome {
2405 kind: HarnessKind::Codex,
2406 path: directory.path().join("codex"),
2407 authenticated: true,
2408 };
2409 let repository = GithubRepository {
2410 owner: "BrokkAi".into(),
2411 repository: "muse-acp".into(),
2412 };
2413 let mut original = build_config_with_runtimes(
2414 std::slice::from_ref(&home),
2415 Some(&repository),
2416 &[],
2417 None,
2418 None,
2419 );
2420 original.profiles.get_mut("codex").unwrap().enabled = false;
2421 original
2422 .profiles
2423 .get_mut("codex")
2424 .unwrap()
2425 .environment
2426 .insert("KEEP".into(), "custom".into());
2427 original.phone.enabled = false;
2428 original.save_to(&path).unwrap();
2429 let discovery = SetupDiscovery {
2430 homes: vec![
2431 home,
2432 DiscoveredHome {
2433 kind: HarnessKind::Muse,
2434 path: directory.path().join("muse"),
2435 authenticated: true,
2436 },
2437 ],
2438 repository: Some(GithubRepository {
2439 owner: "BrokkAi".into(),
2440 repository: "mjolnir".into(),
2441 }),
2442 ..discovery_without_runtimes()
2443 };
2444 let executor = FakeExecutor::succeeds();
2445 for _ in 0..2 {
2446 let mut output = Vec::new();
2447 run_setup_dialog_with(
2448 &mut b"y\n".as_slice(),
2449 &mut output,
2450 &path,
2451 &discovery,
2452 &executor,
2453 &executor,
2454 )
2455 .unwrap();
2456 let saved = Config::load_from(&path).unwrap();
2457 assert_eq!(saved.profiles["codex"], original.profiles["codex"]);
2458 assert_eq!(
2459 saved.bundles["current-repository"],
2460 original.bundles["current-repository"]
2461 );
2462 assert_eq!(saved.targets, original.targets);
2463 assert_eq!(saved.phone, original.phone);
2464 assert_eq!(saved.profiles.len(), 2);
2465 assert_eq!(saved.profiles["muse"].kind, HarnessKind::Muse);
2466 assert_eq!(saved.bundles.len(), 2);
2467 assert_eq!(
2468 saved.bundles["mjolnir"].repositories[0].github.as_deref(),
2469 Some("BrokkAi/mjolnir")
2470 );
2471 }
2472 }
2473
2474 #[test]
2475 fn setup_target_conflict_keeps_working_target_and_can_add_alternative() {
2476 let original = build_config_with_runtimes(
2477 &[],
2478 None,
2479 &[(RuntimeKind::Podman, "original:image")],
2480 None,
2481 None,
2482 );
2483 for (answer, expected_count) in [("\n", 1), ("a\n", 2)] {
2484 let discovered = build_config_with_runtimes(
2485 &[],
2486 None,
2487 &[(RuntimeKind::Podman, "new:image")],
2488 None,
2489 None,
2490 );
2491 let mut output = Vec::new();
2492 let additions =
2493 reconcile_setup(&mut answer.as_bytes(), &mut output, &original, discovered)
2494 .unwrap();
2495 let mut saved = original.clone();
2496 apply_setup_additions(&mut saved, &additions).unwrap();
2497 assert_eq!(saved.targets["podman"], original.targets["podman"]);
2498 assert_eq!(
2499 saved.targets.len(),
2500 original.targets.len() + expected_count - 1
2501 );
2502 assert!(
2503 String::from_utf8(output)
2504 .unwrap()
2505 .contains("Existing sessions will keep using it")
2506 );
2507 if expected_count == 2 {
2508 assert_eq!(
2509 saved.targets["podman-2"],
2510 local_runtime_target(RuntimeKind::Podman, "new:image").1
2511 );
2512 }
2513 }
2514 }
2515
2516 #[test]
2517 fn setup_reloads_concurrent_changes_and_cancellation_writes_nothing() {
2518 struct ConcurrentEdit<'a> {
2519 path: &'a Path,
2520 answer: &'a str,
2521 conflict: bool,
2522 }
2523 impl SetupPrompter for ConcurrentEdit<'_> {
2524 fn read_prompt(&mut self, _: &mut dyn Write, label: &str) -> Result<Option<String>> {
2525 assert!(label.starts_with("Write this configuration?"));
2526 Config::update_to(self.path, |config| {
2527 config.phone.enabled = false;
2528 if self.conflict {
2529 config.targets.insert(
2530 "localhost".into(),
2531 local_runtime_target(RuntimeKind::Docker, "concurrent:image").1,
2532 );
2533 }
2534 Ok(())
2535 })?;
2536 Ok(Some(self.answer.into()))
2537 }
2538 }
2539 let directory = tempfile::tempdir().unwrap();
2540 let path = directory.path().join("config.toml");
2541 let executor = FakeExecutor::succeeds();
2542 let discovery = discovery_without_runtimes();
2543 for (answer, conflict) in [("y", false), ("n", false), ("y", true)] {
2544 Config::default().save_to(&path).unwrap();
2545 let outcome = run_setup_dialog_inner(
2546 &mut ConcurrentEdit {
2547 path: &path,
2548 answer,
2549 conflict,
2550 },
2551 &mut Vec::new(),
2552 &path,
2553 &discovery,
2554 &executor,
2555 &executor,
2556 );
2557 let saved = Config::load_from(&path).unwrap();
2558 assert!(!saved.phone.enabled);
2559 if conflict {
2560 assert!(
2561 outcome
2562 .unwrap_err()
2563 .to_string()
2564 .contains("changed while setup was open")
2565 );
2566 assert_eq!(
2567 saved.targets["localhost"],
2568 local_runtime_target(RuntimeKind::Docker, "concurrent:image").1
2569 );
2570 } else if answer == "n" {
2571 assert_eq!(outcome.unwrap(), SetupOutcome::Cancelled);
2572 assert!(saved.targets.is_empty());
2573 } else {
2574 assert_eq!(outcome.unwrap(), SetupOutcome::Written);
2575 assert!(matches!(
2576 saved.targets["localhost"],
2577 TargetTemplate::LocalBare
2578 ));
2579 }
2580 }
2581 }
2582
2583 #[test]
2584 fn dialog_configures_every_usable_runtime_as_a_normal_target() {
2585 let directory = tempfile::tempdir().unwrap();
2586 let config_path = directory.path().join("config.toml");
2587 let discovery = SetupDiscovery {
2588 homes: vec![DiscoveredHome {
2589 kind: HarnessKind::Codex,
2590 path: PathBuf::from("/profiles/codex"),
2591 authenticated: true,
2592 }],
2593 repository: Some(GithubRepository {
2594 owner: "BrokkAi".into(),
2595 repository: "hel".into(),
2596 }),
2597 runtimes: vec![
2598 RuntimeProbe {
2599 kind: RuntimeKind::Podman,
2600 usable: true,
2601 detail: "podman version 5".into(),
2602 remediation: None,
2603 },
2604 RuntimeProbe {
2605 kind: RuntimeKind::Docker,
2606 usable: true,
2607 detail: "docker version 29".into(),
2608 remediation: None,
2609 },
2610 ],
2611 aws: None,
2612 ssh_hosts: vec![],
2613 };
2614 let executor = FakeExecutor::succeeds();
2615 let mut input = b"\ny\n".as_slice();
2616 let mut output = Vec::new();
2617
2618 assert_eq!(
2619 run_setup_dialog_with(
2620 &mut input,
2621 &mut output,
2622 &config_path,
2623 &discovery,
2624 &executor,
2625 &executor,
2626 )
2627 .unwrap(),
2628 SetupOutcome::Written
2629 );
2630 assert!(config_path.exists());
2631 let config = Config::load_from(&config_path).unwrap();
2632 assert!(matches!(
2633 config.targets["podman"],
2634 TargetTemplate::LocalPodman { .. }
2635 ));
2636 assert!(matches!(
2637 config.targets["docker"],
2638 TargetTemplate::LocalDocker { .. }
2639 ));
2640 let smoke = executor.commands.borrow()[..3]
2641 .iter()
2642 .map(|command| command.args[0].clone())
2643 .collect::<Vec<_>>();
2644 assert_eq!(smoke, ["run", "exec", "rm"]);
2645 let commands = executor.commands.borrow();
2646 assert!(commands.len() >= 6);
2647 assert_eq!(commands[3].program, "sh");
2648 assert_eq!(commands[4].program, "docker");
2649 assert_eq!(commands[5].program, "sh");
2650 drop(commands);
2651 let output = String::from_utf8(output).unwrap();
2652 assert!(output.contains("Podman target using"), "{output}");
2653 assert!(output.contains("Docker target using"), "{output}");
2654 assert!(!output.contains("Recommended runtime"), "{output}");
2655 assert!(!output.contains("Runtime ("), "{output}");
2656 assert!(output.ends_with("Press n to start your first session.\n"));
2657 }
2658
2659 #[test]
2660 fn a_failed_smoke_test_becomes_a_fixable_line_in_the_closing_report() {
2661 let directory = tempfile::tempdir().unwrap();
2662 let config_path = directory.path().join("config.toml");
2663 let discovery = SetupDiscovery {
2664 runtimes: vec![RuntimeProbe {
2665 kind: RuntimeKind::Podman,
2666 usable: true,
2667 detail: "podman version 5".into(),
2668 remediation: None,
2669 }],
2670 ..discovery_without_runtimes()
2671 };
2672 let executor = FakeExecutor {
2674 commands: RefCell::new(vec![]),
2675 statuses: vec![0, 1, 0],
2676 };
2677 let mut input = b"\ny\n".as_slice();
2678 let mut output = Vec::new();
2679
2680 let outcome = run_setup_dialog_with(
2681 &mut input,
2682 &mut output,
2683 &config_path,
2684 &discovery,
2685 &executor,
2686 &executor,
2687 )
2688 .unwrap();
2689
2690 assert_eq!(outcome, SetupOutcome::Written);
2691 assert!(config_path.exists());
2692 let output = String::from_utf8(output).unwrap();
2693 assert!(output.contains("fixable Podman smoke test"), "{output}");
2694 assert!(
2695 output.contains("remediation: Fix the configured image or the Podman runtime"),
2696 "{output}"
2697 );
2698 assert!(
2701 output.contains("Running `mj doctor` checks on the new config..."),
2702 "{output}"
2703 );
2704 assert!(
2705 output.contains("Apply the remediations above, then rerun `mj doctor`."),
2706 "{output}"
2707 );
2708 assert!(
2709 output.ends_with("Press n to start your first session.\n"),
2710 "{output}"
2711 );
2712 }
2713
2714 #[test]
2715 fn setup_finishes_with_the_standard_doctor_report_for_the_config_it_wrote() {
2716 let directory = tempfile::tempdir().unwrap();
2717 let config_path = directory.path().join("config.toml");
2718 let discovery = SetupDiscovery {
2719 homes: vec![DiscoveredHome {
2720 kind: HarnessKind::Codex,
2721 path: directory.path().join("missing-codex-home"),
2722 authenticated: false,
2723 }],
2724 ..discovery_without_runtimes()
2725 };
2726 let executor = FakeExecutor::succeeds();
2727 let mut input = b"y\n".as_slice();
2728 let mut output = Vec::new();
2729
2730 run_setup_dialog_with(
2731 &mut input,
2732 &mut output,
2733 &config_path,
2734 &discovery,
2735 &executor,
2736 &executor,
2737 )
2738 .unwrap();
2739
2740 let output = String::from_utf8(output).unwrap();
2741 assert!(
2744 output.contains(&format!(
2745 "ready Mjolnir configuration: {} is valid",
2746 config_path.display()
2747 )),
2748 "{output}"
2749 );
2750 assert!(output.contains("fixable Harness profile codex"), "{output}");
2751 assert!(
2752 output.contains(" remediation: Run `mj login --profile codex`"),
2753 "{output}"
2754 );
2755 assert!(
2756 output.contains("Apply the remediations above, then rerun `mj doctor`."),
2757 "{output}"
2758 );
2759 }
2760
2761 #[test]
2762 fn dialog_configures_raw_localhost_without_a_container_runtime() {
2763 let directory = tempfile::tempdir().unwrap();
2764 let config_path = directory.path().join("config.toml");
2765 let discovery = SetupDiscovery {
2766 homes: vec![DiscoveredHome {
2767 kind: HarnessKind::Kimi,
2768 path: PathBuf::from("/profiles/kimi"),
2769 authenticated: true,
2770 }],
2771 repository: None,
2772 runtimes: vec![RuntimeProbe {
2773 kind: RuntimeKind::Podman,
2774 usable: false,
2775 detail: "not installed".into(),
2776 remediation: Some("Install Podman.".into()),
2777 }],
2778 aws: None,
2779 ssh_hosts: vec![],
2780 };
2781 let executor = FakeExecutor::succeeds();
2782 let mut input = b"y\n".as_slice();
2783 let mut output = Vec::new();
2784
2785 assert_eq!(
2786 run_setup_dialog_with(
2787 &mut input,
2788 &mut output,
2789 &config_path,
2790 &discovery,
2791 &executor,
2792 &executor,
2793 )
2794 .unwrap(),
2795 SetupOutcome::Written
2796 );
2797 let config = Config::load_from(&config_path).unwrap();
2798 assert!(matches!(
2799 config.targets["localhost"],
2800 TargetTemplate::LocalBare
2801 ));
2802 assert!(
2805 executor
2806 .commands
2807 .borrow()
2808 .iter()
2809 .all(|command| command.program != "podman" || command.args[0] != "run")
2810 );
2811 let output = String::from_utf8(output).unwrap();
2812 assert!(output.contains("DANGER"));
2813 assert!(output.contains("has no guardian approval mode"));
2814 assert!(output.contains("raw localhost will still be configured"));
2815 }
2816
2817 #[test]
2818 fn discovered_homes_warn_for_harnesses_without_guardian_approvals() {
2819 let warning = |kind: HarnessKind| {
2820 let mut output = Vec::new();
2821 write_discovered_homes(
2822 &mut output,
2823 &[DiscoveredHome {
2824 kind,
2825 path: PathBuf::from("/profiles/harness"),
2826 authenticated: true,
2827 }],
2828 )
2829 .unwrap();
2830 String::from_utf8(output).unwrap()
2831 };
2832
2833 for kind in [HarnessKind::Kimi, HarnessKind::Muse] {
2834 let output = warning(kind);
2835 assert!(output.contains("DANGER"), "{kind:?}: {output}");
2836 assert!(
2837 output.contains("has no guardian approval mode"),
2838 "{kind:?}: {output}"
2839 );
2840 assert!(output.contains("raw, unsandboxed target"), "{output}");
2841 }
2842
2843 for kind in [HarnessKind::Codex, HarnessKind::Claude, HarnessKind::Grok] {
2844 assert!(!warning(kind).contains("DANGER"), "{kind:?}");
2845 }
2846 }
2847}