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