Skip to main content

mj_controller/
hel_setup.rs

1//! Plain-stdio first-run configuration for Hel.
2
3use 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
26/// AWS credential detection must never stall an interactive first run, so the
27/// probe commands share a bounded deadline.
28const AWS_PROBE_TIMEOUT: Duration = Duration::from_secs(8);
29
30/// The user every Hel launch template image boots with; see
31/// scripts/update-runson-launch-template.sh.
32const DEFAULT_AWS_SSH_USER: &str = "ubuntu";
33const AWS_TARGET_ID: &str = "aws";
34
35// Published from containers/Containerfile.agent-dev by
36// .github/workflows/publish-agent-dev-image.yml. It already carries Node, Rust,
37// Git, gh, and the pinned ACP bridges, so a first session does not have to
38// install them.
39const 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    /// The fix `mj doctor` would print for this runtime, carried through so
91    /// setup never invents its own remediation wording.
92    pub remediation: Option<String>,
93}
94
95/// An AWS identity that `aws sts get-caller-identity` confirmed.
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct AwsAccount {
98    pub account: String,
99    pub arn: String,
100    /// The CLI's configured default region, when it has one.
101    pub region: Option<String>,
102}
103
104/// The answers that become a `[targets.aws]` entry.
105#[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/// Which kind of SSH target the user chose in the SSH step.
114#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum SshTargetKind {
116    Bare { permissions: PermissionMode },
117    Podman { image: String },
118    Docker { image: String },
119}
120
121/// The answers that become a `[targets.<name>]` SSH entry.
122#[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    /// `None` when this host has no working AWS CLI credentials, in which case
135    /// setup never offers an AWS target.
136    pub aws: Option<AwsAccount>,
137    /// Concrete `Host` aliases read from `~/.ssh/config`; empty when the file
138    /// is absent or only defines wildcard blocks.
139    pub ssh_hosts: Vec<String>,
140}
141
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143pub enum SetupOutcome {
144    Written,
145    Cancelled,
146}
147
148/// Run the setup dialog using the user's normal standard input and output.
149pub fn run_setup_dialog(config_path: &Path) -> Result<SetupOutcome> {
150    // Prerequisite probes run under doctor's per-probe deadline, so a wedged
151    // container socket cannot stall the first run; only the smoke test, which
152    // may pull an image, is allowed to take as long as it needs.
153    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
184/// Read the concrete `Host` aliases from `~/.ssh/config`.
185///
186/// This is a pure read: setup never runs `ssh` while discovering. `Include`
187/// directives are deliberately not followed, because resolving them correctly
188/// means reimplementing OpenSSH's glob and relative-path rules; aliases that
189/// live in an included file simply are not offered, and the user can still
190/// type a host by hand.
191pub 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
201/// Extract the usable `Host` aliases from SSH config text.
202///
203/// Pattern entries (`*`, `?`, `!`) are skipped: they configure other hosts
204/// rather than naming one Hel could connect to.
205pub 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
306/// Ask Claude Code about a scoped profile. Setting `CLAUDE_CONFIG_DIR` for the
307/// default home changes Claude's profile selection, so the default macOS
308/// profile is checked directly in the Keychain instead.
309fn 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/// Mjolnir and Claude Code use this service for the default macOS profile.
329/// `security` is already authorized for the item, so this does not raise a
330/// Keychain prompt; the shared executor still bounds a wedged lookup.
331#[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        // Config accepts owner/repository shorthand, and import uses the same
382        // parser to compare that configured source with `git remote` output.
383        .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
402/// Read the current directory's GitHub origin, through the same executor every
403/// other discovery probe uses so it is bounded and can be faked in tests.
404///
405/// `git -C` selects the directory instead of a working-directory field on the
406/// command, which no executor carries.
407fn 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
429/// Probe the container runtimes setup can configure, reusing the doctor checks
430/// so an unavailable runtime carries doctor's detail and remediation.
431pub 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
457/// Detect a usable AWS CLI identity on this host.
458///
459/// Returns `None` whenever the CLI is missing or its credentials do not work,
460/// so setup can skip the AWS step instead of prompting for a target that could
461/// never launch.
462pub 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        // Leave user and identity file unset: the SSH config alias already
594        // carries whatever the user configured for this host.
595        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        // The dialog already refuses a name that collides, so this only guards
633        // a caller that builds a config without asking: a chosen SSH name must
634        // never silently replace a target configured moments earlier.
635        config
636            .targets
637            .insert(unique_id(&config.targets, &ssh.name), target);
638    }
639    config
640}
641
642/// The same default `serde` applies to a hand-written `ssh-bare` target.
643fn default_ssh_workspace_prefix() -> PathBuf {
644    PathBuf::from(".local/share/hel/workspaces")
645}
646
647/// The first free id at or after `base_id`, so building a config never drops an
648/// entry by inserting over one that is already there.
649fn 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
677/// Ask the setup questions, write the configuration, and report on it.
678///
679/// The smoke test and the closing doctor report run through different
680/// executors on purpose: a smoke test may pull a multi-gigabyte image and must
681/// not be given a deadline, while every prerequisite probe must answer quickly
682/// or be reported as a fixable check.
683pub 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    // Build what the earlier answers already claimed, so the SSH step can
745    // refuse a target name that would replace one of them.
746    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    // A failed smoke test is a fixable prerequisite, not a reason to abandon
774    // the run: the configuration is already written, and this is exactly when
775    // the closing report's remediations matter most.
776    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
862/// Offer an AWS EC2 target, but only when this host already has working AWS
863/// credentials. Without them the step prints one line and asks nothing.
864fn 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, &region_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
932/// Offer an SSH target built from the aliases in `~/.ssh/config`.
933///
934/// With no aliases the step prints one line and asks nothing, the same way the
935/// AWS step reports skipping.
936fn 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
1033/// Ask for the SSH target's name until the answer is a usable target id.
1034///
1035/// Both failures are caught here rather than by `config.validate()` after every
1036/// question has been asked: an invalid id would otherwise discard the whole
1037/// dialog, and a name that is already taken would silently replace the target
1038/// it collides with.
1039fn 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
1070/// Phrase a failed setup smoke test the way `mj doctor --smoke` phrases the
1071/// same failure, so it joins the closing report instead of ending the run.
1072fn 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
1090/// End setup with the same report `mj doctor` prints, so the user gets one
1091/// ready/fixable summary with remediations instead of two different signals.
1092///
1093/// `extra` carries anything setup itself learned that doctor cannot repeat
1094/// without the opt-in smoke test.
1095fn 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
1126/// Read one answer, reporting `None` once the input has ended.
1127///
1128/// Every question but one treats the end of input as an empty answer and takes
1129/// its default. A question that must be asked again until it is answered needs
1130/// the difference, or it would loop forever against a closed stdin.
1131fn 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        // yes, host 1, Docker, default image, and the default target name.
1788        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        // yes, host 1, podman (default), default image and name.
1829        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        // yes, typed host, default guardian permissions, no podman, custom name.
1861        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        // yes, host 1, no podman, then: a name that is already taken, a name
1902        // that is not a usable id, and finally a free one.
1903        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        // yes, host 1, no podman, then nothing: the default name collides, so
1931        // the question can never be answered.
1932        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        // Create the container, fail the command inside it, remove it.
2159        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        // The report the user was promised still runs, and still ends with the
2185        // instruction to apply the remediations it just listed.
2186        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        // The report is doctor's own rendering: a status-prefixed line per
2228        // check, plus the remediation doctor would print for the missing home.
2229        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        // No smoke test runs without a runtime; the trailing commands belong to
2289        // the doctor report.
2290        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}