Skip to main content

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