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};
9
10use crate::hel_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 hel::hel_config::harness_authentication_marker;
16use hel::hel_config::{
17 AwsAddressSource, ContainerTemplate, HarnessKind, HarnessProfile, HelConfig, PermissionMode,
18 ProjectBundle, ProjectRepository, SshConnection, TargetTemplate, validate_id,
19};
20use hel::hel_targets::{
21 CancellableProcessExecutor, CommandExecutor, CommandSpec,
22 ContainerTemplate as RuntimeContainerTemplate, ProcessExecutor,
23 TargetTemplate as RuntimeTargetTemplate, run_setup_smoke_test,
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 = HelConfig::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 HelConfig::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 HelConfig, 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 },
187 );
188 config
189 .targets
190 .insert("localhost".into(), TargetTemplate::LocalBare);
191}
192
193pub fn run_setup_dialog(config_path: &Path) -> Result<SetupOutcome> {
195 let probes = probe_executor();
199 let discovery = discover_current(&probes);
200 let stdout = io::stdout();
201 let mut input = ReadlinePrompter::default();
202 run_setup_dialog_inner(
203 &mut input,
204 &mut stdout.lock(),
205 config_path,
206 &discovery,
207 &ProcessExecutor,
208 &probes,
209 )
210}
211
212pub fn discover_current(executor: &impl CommandExecutor) -> SetupDiscovery {
213 let home = dirs::home_dir();
214 let overrides = HarnessKind::ALL.into_iter().filter_map(|kind| {
215 std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
216 });
217 let homes = discover_harness_homes_with_executor(home.as_deref(), overrides, executor);
218 let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
219
220 SetupDiscovery {
221 homes,
222 repository: discover_github_repository(executor, &cwd),
223 runtimes: probe_local_runtimes(executor, cfg!(target_os = "macos")),
224 aws: detect_aws(&CancellableProcessExecutor::with_timeout(AWS_PROBE_TIMEOUT)),
225 ssh_hosts: discover_ssh_hosts(home.as_deref()),
226 }
227}
228
229pub fn discover_ssh_hosts(home: Option<&Path>) -> Vec<String> {
237 let Some(home) = home else {
238 return Vec::new();
239 };
240 let Ok(contents) = std::fs::read_to_string(home.join(".ssh").join("config")) else {
241 return Vec::new();
242 };
243 ssh_config_aliases(&contents)
244}
245
246pub fn ssh_config_aliases(contents: &str) -> Vec<String> {
251 let mut aliases: Vec<String> = Vec::new();
252 for line in contents.lines() {
253 let line = line.trim();
254 if line.is_empty() || line.starts_with('#') {
255 continue;
256 }
257 let Some((keyword, rest)) = line.split_once(char::is_whitespace) else {
258 continue;
259 };
260 if !keyword.eq_ignore_ascii_case("host") {
261 continue;
262 }
263 for alias in rest.split_whitespace() {
264 let alias = alias.trim_matches('"');
265 if alias.is_empty() || alias.contains(['*', '?', '!']) {
266 continue;
267 }
268 if !aliases.iter().any(|existing| existing == alias) {
269 aliases.push(alias.to_owned());
270 }
271 }
272 }
273 aliases
274}
275
276pub fn discover_harness_homes(
277 home: Option<&Path>,
278 overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
279) -> Vec<DiscoveredHome> {
280 discover_harness_homes_with_executor(home, overrides, &probe_executor())
281}
282
283pub(crate) fn discover_harness_homes_with_executor(
284 home: Option<&Path>,
285 overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
286 executor: &impl CommandExecutor,
287) -> Vec<DiscoveredHome> {
288 let mut candidates = Vec::new();
289 if let Some(home) = home {
290 candidates.extend(
291 HarnessKind::ALL
292 .into_iter()
293 .map(|kind| (kind, home.join(kind.default_home_leaf()), true)),
294 );
295 }
296 candidates.extend(
297 overrides
298 .into_iter()
299 .map(|(kind, path)| (kind, path, false)),
300 );
301
302 let mut seen = BTreeSet::new();
303 candidates
304 .into_iter()
305 .filter(|(kind, path, _)| seen.insert((*kind, path.clone())) && path.is_dir())
306 .map(|(kind, path, is_default_home)| DiscoveredHome {
307 authenticated: harness_is_authenticated_with(kind, &path, is_default_home, executor),
308 kind,
309 path,
310 })
311 .collect()
312}
313
314pub fn harness_is_authenticated(kind: HarnessKind, home: &Path) -> bool {
315 harness_is_authenticated_with_executor(kind, home, &probe_executor())
316}
317
318pub(crate) fn harness_is_authenticated_with_executor(
319 kind: HarnessKind,
320 home: &Path,
321 executor: &impl CommandExecutor,
322) -> bool {
323 let is_default_home =
324 dirs::home_dir().is_some_and(|user_home| home == user_home.join(kind.default_home_leaf()));
325 harness_is_authenticated_with(kind, home, is_default_home, executor)
326}
327
328fn harness_is_authenticated_with(
329 kind: HarnessKind,
330 home: &Path,
331 is_default_home: bool,
332 executor: &impl CommandExecutor,
333) -> bool {
334 if harness_authentication_marker(kind, home).is_file()
335 || (kind == HarnessKind::Kimi && home.join("credentials").is_file())
336 {
337 return true;
338 }
339 if kind != HarnessKind::Claude {
340 return false;
341 }
342 if is_default_home && claude_keychain_reports_authenticated(executor) {
343 return true;
344 }
345 if !is_default_home && claude_cli_reports_authenticated(home, executor) {
346 return true;
347 }
348 false
349}
350
351fn claude_cli_reports_authenticated(home: &Path, executor: &impl CommandExecutor) -> bool {
355 let mut command = CommandSpec::new("claude", ["auth", "status", "--json"])
356 .purpose("check Claude Code authentication");
357 command.env.insert(
358 HarnessKind::Claude.home_env().to_owned(),
359 home.to_string_lossy().into_owned(),
360 );
361 let Ok(output) = executor.execute(&command) else {
362 return false;
363 };
364 if output.status != 0 {
365 return false;
366 }
367 serde_json::from_slice::<serde_json::Value>(&output.stdout)
368 .ok()
369 .and_then(|status| status.get("loggedIn").and_then(serde_json::Value::as_bool))
370 == Some(true)
371}
372
373#[cfg(target_os = "macos")]
377fn claude_keychain_reports_authenticated(executor: &impl CommandExecutor) -> bool {
378 let command = CommandSpec::new(
379 "security",
380 [
381 "find-generic-password",
382 "-s",
383 "Claude Code-credentials",
384 "-w",
385 ],
386 )
387 .purpose("check Claude Code authentication in the macOS Keychain");
388 let Ok(output) = executor.execute(&command) else {
389 return false;
390 };
391 output.status == 0 && claude_credentials_contain_login(&output.stdout)
392}
393
394#[cfg(not(target_os = "macos"))]
395fn claude_keychain_reports_authenticated(_executor: &impl CommandExecutor) -> bool {
396 false
397}
398
399#[cfg(any(target_os = "macos", test))]
400fn claude_credentials_contain_login(credentials: &[u8]) -> bool {
401 let Ok(document) = serde_json::from_slice::<serde_json::Value>(credentials) else {
402 return false;
403 };
404 [
405 "/claudeAiOauth/accessToken",
406 "/claudeAiOauth/refreshToken",
407 "/oauth/accessToken",
408 "/apiKey",
409 ]
410 .into_iter()
411 .any(|pointer| {
412 document
413 .pointer(pointer)
414 .and_then(serde_json::Value::as_str)
415 .is_some_and(|value| !value.trim().is_empty())
416 })
417}
418
419pub fn github_repository_from_origin(origin: &str) -> Option<GithubRepository> {
420 let origin = origin.trim();
421 let path = origin
422 .strip_prefix("https://github.com/")
423 .or_else(|| origin.strip_prefix("http://github.com/"))
424 .or_else(|| origin.strip_prefix("git@github.com:"))
425 .or_else(|| origin.strip_prefix("ssh://git@github.com/"))
426 .unwrap_or(origin);
429 let path = path.trim_end_matches(".git");
430 let mut parts = path.split('/');
431 let owner = parts.next()?;
432 let repository = parts.next()?;
433 if owner.is_empty()
434 || repository.is_empty()
435 || parts.next().is_some()
436 || owner.chars().any(char::is_whitespace)
437 || repository.chars().any(char::is_whitespace)
438 {
439 return None;
440 }
441 Some(GithubRepository {
442 owner: owner.to_owned(),
443 repository: repository.to_owned(),
444 })
445}
446
447fn discover_github_repository(
453 executor: &impl CommandExecutor,
454 cwd: &Path,
455) -> Option<GithubRepository> {
456 let command = CommandSpec::new(
457 "git",
458 [
459 "-C".to_owned(),
460 cwd.to_string_lossy().into_owned(),
461 "remote".to_owned(),
462 "get-url".to_owned(),
463 "origin".to_owned(),
464 ],
465 )
466 .purpose("detect the current repository's GitHub origin");
467 let output = executor.execute(&command).ok()?;
468 if output.status != 0 {
469 return None;
470 }
471 github_repository_from_origin(&String::from_utf8_lossy(&output.stdout))
472}
473
474pub fn probe_local_runtimes(executor: &impl CommandExecutor, is_macos: bool) -> Vec<RuntimeProbe> {
477 let mut probes = vec![
478 runtime_probe_from_check(RuntimeKind::Podman, local_podman_runtime_check(executor)),
479 runtime_probe_from_check(RuntimeKind::Docker, local_docker_runtime_check(executor)),
480 ];
481 if is_macos {
482 probes.push(runtime_probe_from_check(
483 RuntimeKind::AppleContainer,
484 apple_container_daemon_check(executor),
485 ));
486 }
487 probes
488}
489
490fn runtime_probe_from_check(
491 kind: RuntimeKind,
492 check: crate::hel_doctor::DoctorCheck,
493) -> RuntimeProbe {
494 RuntimeProbe {
495 kind,
496 usable: check.status == CheckStatus::Ready,
497 detail: check.detail,
498 remediation: check.remediation,
499 }
500}
501
502pub fn detect_aws(executor: &impl CommandExecutor) -> Option<AwsAccount> {
508 let identity = CommandSpec::new("aws", ["sts", "get-caller-identity", "--output", "json"])
509 .purpose("detect AWS credentials");
510 let output = executor.execute(&identity).ok()?;
511 if output.status != 0 {
512 return None;
513 }
514 let identity: serde_json::Value = serde_json::from_slice(&output.stdout).ok()?;
515 let account = identity.get("Account")?.as_str()?.to_owned();
516 let arn = identity.get("Arn")?.as_str()?.to_owned();
517 Some(AwsAccount {
518 account,
519 arn,
520 region: configured_aws_region(executor),
521 })
522}
523
524fn configured_aws_region(executor: &impl CommandExecutor) -> Option<String> {
525 let command = CommandSpec::new("aws", ["configure", "get", "region"])
526 .purpose("read the default AWS region");
527 let output = executor.execute(&command).ok()?;
528 if output.status != 0 {
529 return None;
530 }
531 let region = String::from_utf8_lossy(&output.stdout).trim().to_owned();
532 (!region.is_empty()).then_some(region)
533}
534
535pub fn build_config(
536 homes: &[DiscoveredHome],
537 repository: Option<&GithubRepository>,
538 runtime: RuntimeKind,
539 image: &str,
540) -> HelConfig {
541 build_config_with_runtime(homes, repository, Some((runtime, image)), None, None)
542}
543
544fn build_config_with_runtime(
545 homes: &[DiscoveredHome],
546 repository: Option<&GithubRepository>,
547 runtime: Option<(RuntimeKind, &str)>,
548 aws: Option<&AwsTargetInput>,
549 ssh: Option<&SshTargetInput>,
550) -> HelConfig {
551 build_config_with_runtimes(
552 homes,
553 repository,
554 &runtime.into_iter().collect::<Vec<_>>(),
555 aws,
556 ssh,
557 )
558}
559
560fn build_config_with_runtimes(
561 homes: &[DiscoveredHome],
562 repository: Option<&GithubRepository>,
563 runtimes: &[(RuntimeKind, &str)],
564 aws: Option<&AwsTargetInput>,
565 ssh: Option<&SshTargetInput>,
566) -> HelConfig {
567 let mut config = HelConfig::default();
568 for home in homes {
569 let id = unique_id(&config.profiles, home.kind.id());
570 config.profiles.insert(
571 id,
572 HarnessProfile {
573 enabled: true,
574 kind: home.kind,
575 home: home.path.clone(),
576 environment: BTreeMap::new(),
577 context_window_bytes: None,
578 },
579 );
580 }
581
582 if let Some(repository) = repository {
583 let repository_id = config_id(&repository.repository);
584 config.bundles.insert(
585 "current-repository".to_owned(),
586 ProjectBundle {
587 primary_repo: repository_id.clone(),
588 repositories: vec![ProjectRepository {
589 id: repository_id.clone(),
590 github: Some(repository.source()),
591 local: None,
592 destination: PathBuf::from(repository_id),
593 git_ref: None,
594 }],
595 },
596 );
597 }
598
599 #[cfg(unix)]
600 config
601 .targets
602 .insert("localhost".to_owned(), TargetTemplate::LocalBare);
603 for (runtime, image) in runtimes {
604 let (target_id, target) = local_runtime_target(*runtime, image);
605 config.targets.insert(target_id.to_owned(), target);
606 }
607 if let Some(aws) = aws {
608 config.targets.insert(
609 AWS_TARGET_ID.to_owned(),
610 TargetTemplate::AwsEc2 {
611 aws_profile: None,
612 region: aws.region.clone(),
613 launch_template: aws.launch_template.clone(),
614 launch_template_version: None,
615 ssh_user: aws.ssh_user.clone(),
616 address_source: AwsAddressSource::default(),
617 identity_file: aws.identity_file.clone(),
618 ssh_args: vec![],
619 },
620 );
621 }
622 if let Some(ssh) = ssh {
623 let connection = SshConnection {
626 host: ssh.host.clone(),
627 user: None,
628 identity_file: None,
629 extra_args: vec![],
630 };
631 let target = match &ssh.kind {
632 SshTargetKind::Bare { permissions } => TargetTemplate::SshBare {
633 ssh: connection,
634 permissions: *permissions,
635 workspace_prefix: default_ssh_workspace_prefix(),
636 },
637 SshTargetKind::Podman { image } => TargetTemplate::SshPodman {
638 ssh: connection,
639 container: ContainerTemplate {
640 image: image.clone(),
641 pull_policy: Default::default(),
642 platform: None,
643 cpus: None,
644 memory: None,
645 environment: BTreeMap::new(),
646 workspace_storage: Default::default(),
647 },
648 },
649 SshTargetKind::Docker { image } => TargetTemplate::SshDocker {
650 ssh: connection,
651 container: ContainerTemplate {
652 image: image.clone(),
653 pull_policy: Default::default(),
654 platform: None,
655 cpus: None,
656 memory: None,
657 environment: BTreeMap::new(),
658 workspace_storage: Default::default(),
659 },
660 },
661 };
662 config
666 .targets
667 .insert(unique_id(&config.targets, &ssh.name), target);
668 }
669 config
670}
671
672pub fn local_runtime_target(runtime: RuntimeKind, image: &str) -> (&'static str, TargetTemplate) {
674 let container = ContainerTemplate {
675 image: image.trim().to_owned(),
676 pull_policy: Default::default(),
677 platform: None,
678 cpus: None,
679 memory: None,
680 environment: BTreeMap::new(),
681 workspace_storage: Default::default(),
682 };
683 match runtime {
684 RuntimeKind::Podman => ("podman", TargetTemplate::LocalPodman { container }),
685 RuntimeKind::Docker => ("docker", TargetTemplate::LocalDocker { container }),
686 RuntimeKind::AppleContainer => (
687 "apple-container",
688 TargetTemplate::AppleContainer { container },
689 ),
690 }
691}
692
693fn default_ssh_workspace_prefix() -> PathBuf {
695 PathBuf::from(".local/share/hel/workspaces")
696}
697
698fn unique_id<T>(entries: &BTreeMap<String, T>, base_id: &str) -> String {
701 if !entries.contains_key(base_id) {
702 return base_id.to_owned();
703 }
704 let mut number = 2;
705 loop {
706 let candidate = format!("{base_id}-{number}");
707 if !entries.contains_key(&candidate) {
708 return candidate;
709 }
710 number += 1;
711 }
712}
713
714fn config_id(value: &str) -> String {
715 let mut id = value
716 .chars()
717 .filter(|character| {
718 character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
719 })
720 .take(64)
721 .collect::<String>();
722 if id.is_empty() || matches!(id.as_str(), "." | "..") {
723 id = "repository".to_owned();
724 }
725 id
726}
727
728pub fn run_setup_dialog_with(
735 input: &mut impl BufRead,
736 output: &mut impl Write,
737 config_path: &Path,
738 discovery: &SetupDiscovery,
739 smoke_executor: &impl CommandExecutor,
740 probe_executor: &impl CommandExecutor,
741) -> Result<SetupOutcome> {
742 run_setup_dialog_inner(
743 input,
744 output,
745 config_path,
746 discovery,
747 smoke_executor,
748 probe_executor,
749 )
750}
751
752fn run_setup_dialog_inner(
753 input: &mut impl SetupPrompter,
754 output: &mut impl Write,
755 config_path: &Path,
756 discovery: &SetupDiscovery,
757 smoke_executor: &impl CommandExecutor,
758 probe_executor: &impl CommandExecutor,
759) -> Result<SetupOutcome> {
760 writeln!(output, "Welcome to Mjolnir setup.")?;
761 writeln!(output)?;
762 write_discovered_homes(output, &discovery.homes)?;
763 write_repository(output, discovery.repository.as_ref())?;
764 write_runtimes(output, &discovery.runtimes)?;
765
766 let runtimes = if discovery.runtimes.iter().any(|runtime| runtime.usable) {
767 let image = prompt(
768 input,
769 output,
770 &format!("Container image [{DEFAULT_IMAGE}]: "),
771 )?;
772 let image = if image.is_empty() {
773 DEFAULT_IMAGE.to_owned()
774 } else {
775 image
776 };
777 discovery
778 .runtimes
779 .iter()
780 .filter(|runtime| runtime.usable)
781 .map(|runtime| (runtime.kind, image.clone()))
782 .collect::<Vec<_>>()
783 } else {
784 writeln!(
785 output,
786 "No usable container runtime found; raw localhost will still be configured."
787 )?;
788 Vec::new()
789 };
790 let aws = prompt_aws_target(input, output, discovery.aws.as_ref())?;
791 let runtime_choices = runtimes
792 .iter()
793 .map(|(runtime, image)| (*runtime, image.as_str()))
794 .collect::<Vec<_>>();
795 let configured = build_config_with_runtimes(
798 &discovery.homes,
799 discovery.repository.as_ref(),
800 &runtime_choices,
801 aws.as_ref(),
802 None,
803 );
804 let ssh = prompt_ssh_target(input, output, &discovery.ssh_hosts, &configured.targets)?;
805 let config = build_config_with_runtimes(
806 &discovery.homes,
807 discovery.repository.as_ref(),
808 &runtime_choices,
809 aws.as_ref(),
810 ssh.as_ref(),
811 );
812 config.validate()?;
813
814 writeln!(output)?;
815 write_summary(output, config_path, &config, &runtimes)?;
816 let confirmation = prompt(input, output, "Write this configuration? [y/N]: ")?;
817 if !matches!(confirmation.to_ascii_lowercase().as_str(), "y" | "yes") {
818 writeln!(output, "Setup cancelled.")?;
819 return Ok(SetupOutcome::Cancelled);
820 }
821
822 writeln!(output, "Writing {}...", config_path.display())?;
823 config.save_to(config_path)?;
824 let smoke_failures = runtimes
828 .iter()
829 .filter_map(|(runtime, image)| {
830 let target = smoke_target(*runtime, image);
831 run_smoke_test(output, &target, smoke_executor)
832 .err()
833 .map(|error| smoke_failure_check(*runtime, image, &error))
834 })
835 .collect();
836 write_doctor_report(output, config_path, probe_executor, smoke_failures)?;
837 writeln!(
838 output,
839 "Advanced users can edit TOML for extra profiles, virtual monorepos, SSH, and AWS."
840 )?;
841 writeln!(output, "Press n to start your first session.")?;
842 Ok(SetupOutcome::Written)
843}
844
845fn write_discovered_homes(output: &mut impl Write, homes: &[DiscoveredHome]) -> Result<()> {
846 writeln!(output, "Harness homes:")?;
847 if homes.is_empty() {
848 writeln!(
849 output,
850 " No existing Codex, Claude Code, Kimi Code, or Grok Build homes found."
851 )?;
852 }
853 for home in homes {
854 let authentication = if home.authenticated {
855 "authenticated"
856 } else {
857 "not authenticated"
858 };
859 writeln!(
860 output,
861 " {}: {} ({authentication}){}",
862 home.kind.display_name(),
863 home.path.display(),
864 match home.kind.unsandboxed_guardian_warning() {
865 Some(warning) => format!(" — {warning}"),
866 None => String::new(),
867 }
868 )?;
869 }
870 Ok(())
871}
872
873fn write_repository(output: &mut impl Write, repository: Option<&GithubRepository>) -> Result<()> {
874 match repository {
875 Some(repository) => writeln!(
876 output,
877 "GitHub origin: {} (a one-repository bundle will be created)",
878 repository.source()
879 )?,
880 None => writeln!(
881 output,
882 "GitHub origin: none detected in the current directory."
883 )?,
884 }
885 Ok(())
886}
887
888fn write_runtimes(output: &mut impl Write, runtimes: &[RuntimeProbe]) -> Result<()> {
889 writeln!(output, "Local runtimes:")?;
890 for runtime in runtimes {
891 let state = if runtime.usable {
892 "usable"
893 } else {
894 "unavailable"
895 };
896 if runtime.detail.is_empty() {
897 writeln!(output, " {}: {state}", runtime.kind.label())?;
898 } else {
899 writeln!(
900 output,
901 " {}: {state} ({})",
902 runtime.kind.label(),
903 runtime.detail
904 )?;
905 }
906 if let Some(remediation) = &runtime.remediation {
907 writeln!(output, " remediation: {remediation}")?;
908 }
909 }
910 Ok(())
911}
912
913fn prompt_aws_target(
916 input: &mut impl SetupPrompter,
917 output: &mut impl Write,
918 account: Option<&AwsAccount>,
919) -> Result<Option<AwsTargetInput>> {
920 let Some(account) = account else {
921 writeln!(
922 output,
923 "AWS: no working `aws` CLI credentials found; skipping the AWS target."
924 )?;
925 return Ok(None);
926 };
927 writeln!(
928 output,
929 "AWS: credentials are valid for account {} ({}).",
930 account.account, account.arn
931 )?;
932 let answer = prompt(input, output, "Add an AWS EC2 target? [y/N]: ")?;
933 if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
934 return Ok(None);
935 }
936
937 let launch_template = prompt(input, output, "Launch template name: ")?;
938 if launch_template.is_empty() {
939 writeln!(
940 output,
941 "A launch template name is required; skipping the AWS target."
942 )?;
943 return Ok(None);
944 }
945
946 let region_label = match &account.region {
947 Some(region) => format!("Region [{region}]: "),
948 None => "Region: ".to_owned(),
949 };
950 let region = prompt(input, output, ®ion_label)?;
951 let region = if region.is_empty() {
952 match &account.region {
953 Some(region) => region.clone(),
954 None => {
955 writeln!(output, "A region is required; skipping the AWS target.")?;
956 return Ok(None);
957 }
958 }
959 } else {
960 region
961 };
962
963 let ssh_user = prompt(
964 input,
965 output,
966 &format!("SSH user [{DEFAULT_AWS_SSH_USER}]: "),
967 )?;
968 let ssh_user = if ssh_user.is_empty() {
969 DEFAULT_AWS_SSH_USER.to_owned()
970 } else {
971 ssh_user
972 };
973 let identity_file = prompt(input, output, "SSH identity file (optional): ")?;
974
975 Ok(Some(AwsTargetInput {
976 launch_template,
977 region,
978 ssh_user,
979 identity_file: (!identity_file.is_empty()).then(|| PathBuf::from(identity_file)),
980 }))
981}
982
983fn prompt_ssh_target(
988 input: &mut impl SetupPrompter,
989 output: &mut impl Write,
990 aliases: &[String],
991 configured: &BTreeMap<String, TargetTemplate>,
992) -> Result<Option<SshTargetInput>> {
993 if aliases.is_empty() {
994 writeln!(
995 output,
996 "SSH: no host aliases found in ~/.ssh/config; skipping the SSH target."
997 )?;
998 return Ok(None);
999 }
1000 writeln!(output, "SSH: hosts found in ~/.ssh/config:")?;
1001 for (index, alias) in aliases.iter().enumerate() {
1002 writeln!(output, " {}) {alias}", index + 1)?;
1003 }
1004 let answer = prompt(input, output, "Add an SSH target? [y/N]: ")?;
1005 if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") {
1006 return Ok(None);
1007 }
1008
1009 let choice = prompt(
1010 input,
1011 output,
1012 &format!("Host number 1-{} or a host name: ", aliases.len()),
1013 )?;
1014 let host = match choice.parse::<usize>() {
1015 Ok(index) if (1..=aliases.len()).contains(&index) => aliases[index - 1].clone(),
1016 _ if !choice.is_empty() => choice,
1017 _ => {
1018 writeln!(output, "A host is required; skipping the SSH target.")?;
1019 return Ok(None);
1020 }
1021 };
1022
1023 let kind = loop {
1024 let runtime = prompt(
1025 input,
1026 output,
1027 "Container runtime on that host, podman, docker, or bare [podman]: ",
1028 )?;
1029 let runtime = runtime.to_ascii_lowercase();
1030 if matches!(runtime.as_str(), "n" | "no" | "bare") {
1031 let permissions = loop {
1032 let mode = prompt(
1033 input,
1034 output,
1035 "Raw-host permissions, guardian or yolo [guardian]: ",
1036 )?;
1037 match mode.to_ascii_lowercase().as_str() {
1038 "" | "guardian" => break PermissionMode::Guardian,
1039 "yolo" => break PermissionMode::Yolo,
1040 _ => writeln!(output, "Permissions must be `guardian` or `yolo`.")?,
1041 }
1042 };
1043 break SshTargetKind::Bare { permissions };
1044 }
1045 let kind = if matches!(runtime.as_str(), "" | "y" | "yes" | "podman") {
1046 SshTargetKind::Podman {
1047 image: String::new(),
1048 }
1049 } else if runtime == "docker" {
1050 SshTargetKind::Docker {
1051 image: String::new(),
1052 }
1053 } else {
1054 writeln!(
1055 output,
1056 "Runtime must be `podman`, `docker`, or `bare`; please choose again."
1057 )?;
1058 continue;
1059 };
1060 let image = prompt(
1061 input,
1062 output,
1063 &format!("Container image [{DEFAULT_IMAGE}]: "),
1064 )?;
1065 let image = if image.is_empty() {
1066 DEFAULT_IMAGE.to_owned()
1067 } else {
1068 image
1069 };
1070 break match kind {
1071 SshTargetKind::Podman { .. } => SshTargetKind::Podman { image },
1072 SshTargetKind::Docker { .. } => SshTargetKind::Docker { image },
1073 SshTargetKind::Bare { .. } => unreachable!("bare runtime returned above"),
1074 };
1075 };
1076
1077 let Some(name) = prompt_ssh_target_name(input, output, &host, configured)? else {
1078 return Ok(None);
1079 };
1080
1081 Ok(Some(SshTargetInput { name, host, kind }))
1082}
1083
1084fn prompt_ssh_target_name(
1091 input: &mut impl SetupPrompter,
1092 output: &mut impl Write,
1093 host: &str,
1094 configured: &BTreeMap<String, TargetTemplate>,
1095) -> Result<Option<String>> {
1096 loop {
1097 let Some(answer) = prompt_line(input, output, &format!("Target name [{host}]: "))? else {
1098 writeln!(output, "Input ended; skipping the SSH target.")?;
1099 return Ok(None);
1100 };
1101 let name = if answer.is_empty() {
1102 host.to_owned()
1103 } else {
1104 answer
1105 };
1106 if let Err(error) = validate_id("target", &name) {
1107 writeln!(output, "{error}")?;
1108 continue;
1109 }
1110 if configured.contains_key(&name) {
1111 writeln!(
1112 output,
1113 "Target {name} is already configured; choose another name."
1114 )?;
1115 continue;
1116 }
1117 return Ok(Some(name));
1118 }
1119}
1120
1121fn smoke_failure_check(runtime: RuntimeKind, image: &str, error: &anyhow::Error) -> DoctorCheck {
1124 let scope = match runtime {
1125 RuntimeKind::Docker => "Disposable run/exec/remove and OverlayFS attachment smoke test",
1126 RuntimeKind::Podman | RuntimeKind::AppleContainer => {
1127 "Disposable run/exec/remove smoke test"
1128 }
1129 };
1130 DoctorCheck::fixable(
1131 format!("runtime.{}.smoke", runtime.id()),
1132 format!("{} smoke test", runtime.label()),
1133 format!("{scope} failed for image {image}: {error:#}"),
1134 format!(
1135 "Fix the configured image or the {} runtime, then run `mj doctor --smoke` again.",
1136 runtime.label()
1137 ),
1138 )
1139}
1140
1141fn write_doctor_report(
1147 output: &mut impl Write,
1148 config_path: &Path,
1149 executor: &impl CommandExecutor,
1150 extra: Vec<DoctorCheck>,
1151) -> Result<()> {
1152 writeln!(output)?;
1153 writeln!(output, "Running `mj doctor` checks on the new config...")?;
1154 let mut checks = run_with_config_path(
1155 config_path,
1156 executor,
1157 current_apple_platform(executor),
1158 DoctorOptions { smoke: false },
1159 );
1160 checks.extend(extra);
1161 render_human(&checks, output)?;
1162 if all_ready(&checks) {
1163 writeln!(output, "Every check is ready.")?;
1164 } else {
1165 writeln!(
1166 output,
1167 "Apply the remediations above, then rerun `mj doctor`."
1168 )?;
1169 }
1170 Ok(())
1171}
1172
1173fn prompt(input: &mut impl SetupPrompter, output: &mut impl Write, label: &str) -> Result<String> {
1174 Ok(prompt_line(input, output, label)?.unwrap_or_default())
1175}
1176
1177fn prompt_line(
1183 input: &mut impl SetupPrompter,
1184 output: &mut impl Write,
1185 label: &str,
1186) -> Result<Option<String>> {
1187 input.read_prompt(output, label)
1188}
1189
1190trait SetupPrompter {
1191 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>>;
1192}
1193
1194impl<R: BufRead> SetupPrompter for R {
1195 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1196 write!(output, "{label}")?;
1197 output.flush()?;
1198 let mut answer = String::new();
1199 let read = self.read_line(&mut answer).context("read setup response")?;
1200 Ok((read > 0).then(|| answer.trim().to_owned()))
1201 }
1202}
1203
1204#[derive(Default)]
1205struct ReadlinePrompter(crate::hel_readline::LineReader);
1206
1207impl SetupPrompter for ReadlinePrompter {
1208 fn read_prompt(&mut self, output: &mut dyn Write, label: &str) -> Result<Option<String>> {
1209 output.flush()?;
1210 self.0.read_line(label).context("read setup response")
1211 }
1212}
1213
1214fn write_summary(
1215 output: &mut impl Write,
1216 config_path: &Path,
1217 config: &HelConfig,
1218 runtimes: &[(RuntimeKind, String)],
1219) -> Result<()> {
1220 writeln!(output, "Mjolnir will write {} with:", config_path.display())?;
1221 writeln!(output, " {} profile(s)", config.profiles.len())?;
1222 writeln!(output, " {} bundle(s)", config.bundles.len())?;
1223 writeln!(
1224 output,
1225 " raw localhost target using configured harness homes directly"
1226 )?;
1227 for (runtime, _) in runtimes {
1228 let target = config
1229 .targets
1230 .get(runtime.id())
1231 .expect("configured runtime target exists");
1232 let image = match target {
1233 TargetTemplate::LocalPodman { container }
1234 | TargetTemplate::LocalDocker { container }
1235 | TargetTemplate::AppleContainer { container } => &container.image,
1236 _ => unreachable!("setup runtime target is a local container"),
1237 };
1238 writeln!(output, " {} target using {image}", runtime.label())?;
1239 }
1240 if let Some(TargetTemplate::AwsEc2 {
1241 launch_template,
1242 region,
1243 ..
1244 }) = config.targets.get(AWS_TARGET_ID)
1245 {
1246 writeln!(
1247 output,
1248 " AWS EC2 target using launch template {launch_template} in {region}"
1249 )?;
1250 }
1251 for (id, target) in &config.targets {
1252 match target {
1253 TargetTemplate::SshBare { ssh, .. } => {
1254 writeln!(output, " SSH target {id} on {} (no container)", ssh.host)?;
1255 }
1256 TargetTemplate::SshPodman { ssh, container, .. } => {
1257 writeln!(
1258 output,
1259 " SSH target {id} on {} using Podman image {}",
1260 ssh.host, container.image
1261 )?;
1262 }
1263 TargetTemplate::SshDocker { ssh, container } => {
1264 writeln!(
1265 output,
1266 " SSH target {id} on {} using Docker image {}",
1267 ssh.host, container.image
1268 )?;
1269 }
1270 _ => {}
1271 }
1272 }
1273 if config_path.exists() {
1274 writeln!(output, " This replaces the existing configuration file.")?;
1275 }
1276 Ok(())
1277}
1278
1279fn smoke_target(runtime: RuntimeKind, image: &str) -> RuntimeTargetTemplate {
1280 let container = RuntimeContainerTemplate {
1281 image: image.to_owned(),
1282 pull_policy: Default::default(),
1283 extra_run_args: vec![],
1284 workspace_storage: Default::default(),
1285 };
1286 match runtime {
1287 RuntimeKind::Podman => RuntimeTargetTemplate::LocalPodman(container),
1288 RuntimeKind::Docker => RuntimeTargetTemplate::LocalDocker(container),
1289 RuntimeKind::AppleContainer => RuntimeTargetTemplate::AppleContainer(container),
1290 }
1291}
1292
1293fn run_smoke_test(
1294 output: &mut impl Write,
1295 target: &RuntimeTargetTemplate,
1296 executor: &impl CommandExecutor,
1297) -> Result<()> {
1298 let smoke_id = format!(
1299 "setup-{}-{:x}",
1300 std::process::id(),
1301 SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
1302 );
1303 let description = match target {
1304 RuntimeTargetTemplate::LocalDocker(_) => {
1305 "Smoke test: verifying a disposable container and writable OverlayFS attachment..."
1306 }
1307 _ => "Smoke test: verifying a disposable container...",
1308 };
1309 writeln!(output, "{description}")?;
1310 run_setup_smoke_test(target, &smoke_id, executor)
1311}
1312
1313#[cfg(test)]
1314mod tests {
1315 use std::cell::RefCell;
1316 use std::fs;
1317
1318 use super::*;
1319 use hel::hel_targets::CommandOutput;
1320
1321 #[cfg(unix)]
1322 #[test]
1323 fn first_terminal_launch_writes_a_local_codex_config_once() {
1324 let directory = tempfile::tempdir().unwrap();
1325 let path = directory.path().join("config.toml");
1326 initialize_local_startup_config(&path).unwrap();
1327 let config = HelConfig::load_from(&path).unwrap();
1328 assert_eq!(config.profiles["codex"].kind, HarnessKind::Codex);
1329 assert!(config.profiles["codex"].home.is_absolute());
1330 assert!(matches!(
1331 config.targets["localhost"],
1332 TargetTemplate::LocalBare
1333 ));
1334 assert!(config.bundles.is_empty());
1335 let written = fs::read(&path).unwrap();
1336 initialize_local_startup_config(&path).unwrap();
1337 assert_eq!(fs::read(&path).unwrap(), written);
1338 }
1339
1340 #[cfg(unix)]
1341 #[test]
1342 fn local_startup_preserves_existing_settings_and_ignores_disabled_startup() {
1343 let directory = tempfile::tempdir().unwrap();
1344 let path = directory.path().join("config.toml");
1345 let mut config = HelConfig::default();
1346 config.phone.enabled = false;
1347 config.save_to(&path).unwrap();
1348 initialize_local_startup_config(&path).unwrap();
1349 assert!(!HelConfig::load_from(&path).unwrap().phone.enabled);
1350
1351 let configured =
1353 "version = 2\n# keep this comment\n[targets.custom]\nkind = 'local-bare'\n";
1354 fs::write(&path, configured).unwrap();
1355 initialize_local_startup_config(&path).unwrap();
1356 assert_eq!(fs::read_to_string(&path).unwrap(), configured);
1357
1358 let disabled = "version = 2\n[startup]\nenabled = false\n";
1359 fs::write(&path, disabled).unwrap();
1360 initialize_local_startup_config(&path).unwrap();
1361 let bootstrapped = HelConfig::load_from(&path).unwrap();
1362 assert!(!bootstrapped.startup.enabled);
1363 assert_eq!(bootstrapped.profiles["codex"].kind, HarnessKind::Codex);
1364 assert!(matches!(
1365 bootstrapped.targets["localhost"],
1366 TargetTemplate::LocalBare
1367 ));
1368
1369 let newer = "version = 999\nfuture_field = true\n";
1370 fs::write(&path, newer).unwrap();
1371 initialize_local_startup_config(&path).unwrap();
1372 assert_eq!(fs::read_to_string(&path).unwrap(), newer);
1373 }
1374
1375 struct FakeExecutor {
1376 commands: RefCell<Vec<CommandSpec>>,
1377 statuses: Vec<i32>,
1378 }
1379
1380 impl FakeExecutor {
1381 fn succeeds() -> Self {
1382 Self {
1383 commands: RefCell::new(vec![]),
1384 statuses: vec![0, 0, 0],
1385 }
1386 }
1387 }
1388
1389 impl CommandExecutor for FakeExecutor {
1390 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1391 let index = self.commands.borrow().len();
1392 self.commands.borrow_mut().push(command.clone());
1393 Ok(CommandOutput {
1394 status: self.statuses.get(index).copied().unwrap_or(0),
1395 stdout: b"available".to_vec(),
1396 stderr: b"failed".to_vec(),
1397 })
1398 }
1399 }
1400
1401 struct RuntimeProbeExecutor {
1402 commands: RefCell<Vec<CommandSpec>>,
1403 outputs: RefCell<Vec<CommandOutput>>,
1404 }
1405
1406 impl RuntimeProbeExecutor {
1407 fn new(outputs: impl IntoIterator<Item = CommandOutput>) -> Self {
1408 Self {
1409 commands: RefCell::new(vec![]),
1410 outputs: RefCell::new(outputs.into_iter().collect()),
1411 }
1412 }
1413 }
1414
1415 impl CommandExecutor for RuntimeProbeExecutor {
1416 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
1417 self.commands.borrow_mut().push(command.clone());
1418 if self.outputs.borrow().is_empty() {
1419 anyhow::bail!("no canned output for {}", command.program);
1420 }
1421 Ok(self.outputs.borrow_mut().remove(0))
1422 }
1423 }
1424
1425 fn ok(stdout: &[u8]) -> CommandOutput {
1426 CommandOutput {
1427 status: 0,
1428 stdout: stdout.to_vec(),
1429 stderr: vec![],
1430 }
1431 }
1432
1433 fn failed(stderr: &[u8]) -> CommandOutput {
1434 CommandOutput {
1435 status: 1,
1436 stdout: vec![],
1437 stderr: stderr.to_vec(),
1438 }
1439 }
1440
1441 const CALLER_IDENTITY: &[u8] =
1442 br#"{"UserId":"AIDA","Account":"123456789012","Arn":"arn:aws:iam::123456789012:user/dev"}"#;
1443
1444 fn discovery_without_runtimes() -> SetupDiscovery {
1445 SetupDiscovery {
1446 homes: vec![],
1447 repository: None,
1448 runtimes: vec![],
1449 aws: None,
1450 ssh_hosts: vec![],
1451 }
1452 }
1453
1454 #[test]
1455 fn discovers_default_and_overridden_homes_with_authentication_markers() {
1456 let directory = tempfile::tempdir().unwrap();
1457 let home = directory.path().join("home");
1458 let codex = home.join(".codex");
1459 let kimi = home.join(".kimi-code");
1460 let grok = home.join(".grok");
1461 let deepseek = home.join(".dsh");
1462 let claude = directory.path().join("claude-override");
1463 fs::create_dir_all(&codex).unwrap();
1464 fs::create_dir_all(kimi.join("credentials")).unwrap();
1465 fs::create_dir_all(&grok).unwrap();
1466 fs::create_dir_all(&deepseek).unwrap();
1467 fs::create_dir_all(&claude).unwrap();
1468 fs::write(codex.join("auth.json"), "{}").unwrap();
1469 fs::write(kimi.join("credentials/kimi-code.json"), "{}").unwrap();
1470 fs::write(grok.join("auth.json"), "{}").unwrap();
1471 fs::write(deepseek.join(".credentials.yaml"), "version: 1\n").unwrap();
1472 fs::write(claude.join(".credentials.json"), "{}").unwrap();
1473
1474 let executor = FakeExecutor::succeeds();
1475 let homes = discover_harness_homes_with_executor(
1476 Some(&home),
1477 [(HarnessKind::Claude, claude.clone())],
1478 &executor,
1479 );
1480
1481 assert_eq!(homes.len(), 5);
1482 assert!(homes.iter().all(|home| home.authenticated));
1483 assert!(homes.iter().any(|home| home.path == codex));
1484 assert!(homes.iter().any(|home| home.path == claude));
1485 assert!(homes.iter().any(|home| home.path == kimi));
1486 assert!(
1487 homes
1488 .iter()
1489 .any(|home| { home.path == deepseek && home.kind == HarnessKind::Deepseek })
1490 );
1491 assert!(
1492 homes
1493 .iter()
1494 .any(|home| home.path == grok && home.kind == HarnessKind::Grok)
1495 );
1496 }
1497
1498 #[test]
1499 fn every_harness_has_a_discoverable_default_home() {
1500 let directory = tempfile::tempdir().unwrap();
1501 let home = directory.path().to_path_buf();
1502 for kind in HarnessKind::ALL {
1503 fs::create_dir_all(home.join(kind.default_home_leaf())).unwrap();
1504 }
1505
1506 let executor = FakeExecutor::succeeds();
1507 let homes = discover_harness_homes_with_executor(Some(&home), [], &executor);
1508
1509 assert_eq!(homes.len(), HarnessKind::ALL.len());
1510 for kind in HarnessKind::ALL {
1511 assert!(
1512 homes
1513 .iter()
1514 .any(|home| home.kind == kind && !home.authenticated),
1515 "{kind:?} default home"
1516 );
1517 }
1518 }
1519
1520 #[cfg(target_os = "macos")]
1521 #[test]
1522 fn claude_keychain_marks_the_default_home_authenticated_without_a_marker() {
1523 let directory = tempfile::tempdir().unwrap();
1524 let home = directory.path().join("home");
1525 let claude = home.join(".claude");
1526 fs::create_dir_all(&claude).unwrap();
1527 let executor = RuntimeProbeExecutor::new([ok(
1528 br#"{"claudeAiOauth":{"accessToken":"access","refreshToken":"refresh"}}"#,
1529 )]);
1530
1531 let homes = discover_harness_homes_with_executor(Some(&home), [], &executor);
1532
1533 assert_eq!(
1534 homes,
1535 vec![DiscoveredHome {
1536 kind: HarnessKind::Claude,
1537 path: claude.clone(),
1538 authenticated: true,
1539 }]
1540 );
1541 let commands = executor.commands.borrow();
1542 assert_eq!(commands.len(), 1);
1543 assert_eq!(commands[0].program, "security");
1544 assert_eq!(
1545 commands[0].args,
1546 [
1547 "find-generic-password",
1548 "-s",
1549 "Claude Code-credentials",
1550 "-w"
1551 ]
1552 );
1553 assert!(commands[0].env.is_empty());
1554 }
1555
1556 #[test]
1557 fn claude_status_checks_a_custom_home_without_a_marker() {
1558 let directory = tempfile::tempdir().unwrap();
1559 let claude = directory.path().join("claude-custom");
1560 fs::create_dir_all(&claude).unwrap();
1561 let executor =
1562 RuntimeProbeExecutor::new([ok(br#"{"loggedIn":true,"authMethod":"claude.ai"}"#)]);
1563
1564 let homes = discover_harness_homes_with_executor(
1565 None,
1566 [(HarnessKind::Claude, claude.clone())],
1567 &executor,
1568 );
1569
1570 assert_eq!(
1571 homes,
1572 vec![DiscoveredHome {
1573 kind: HarnessKind::Claude,
1574 path: claude.clone(),
1575 authenticated: true,
1576 }]
1577 );
1578 let commands = executor.commands.borrow();
1579 assert_eq!(commands.len(), 1);
1580 assert_eq!(commands[0].program, "claude");
1581 assert_eq!(commands[0].args, ["auth", "status", "--json"]);
1582 assert_eq!(
1583 commands[0].env.get("CLAUDE_CONFIG_DIR"),
1584 Some(&claude.to_string_lossy().into_owned())
1585 );
1586 }
1587
1588 #[test]
1589 fn claude_credential_evidence_requires_a_nonempty_login_secret() {
1590 assert!(claude_credentials_contain_login(
1591 br#"{"claudeAiOauth":{"refreshToken":"refresh"}}"#
1592 ));
1593 assert!(!claude_credentials_contain_login(
1594 br#"{"claudeAiOauth":{"refreshToken":" "}}"#
1595 ));
1596 assert!(!claude_credentials_contain_login(b"not json"));
1597 }
1598
1599 #[test]
1600 fn github_origin_parser_accepts_standard_https_and_ssh_forms() {
1601 for origin in [
1602 "https://github.com/BrokkAi/hel.git",
1603 "git@github.com:BrokkAi/hel.git",
1604 "ssh://git@github.com/BrokkAi/hel.git",
1605 ] {
1606 assert_eq!(
1607 github_repository_from_origin(origin),
1608 Some(GithubRepository {
1609 owner: "BrokkAi".into(),
1610 repository: "hel".into(),
1611 })
1612 );
1613 }
1614 assert_eq!(
1615 github_repository_from_origin("https://example.com/hel"),
1616 None
1617 );
1618 }
1619
1620 #[test]
1621 fn config_contains_discovered_profiles_current_repository_and_selected_target() {
1622 let homes = vec![
1623 DiscoveredHome {
1624 kind: HarnessKind::Codex,
1625 path: PathBuf::from("/profiles/codex"),
1626 authenticated: true,
1627 },
1628 DiscoveredHome {
1629 kind: HarnessKind::Codex,
1630 path: PathBuf::from("/profiles/codex-two"),
1631 authenticated: false,
1632 },
1633 ];
1634 let repository = GithubRepository {
1635 owner: "BrokkAi".into(),
1636 repository: "hel".into(),
1637 };
1638
1639 let config = build_config(
1640 &homes,
1641 Some(&repository),
1642 RuntimeKind::Podman,
1643 "ubuntu:24.04",
1644 );
1645
1646 config.validate().unwrap();
1647 assert!(config.profiles.contains_key("codex"));
1648 assert!(config.profiles.contains_key("codex-2"));
1649 assert_eq!(
1650 config.bundles["current-repository"].repositories[0]
1651 .github
1652 .as_deref(),
1653 Some("BrokkAi/hel")
1654 );
1655 assert!(matches!(
1656 config.targets["podman"],
1657 TargetTemplate::LocalPodman { .. }
1658 ));
1659 assert!(matches!(
1660 config.targets["localhost"],
1661 TargetTemplate::LocalBare
1662 ));
1663
1664 let docker = build_config(
1665 &homes,
1666 Some(&repository),
1667 RuntimeKind::Docker,
1668 "ubuntu:24.04",
1669 );
1670 assert!(matches!(
1671 docker.targets["docker"],
1672 TargetTemplate::LocalDocker { .. }
1673 ));
1674 }
1675
1676 #[test]
1677 fn runtime_probe_requires_podman_rootless_preflight_and_checks_apple_on_macos() {
1678 let executor = RuntimeProbeExecutor::new([
1679 ok(b"podman version 5.4.2\n"),
1680 ok(b"true\n"),
1681 ok(b"0 1000 1\n1 100000 65536\n"),
1682 ok(b"29.0.1 linux\n"),
1683 ok(b"container version 1\n"),
1684 ok(b"running\n"),
1685 ]);
1686 let runtimes = probe_local_runtimes(&executor, true);
1687
1688 assert_eq!(runtimes.len(), 3);
1689 assert_eq!(executor.commands.borrow()[0].program, "podman");
1690 assert_eq!(executor.commands.borrow()[0].args, ["--version"]);
1691 assert_eq!(
1692 executor.commands.borrow()[1].args,
1693 ["info", "--format", "{{.Host.Security.Rootless}}"]
1694 );
1695 assert_eq!(
1696 executor.commands.borrow()[2].args,
1697 ["unshare", "cat", "/proc/self/uid_map"]
1698 );
1699 assert_eq!(executor.commands.borrow()[3].program, "docker");
1700 assert_eq!(executor.commands.borrow()[4].program, "container");
1701 assert!(runtimes.iter().all(|runtime| runtime.usable));
1702 }
1703
1704 #[test]
1705 fn unusable_podman_carries_the_doctor_remediation_into_the_runtime_list() {
1706 let executor = RuntimeProbeExecutor::new([
1707 ok(b"podman version 3.4.7\n"),
1708 failed(b"docker is unavailable"),
1709 ]);
1710
1711 let runtimes = probe_local_runtimes(&executor, false);
1712
1713 assert_eq!(runtimes.len(), 2);
1714 assert!(!runtimes[0].usable);
1715 let remediation = runtimes[0].remediation.as_deref().unwrap();
1716 assert!(remediation.contains("Upgrade Podman"), "{remediation}");
1717
1718 let mut output = Vec::new();
1719 write_runtimes(&mut output, &runtimes).unwrap();
1720 let output = String::from_utf8(output).unwrap();
1721 assert!(output.contains("Podman: unavailable"), "{output}");
1722 assert!(output.contains("Docker: unavailable"), "{output}");
1723 assert!(output.contains("remediation: Upgrade Podman"), "{output}");
1724 }
1725
1726 #[test]
1727 fn aws_is_detected_only_when_the_caller_identity_call_succeeds() {
1728 let missing = RuntimeProbeExecutor::new([]);
1729 assert_eq!(detect_aws(&missing), None);
1730
1731 let denied = RuntimeProbeExecutor::new([failed(b"ExpiredToken")]);
1732 assert_eq!(detect_aws(&denied), None);
1733
1734 let working = RuntimeProbeExecutor::new([ok(CALLER_IDENTITY), ok(b"us-east-1\n")]);
1735 assert_eq!(
1736 detect_aws(&working),
1737 Some(AwsAccount {
1738 account: "123456789012".into(),
1739 arn: "arn:aws:iam::123456789012:user/dev".into(),
1740 region: Some("us-east-1".into()),
1741 })
1742 );
1743 assert_eq!(working.commands.borrow()[0].args[0], "sts");
1744 assert_eq!(
1745 working.commands.borrow()[1].args,
1746 ["configure", "get", "region"]
1747 );
1748 }
1749
1750 #[test]
1751 fn aws_detection_without_a_configured_region_leaves_the_region_unset() {
1752 let executor = RuntimeProbeExecutor::new([ok(CALLER_IDENTITY), failed(b"")]);
1753
1754 assert_eq!(detect_aws(&executor).unwrap().region, None);
1755 }
1756
1757 #[test]
1758 fn the_aws_step_asks_nothing_when_no_aws_credentials_were_detected() {
1759 let mut input = b"".as_slice();
1760 let mut output = Vec::new();
1761
1762 let aws = prompt_aws_target(&mut input, &mut output, None).unwrap();
1763
1764 assert_eq!(aws, None);
1765 let output = String::from_utf8(output).unwrap();
1766 assert!(output.contains("skipping the AWS target"), "{output}");
1767 assert!(!output.contains("[y/N]"), "{output}");
1768 }
1769
1770 #[test]
1771 fn the_aws_step_defaults_region_and_ssh_user_when_the_answers_are_blank() {
1772 let account = AwsAccount {
1773 account: "123456789012".into(),
1774 arn: "arn:aws:iam::123456789012:user/dev".into(),
1775 region: Some("us-east-1".into()),
1776 };
1777 let mut input = b"y\nhel-runson\n\n\n\n".as_slice();
1778 let mut output = Vec::new();
1779
1780 let aws = prompt_aws_target(&mut input, &mut output, Some(&account))
1781 .unwrap()
1782 .unwrap();
1783
1784 assert_eq!(
1785 aws,
1786 AwsTargetInput {
1787 launch_template: "hel-runson".into(),
1788 region: "us-east-1".into(),
1789 ssh_user: DEFAULT_AWS_SSH_USER.into(),
1790 identity_file: None,
1791 }
1792 );
1793 let config = build_config_with_runtime(&[], None, None, Some(&aws), None);
1794 let TargetTemplate::AwsEc2 {
1795 region,
1796 launch_template,
1797 ssh_user,
1798 ..
1799 } = &config.targets[AWS_TARGET_ID]
1800 else {
1801 panic!("setup must write an aws-ec2 target");
1802 };
1803 assert_eq!(region, "us-east-1");
1804 assert_eq!(launch_template, "hel-runson");
1805 assert_eq!(ssh_user, DEFAULT_AWS_SSH_USER);
1806 config.validate().unwrap();
1807 }
1808
1809 const SSH_CONFIG_FIXTURE: &str = r#"
1810# Personal hosts
1811Host *
1812 ServerAliveInterval 60
1813
1814Host builder build.example.com
1815 HostName build.example.com
1816 User dev
1817
1818Host bastion
1819 HostName 10.0.0.1
1820 IdentityFile ~/.ssh/id_ed25519
1821
1822Host prod-*
1823 User deploy
1824
1825Host !staging *.internal
1826 User deploy
1827
1828Host builder
1829 Compression yes
1830"#;
1831
1832 #[test]
1833 fn ssh_config_parsing_keeps_concrete_aliases_and_drops_pattern_blocks() {
1834 let aliases = ssh_config_aliases(SSH_CONFIG_FIXTURE);
1835
1836 assert_eq!(
1837 aliases,
1838 vec!["builder", "build.example.com", "bastion"],
1839 "wildcard, negated, and duplicate entries must not appear"
1840 );
1841 }
1842
1843 #[test]
1844 fn ssh_config_parsing_returns_nothing_for_a_config_of_only_wildcards() {
1845 assert!(
1846 ssh_config_aliases(
1847 "Host *
1848 User dev
1849"
1850 )
1851 .is_empty()
1852 );
1853 assert!(ssh_config_aliases("").is_empty());
1854 }
1855
1856 #[test]
1857 fn the_ssh_step_asks_nothing_when_the_ssh_config_has_no_aliases() {
1858 let mut input = b"".as_slice();
1859 let mut output = Vec::new();
1860
1861 assert_eq!(
1862 prompt_ssh_target(&mut input, &mut output, &[], &BTreeMap::new()).unwrap(),
1863 None
1864 );
1865 let output = String::from_utf8(output).unwrap();
1866 assert!(output.contains("skipping the SSH target"), "{output}");
1867 assert!(!output.contains("[y/N]"), "{output}");
1868 }
1869
1870 #[test]
1871 fn declining_the_ssh_step_writes_no_ssh_target() {
1872 let aliases = vec!["builder".to_owned()];
1873 let mut input = b"\n".as_slice();
1874 let mut output = Vec::new();
1875
1876 assert_eq!(
1877 prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new()).unwrap(),
1878 None
1879 );
1880 let config = build_config_with_runtime(&[], None, None, None, None);
1881 assert!(!config.targets.values().any(|target| matches!(
1882 target,
1883 TargetTemplate::SshBare { .. }
1884 | TargetTemplate::SshPodman { .. }
1885 | TargetTemplate::SshDocker { .. }
1886 )));
1887 }
1888
1889 #[test]
1890 fn accepting_the_ssh_step_can_write_a_docker_target() {
1891 let aliases = vec!["builder".to_owned()];
1892 let mut input = b"y\n1\ndocker\n\n\n".as_slice();
1894 let mut output = Vec::new();
1895
1896 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
1897 .unwrap()
1898 .unwrap();
1899 assert_eq!(
1900 ssh.kind,
1901 SshTargetKind::Docker {
1902 image: DEFAULT_IMAGE.into()
1903 }
1904 );
1905 let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
1906 let TargetTemplate::SshDocker { ssh, container } = &config.targets["builder"] else {
1907 panic!("setup must write an ssh-docker target");
1908 };
1909 assert_eq!(ssh.host, "builder");
1910 assert_eq!(container.image, DEFAULT_IMAGE);
1911 config.validate().unwrap();
1912 }
1913
1914 #[test]
1915 fn the_ssh_step_rejects_an_unknown_runtime_before_asking_for_an_image() {
1916 let aliases = vec!["builder".to_owned()];
1917 let mut input = b"y\n1\ncontainerd\ndocker\n\n\n".as_slice();
1918 let mut output = Vec::new();
1919
1920 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
1921 .unwrap()
1922 .unwrap();
1923
1924 assert!(matches!(ssh.kind, SshTargetKind::Docker { .. }));
1925 let output = String::from_utf8(output).unwrap();
1926 assert!(output.contains("Runtime must be"), "{output}");
1927 assert_eq!(output.matches("Container image").count(), 1);
1928 }
1929
1930 #[test]
1931 fn accepting_the_ssh_step_writes_an_ssh_podman_target_with_the_default_image() {
1932 let aliases = vec!["builder".to_owned(), "bastion".to_owned()];
1933 let mut input = b"y\n1\n\n\n\n".as_slice();
1935 let mut output = Vec::new();
1936
1937 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
1938 .unwrap()
1939 .unwrap();
1940
1941 assert_eq!(
1942 ssh,
1943 SshTargetInput {
1944 name: "builder".into(),
1945 host: "builder".into(),
1946 kind: SshTargetKind::Podman {
1947 image: DEFAULT_IMAGE.into()
1948 },
1949 }
1950 );
1951 let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
1952 let TargetTemplate::SshPodman { ssh, container, .. } = &config.targets["builder"] else {
1953 panic!("setup must write an ssh-podman target");
1954 };
1955 assert_eq!(ssh.host, "builder");
1956 assert_eq!(ssh.user, None);
1957 assert_eq!(ssh.identity_file, None);
1958 assert_eq!(container.image, DEFAULT_IMAGE);
1959 config.validate().unwrap();
1960 }
1961
1962 #[test]
1963 fn accepting_the_ssh_step_writes_an_ssh_bare_target_under_a_chosen_name() {
1964 let aliases = vec!["builder".to_owned()];
1965 let mut input = b"y\nother.example.com\nn\n\nremote\n".as_slice();
1967 let mut output = Vec::new();
1968
1969 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &BTreeMap::new())
1970 .unwrap()
1971 .unwrap();
1972
1973 assert_eq!(
1974 ssh,
1975 SshTargetInput {
1976 name: "remote".into(),
1977 host: "other.example.com".into(),
1978 kind: SshTargetKind::Bare {
1979 permissions: PermissionMode::Guardian,
1980 },
1981 }
1982 );
1983 let config = build_config_with_runtime(&[], None, None, None, Some(&ssh));
1984 let TargetTemplate::SshBare {
1985 ssh, permissions, ..
1986 } = &config.targets["remote"]
1987 else {
1988 panic!("setup must write an ssh-bare target");
1989 };
1990 assert_eq!(ssh.host, "other.example.com");
1991 assert_eq!(*permissions, PermissionMode::Guardian);
1992 config.validate().unwrap();
1993 }
1994
1995 #[test]
1996 fn the_ssh_step_reasks_until_the_name_is_a_free_and_valid_target_id() {
1997 let aliases = vec!["builder".to_owned()];
1998 let configured = build_config_with_runtime(
1999 &[],
2000 None,
2001 Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2002 None,
2003 None,
2004 )
2005 .targets;
2006 let mut input = b"y\n1\nn\n\npodman\nbuild host\nbuilder\n".as_slice();
2009 let mut output = Vec::new();
2010
2011 let ssh = prompt_ssh_target(&mut input, &mut output, &aliases, &configured)
2012 .unwrap()
2013 .unwrap();
2014
2015 assert_eq!(ssh.name, "builder");
2016 let output = String::from_utf8(output).unwrap();
2017 assert!(
2018 output.contains("Target podman is already configured"),
2019 "{output}"
2020 );
2021 assert!(output.contains("invalid target id"), "{output}");
2022 }
2023
2024 #[test]
2025 fn the_ssh_step_stops_asking_for_a_name_once_the_input_ends() {
2026 let aliases = vec!["podman".to_owned()];
2027 let configured = build_config_with_runtime(
2028 &[],
2029 None,
2030 Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2031 None,
2032 None,
2033 )
2034 .targets;
2035 let mut input = b"y\n1\nn\n\n".as_slice();
2038 let mut output = Vec::new();
2039
2040 assert_eq!(
2041 prompt_ssh_target(&mut input, &mut output, &aliases, &configured).unwrap(),
2042 None
2043 );
2044 let output = String::from_utf8(output).unwrap();
2045 assert!(output.contains("Input ended; skipping"), "{output}");
2046 }
2047
2048 #[test]
2049 fn an_ssh_target_never_replaces_a_target_configured_earlier() {
2050 let ssh = SshTargetInput {
2051 name: "podman".into(),
2052 host: "builder".into(),
2053 kind: SshTargetKind::Bare {
2054 permissions: PermissionMode::Guardian,
2055 },
2056 };
2057
2058 let config = build_config_with_runtime(
2059 &[],
2060 None,
2061 Some((RuntimeKind::Podman, DEFAULT_IMAGE)),
2062 None,
2063 Some(&ssh),
2064 );
2065
2066 assert!(matches!(
2067 config.targets["podman"],
2068 TargetTemplate::LocalPodman { .. }
2069 ));
2070 assert!(matches!(
2071 config.targets["podman-2"],
2072 TargetTemplate::SshBare { .. }
2073 ));
2074 config.validate().unwrap();
2075 }
2076
2077 #[test]
2078 fn the_github_origin_is_discovered_through_the_shared_executor() {
2079 let executor = RuntimeProbeExecutor::new([ok(b"git@github.com:BrokkAi/hel.git\n")]);
2080
2081 let repository = discover_github_repository(&executor, Path::new("/work/hel")).unwrap();
2082
2083 assert_eq!(repository.source(), "BrokkAi/hel");
2084 let commands = executor.commands.borrow();
2085 assert_eq!(commands[0].program, "git");
2086 assert_eq!(
2087 commands[0].args,
2088 ["-C", "/work/hel", "remote", "get-url", "origin"]
2089 );
2090 }
2091
2092 #[test]
2093 fn no_github_origin_is_reported_when_the_probe_fails() {
2094 let failing = RuntimeProbeExecutor::new([failed(b"not a git repository")]);
2095 assert_eq!(
2096 discover_github_repository(&failing, Path::new("/work/plain")),
2097 None
2098 );
2099
2100 let missing = RuntimeProbeExecutor::new([]);
2101 assert_eq!(
2102 discover_github_repository(&missing, Path::new("/work/plain")),
2103 None
2104 );
2105 }
2106
2107 #[test]
2108 fn declining_the_aws_step_writes_no_aws_target() {
2109 let account = AwsAccount {
2110 account: "123456789012".into(),
2111 arn: "arn:aws:iam::123456789012:user/dev".into(),
2112 region: None,
2113 };
2114 let mut input = b"\n".as_slice();
2115 let mut output = Vec::new();
2116
2117 assert_eq!(
2118 prompt_aws_target(&mut input, &mut output, Some(&account)).unwrap(),
2119 None
2120 );
2121 let config = build_config_with_runtime(&[], None, None, None, None);
2122 assert!(!config.targets.contains_key(AWS_TARGET_ID));
2123 }
2124
2125 #[test]
2126 fn smoke_test_removes_the_container_after_a_failed_command() {
2127 let executor = FakeExecutor {
2128 commands: RefCell::new(vec![]),
2129 statuses: vec![0, 1, 0],
2130 };
2131 let mut output = Vec::new();
2132
2133 assert!(
2134 run_smoke_test(
2135 &mut output,
2136 &smoke_target(RuntimeKind::Podman, "ubuntu:24.04"),
2137 &executor
2138 )
2139 .is_err()
2140 );
2141 let commands = executor.commands.borrow();
2142 assert_eq!(commands.len(), 3);
2143 assert_eq!(commands[2].args[0], "rm");
2144 }
2145
2146 #[test]
2147 fn docker_smoke_test_exercises_the_managed_overlay_attachment_path() {
2148 let executor = FakeExecutor::succeeds();
2149 let mut output = Vec::new();
2150
2151 run_smoke_test(
2152 &mut output,
2153 &smoke_target(RuntimeKind::Docker, "ubuntu:24.04"),
2154 &executor,
2155 )
2156 .unwrap();
2157
2158 let commands = executor.commands.borrow();
2159 assert_eq!(commands.len(), 3);
2160 assert_eq!(commands[0].program, "sh");
2161 assert!(commands[0].args[1].contains("docker volume create"));
2162 assert!(commands[0].args[1].contains("type=overlay"));
2163 assert_eq!(commands[1].program, "docker");
2164 assert_eq!(commands[1].args[0], "exec");
2165 assert_eq!(commands[2].program, "sh");
2166 assert!(commands[2].args[1].contains("docker volume rm --force"));
2167 assert!(
2168 String::from_utf8(output)
2169 .unwrap()
2170 .contains("writable OverlayFS attachment")
2171 );
2172 }
2173
2174 #[test]
2175 fn dialog_configures_every_usable_runtime_as_a_normal_target() {
2176 let directory = tempfile::tempdir().unwrap();
2177 let config_path = directory.path().join("config.toml");
2178 let discovery = SetupDiscovery {
2179 homes: vec![DiscoveredHome {
2180 kind: HarnessKind::Codex,
2181 path: PathBuf::from("/profiles/codex"),
2182 authenticated: true,
2183 }],
2184 repository: Some(GithubRepository {
2185 owner: "BrokkAi".into(),
2186 repository: "hel".into(),
2187 }),
2188 runtimes: vec![
2189 RuntimeProbe {
2190 kind: RuntimeKind::Podman,
2191 usable: true,
2192 detail: "podman version 5".into(),
2193 remediation: None,
2194 },
2195 RuntimeProbe {
2196 kind: RuntimeKind::Docker,
2197 usable: true,
2198 detail: "docker version 29".into(),
2199 remediation: None,
2200 },
2201 ],
2202 aws: None,
2203 ssh_hosts: vec![],
2204 };
2205 let executor = FakeExecutor::succeeds();
2206 let mut input = b"\ny\n".as_slice();
2207 let mut output = Vec::new();
2208
2209 assert_eq!(
2210 run_setup_dialog_with(
2211 &mut input,
2212 &mut output,
2213 &config_path,
2214 &discovery,
2215 &executor,
2216 &executor,
2217 )
2218 .unwrap(),
2219 SetupOutcome::Written
2220 );
2221 assert!(config_path.exists());
2222 let config = HelConfig::load_from(&config_path).unwrap();
2223 assert!(matches!(
2224 config.targets["podman"],
2225 TargetTemplate::LocalPodman { .. }
2226 ));
2227 assert!(matches!(
2228 config.targets["docker"],
2229 TargetTemplate::LocalDocker { .. }
2230 ));
2231 let smoke = executor.commands.borrow()[..3]
2232 .iter()
2233 .map(|command| command.args[0].clone())
2234 .collect::<Vec<_>>();
2235 assert_eq!(smoke, ["run", "exec", "rm"]);
2236 let commands = executor.commands.borrow();
2237 assert!(commands.len() >= 6);
2238 assert_eq!(commands[3].program, "sh");
2239 assert_eq!(commands[4].program, "docker");
2240 assert_eq!(commands[5].program, "sh");
2241 drop(commands);
2242 let output = String::from_utf8(output).unwrap();
2243 assert!(output.contains("Podman target using"), "{output}");
2244 assert!(output.contains("Docker target using"), "{output}");
2245 assert!(!output.contains("Recommended runtime"), "{output}");
2246 assert!(!output.contains("Runtime ("), "{output}");
2247 assert!(output.ends_with("Press n to start your first session.\n"));
2248 }
2249
2250 #[test]
2251 fn a_failed_smoke_test_becomes_a_fixable_line_in_the_closing_report() {
2252 let directory = tempfile::tempdir().unwrap();
2253 let config_path = directory.path().join("config.toml");
2254 let discovery = SetupDiscovery {
2255 runtimes: vec![RuntimeProbe {
2256 kind: RuntimeKind::Podman,
2257 usable: true,
2258 detail: "podman version 5".into(),
2259 remediation: None,
2260 }],
2261 ..discovery_without_runtimes()
2262 };
2263 let executor = FakeExecutor {
2265 commands: RefCell::new(vec![]),
2266 statuses: vec![0, 1, 0],
2267 };
2268 let mut input = b"\ny\n".as_slice();
2269 let mut output = Vec::new();
2270
2271 let outcome = run_setup_dialog_with(
2272 &mut input,
2273 &mut output,
2274 &config_path,
2275 &discovery,
2276 &executor,
2277 &executor,
2278 )
2279 .unwrap();
2280
2281 assert_eq!(outcome, SetupOutcome::Written);
2282 assert!(config_path.exists());
2283 let output = String::from_utf8(output).unwrap();
2284 assert!(output.contains("fixable Podman smoke test"), "{output}");
2285 assert!(
2286 output.contains("remediation: Fix the configured image or the Podman runtime"),
2287 "{output}"
2288 );
2289 assert!(
2292 output.contains("Running `mj doctor` checks on the new config..."),
2293 "{output}"
2294 );
2295 assert!(
2296 output.contains("Apply the remediations above, then rerun `mj doctor`."),
2297 "{output}"
2298 );
2299 assert!(
2300 output.ends_with("Press n to start your first session.\n"),
2301 "{output}"
2302 );
2303 }
2304
2305 #[test]
2306 fn setup_finishes_with_the_standard_doctor_report_for_the_config_it_wrote() {
2307 let directory = tempfile::tempdir().unwrap();
2308 let config_path = directory.path().join("config.toml");
2309 let discovery = SetupDiscovery {
2310 homes: vec![DiscoveredHome {
2311 kind: HarnessKind::Codex,
2312 path: directory.path().join("missing-codex-home"),
2313 authenticated: false,
2314 }],
2315 ..discovery_without_runtimes()
2316 };
2317 let executor = FakeExecutor::succeeds();
2318 let mut input = b"y\n".as_slice();
2319 let mut output = Vec::new();
2320
2321 run_setup_dialog_with(
2322 &mut input,
2323 &mut output,
2324 &config_path,
2325 &discovery,
2326 &executor,
2327 &executor,
2328 )
2329 .unwrap();
2330
2331 let output = String::from_utf8(output).unwrap();
2332 assert!(
2335 output.contains(&format!(
2336 "ready Mjolnir configuration: {} is valid",
2337 config_path.display()
2338 )),
2339 "{output}"
2340 );
2341 assert!(output.contains("fixable Harness profile codex"), "{output}");
2342 assert!(
2343 output.contains(" remediation: Create or select the Codex home"),
2344 "{output}"
2345 );
2346 assert!(
2347 output.contains("Apply the remediations above, then rerun `mj doctor`."),
2348 "{output}"
2349 );
2350 }
2351
2352 #[test]
2353 fn dialog_configures_raw_localhost_without_a_container_runtime() {
2354 let directory = tempfile::tempdir().unwrap();
2355 let config_path = directory.path().join("config.toml");
2356 let discovery = SetupDiscovery {
2357 homes: vec![DiscoveredHome {
2358 kind: HarnessKind::Kimi,
2359 path: PathBuf::from("/profiles/kimi"),
2360 authenticated: true,
2361 }],
2362 repository: None,
2363 runtimes: vec![RuntimeProbe {
2364 kind: RuntimeKind::Podman,
2365 usable: false,
2366 detail: "not installed".into(),
2367 remediation: Some("Install Podman.".into()),
2368 }],
2369 aws: None,
2370 ssh_hosts: vec![],
2371 };
2372 let executor = FakeExecutor::succeeds();
2373 let mut input = b"y\n".as_slice();
2374 let mut output = Vec::new();
2375
2376 assert_eq!(
2377 run_setup_dialog_with(
2378 &mut input,
2379 &mut output,
2380 &config_path,
2381 &discovery,
2382 &executor,
2383 &executor,
2384 )
2385 .unwrap(),
2386 SetupOutcome::Written
2387 );
2388 let config = HelConfig::load_from(&config_path).unwrap();
2389 assert!(matches!(
2390 config.targets["localhost"],
2391 TargetTemplate::LocalBare
2392 ));
2393 assert!(
2396 executor
2397 .commands
2398 .borrow()
2399 .iter()
2400 .all(|command| command.program != "podman" || command.args[0] != "run")
2401 );
2402 let output = String::from_utf8(output).unwrap();
2403 assert!(output.contains("DANGER"));
2404 assert!(output.contains("has no guardian approval mode"));
2405 assert!(output.contains("raw localhost will still be configured"));
2406 }
2407
2408 #[test]
2409 fn discovered_homes_warn_for_harnesses_without_guardian_approvals() {
2410 let warning = |kind: HarnessKind| {
2411 let mut output = Vec::new();
2412 write_discovered_homes(
2413 &mut output,
2414 &[DiscoveredHome {
2415 kind,
2416 path: PathBuf::from("/profiles/harness"),
2417 authenticated: true,
2418 }],
2419 )
2420 .unwrap();
2421 String::from_utf8(output).unwrap()
2422 };
2423
2424 for kind in [HarnessKind::Kimi, HarnessKind::Deepseek] {
2425 let output = warning(kind);
2426 assert!(output.contains("DANGER"), "{kind:?}: {output}");
2427 assert!(
2428 output.contains("has no guardian approval mode"),
2429 "{kind:?}: {output}"
2430 );
2431 assert!(output.contains("raw, unsandboxed target"), "{output}");
2432 }
2433
2434 for kind in [HarnessKind::Codex, HarnessKind::Claude, HarnessKind::Grok] {
2435 assert!(!warning(kind).contains("DANGER"), "{kind:?}");
2436 }
2437 }
2438}