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