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