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