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