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