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