Skip to main content

mj_controller/
hel_setup.rs

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