Skip to main content

mj_controller/
setup.rs

1//! Additive first-run discovery shared by the dashboard and `mj setup`.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use crate::doctor::{
7    ApplePlatform, CheckStatus, apple_container_runtime_check, current_apple_platform,
8    local_docker_runtime_check, local_podman_runtime_check,
9};
10use crate::targets::{CommandExecutor, CommandSpec};
11use mj_core::config::{
12    Config, ContainerTemplate, HarnessHost, HarnessKind, HarnessProfile, ProjectBundle,
13    ProjectRepository, TargetTemplate, unique_config_id as unique_id,
14};
15
16mod startup;
17pub use startup::{
18    SetupReport, actionable_errors, add_detected_profiles, run_setup, run_setup_command,
19};
20
21// Published from containers/Containerfile.agent-dev by
22// .github/workflows/publish-agent-dev-image.yml. It already carries Node, Rust,
23// Git, gh, and the pinned ACP bridges, so a first session does not have to
24// install them.
25pub use mj_client::target::DEFAULT_IMAGE;
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct DiscoveredHome {
29    pub kind: HarnessKind,
30    pub path: PathBuf,
31    pub authenticated: bool,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct GithubRepository {
36    pub owner: String,
37    pub repository: String,
38}
39
40impl GithubRepository {
41    fn source(&self) -> String {
42        format!("{}/{}", self.owner, self.repository)
43    }
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum RuntimeKind {
48    Podman,
49    Docker,
50    AppleContainer,
51}
52
53impl RuntimeKind {
54    pub fn label(self) -> &'static str {
55        match self {
56            Self::Podman => "Podman",
57            Self::Docker => "Docker",
58            Self::AppleContainer => "Apple container",
59        }
60    }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq)]
64pub struct RuntimeProbe {
65    pub kind: RuntimeKind,
66    pub status: CheckStatus,
67    pub detail: String,
68    /// The fix `mj doctor` would print for this runtime, carried through so
69    /// setup never invents its own remediation wording.
70    pub remediation: Option<String>,
71}
72
73impl RuntimeProbe {
74    pub fn usable(&self) -> bool {
75        self.status == CheckStatus::Ready
76    }
77}
78
79/// The installed agent homes alone. Callers that only add profiles use this
80/// without probing unrelated runtimes or remote targets.
81pub fn discover_profiles(executor: &impl CommandExecutor) -> Vec<DiscoveredHome> {
82    let home = dirs::home_dir();
83    let overrides = HarnessKind::ALL
84        .into_iter()
85        .filter_map(|kind| {
86            std::env::var_os(kind.home_env()).map(|path| (kind, kind.home_from_environment(path)))
87        })
88        .collect::<BTreeMap<_, _>>();
89    let mut homes =
90        discover_harness_homes_with_executor(home.as_deref(), overrides.clone(), executor);
91    discover_installed_harnesses(home.as_deref(), &overrides, &mut homes, executor);
92    homes
93}
94
95/// The container runtimes on this machine alone, usable or not, so a caller
96/// can report why an unusable one was skipped.
97pub fn discover_runtimes(executor: &impl CommandExecutor) -> Vec<RuntimeProbe> {
98    probe_local_runtimes(executor, &current_apple_platform(executor))
99}
100
101/// A configuration holding the discovered profiles and nothing else, for
102/// merging into an existing configuration.
103pub fn profiles_config(homes: &[DiscoveredHome]) -> Config {
104    build_config(homes, None)
105}
106
107/// Read the concrete `Host` aliases from `~/.ssh/config`.
108///
109/// This is a pure read: setup never runs `ssh` while discovering. `Include`
110/// directives are deliberately not followed, because resolving them correctly
111/// means reimplementing OpenSSH's glob and relative-path rules; aliases that
112/// live in an included file simply are not offered, and the user can still
113/// type a host by hand.
114pub fn discover_ssh_hosts(home: Option<&Path>) -> Vec<String> {
115    let Some(home) = home else {
116        return Vec::new();
117    };
118    let Ok(contents) = std::fs::read_to_string(home.join(".ssh").join("config")) else {
119        return Vec::new();
120    };
121    ssh_config_aliases(&contents)
122}
123
124/// Extract the usable `Host` aliases from SSH config text.
125///
126/// Pattern entries (`*`, `?`, `!`) are skipped: they configure other hosts
127/// rather than naming one Hel could connect to.
128pub fn ssh_config_aliases(contents: &str) -> Vec<String> {
129    let mut aliases: Vec<String> = Vec::new();
130    for line in contents.lines() {
131        let line = line.trim();
132        if line.is_empty() || line.starts_with('#') {
133            continue;
134        }
135        let Some((keyword, rest)) = line.split_once(char::is_whitespace) else {
136            continue;
137        };
138        if !keyword.eq_ignore_ascii_case("host") {
139            continue;
140        }
141        for alias in rest.split_whitespace() {
142            let alias = alias.trim_matches('"');
143            if alias.is_empty() || alias.contains(['*', '?', '!']) {
144                continue;
145            }
146            if !aliases.iter().any(|existing| existing == alias) {
147                aliases.push(alias.to_owned());
148            }
149        }
150    }
151    aliases
152}
153
154/// A newly installed CLI may not create its profile directory until login.
155/// Run these probes through setup's bounded, cancellable executor.
156fn discover_installed_harnesses(
157    user_home: Option<&Path>,
158    overrides: &BTreeMap<HarnessKind, PathBuf>,
159    homes: &mut Vec<DiscoveredHome>,
160    executor: &impl CommandExecutor,
161) {
162    for kind in HarnessKind::ALL {
163        if homes.iter().any(|home| home.kind == kind) {
164            continue;
165        }
166        let Some(home) = overrides
167            .get(&kind)
168            .cloned()
169            .or_else(|| user_home.map(|home| home.join(kind.default_home_leaf())))
170        else {
171            continue;
172        };
173        let probe = CommandSpec::new(kind.cli_binary_name(), ["--version"])
174            .purpose("detect installed harness before first login");
175        match executor.execute(&probe) {
176            Ok(output) if output.status == 0 => homes.push(DiscoveredHome {
177                kind,
178                path: home,
179                authenticated: false,
180            }),
181            Ok(_) => {}
182            Err(error) => tracing::debug!(
183                harness = kind.id(),
184                "installation probe unavailable: {error:#}"
185            ),
186        }
187    }
188}
189
190pub(crate) fn discover_harness_homes_with_executor(
191    home: Option<&Path>,
192    overrides: impl IntoIterator<Item = (HarnessKind, PathBuf)>,
193    executor: &impl CommandExecutor,
194) -> Vec<DiscoveredHome> {
195    let mut candidates = Vec::new();
196    if let Some(home) = home {
197        candidates.extend(
198            HarnessKind::ALL
199                .into_iter()
200                .map(|kind| (kind, home.join(kind.default_home_leaf()), true)),
201        );
202    }
203    candidates.extend(
204        overrides
205            .into_iter()
206            .map(|(kind, path)| (kind, path, false)),
207    );
208
209    let mut seen = BTreeSet::new();
210    candidates
211        .into_iter()
212        .filter(|(kind, path, _)| seen.insert((*kind, path.clone())) && path.is_dir())
213        .map(|(kind, path, is_default_home)| DiscoveredHome {
214            authenticated: harness_is_authenticated_with(
215                &probe_profile(kind, &path),
216                is_default_home,
217                executor,
218            ),
219            kind,
220            path,
221        })
222        .collect()
223}
224
225/// A profile standing in for a home discovery found but the user has not
226/// configured. It carries no environment, so cloud backends must be configured
227/// explicitly before their authentication can be recognized.
228fn probe_profile(kind: HarnessKind, home: &Path) -> HarnessProfile {
229    HarnessProfile {
230        enabled: true,
231        kind,
232        home: home.to_path_buf(),
233        environment: Default::default(),
234        context_window_bytes: None,
235        legacy_subagents: Default::default(),
236        guardian_review_model: None,
237    }
238}
239
240pub(crate) fn harness_is_authenticated_with_executor(
241    profile: &HarnessProfile,
242    executor: &impl CommandExecutor,
243) -> bool {
244    let is_default_home = dirs::home_dir()
245        .is_some_and(|user_home| profile.home == user_home.join(profile.kind.default_home_leaf()));
246    harness_is_authenticated_with(profile, is_default_home, executor)
247}
248
249/// Whether this profile can talk to its service without a login first.
250///
251/// An API-key profile is proven by its harness configuration file, because its
252/// key lives in the profile's `environment` rather than in a credential file;
253/// [`HarnessProfile::authentication_marker`] already names the right file for
254/// either case.
255fn harness_is_authenticated_with(
256    profile: &HarnessProfile,
257    is_default_home: bool,
258    executor: &impl CommandExecutor,
259) -> bool {
260    let kind = profile.kind;
261    let home = profile.home.as_path();
262    // AWS credentials can come from a role or an external credential process;
263    // there is no harness login file. Doctor probes the chain separately.
264    if profile.auth_scheme() == mj_core::config::AuthScheme::AwsCredentialChain {
265        return true;
266    }
267    if profile.authentication_marker().is_file()
268        || (kind == HarnessKind::Kimi && home.join("credentials").is_file())
269    {
270        return true;
271    }
272    if kind != HarnessKind::Claude {
273        return false;
274    }
275    // Where the login does not live in the home, every Claude profile shares
276    // the one Keychain item, so asking the CLI about a scoped home would only
277    // report the default profile's state again.
278    if is_default_home || !kind.keeps_login_in_home(HarnessHost::current()) {
279        return claude_keychain_reports_authenticated(executor);
280    }
281    claude_cli_reports_authenticated(home, executor)
282}
283
284/// Ask Claude Code about a scoped profile. Setting `CLAUDE_CONFIG_DIR` for the
285/// default home changes Claude's profile selection, so the default macOS
286/// profile is checked directly in the Keychain instead.
287fn claude_cli_reports_authenticated(home: &Path, executor: &impl CommandExecutor) -> bool {
288    let mut command = CommandSpec::new("claude", ["auth", "status", "--json"])
289        .purpose("check Claude Code authentication");
290    command.env.insert(
291        HarnessKind::Claude.home_env().to_owned(),
292        home.to_string_lossy().into_owned(),
293    );
294    let Ok(output) = executor.execute(&command) else {
295        return false;
296    };
297    if output.status != 0 {
298        return false;
299    }
300    serde_json::from_slice::<serde_json::Value>(&output.stdout)
301        .ok()
302        .and_then(|status| status.get("loggedIn").and_then(serde_json::Value::as_bool))
303        == Some(true)
304}
305
306/// Mjolnir and Claude Code use this service for the default macOS profile.
307/// `security` is already authorized for the item, so this does not raise a
308/// Keychain prompt; the shared executor still bounds a wedged lookup.
309#[cfg(target_os = "macos")]
310fn claude_keychain_reports_authenticated(executor: &impl CommandExecutor) -> bool {
311    let command = CommandSpec::new(
312        "security",
313        [
314            "find-generic-password",
315            "-s",
316            "Claude Code-credentials",
317            "-w",
318        ],
319    )
320    .purpose("check Claude Code authentication in the macOS Keychain");
321    let Ok(output) = executor.execute(&command) else {
322        return false;
323    };
324    output.status == 0 && claude_credentials_contain_login(&output.stdout)
325}
326
327#[cfg(not(target_os = "macos"))]
328fn claude_keychain_reports_authenticated(_executor: &impl CommandExecutor) -> bool {
329    false
330}
331
332#[cfg(any(target_os = "macos", test))]
333fn claude_credentials_contain_login(credentials: &[u8]) -> bool {
334    let Ok(document) = serde_json::from_slice::<serde_json::Value>(credentials) else {
335        return false;
336    };
337    [
338        "/claudeAiOauth/accessToken",
339        "/claudeAiOauth/refreshToken",
340        "/oauth/accessToken",
341        "/apiKey",
342    ]
343    .into_iter()
344    .any(|pointer| {
345        document
346            .pointer(pointer)
347            .and_then(serde_json::Value::as_str)
348            .is_some_and(|value| !value.trim().is_empty())
349    })
350}
351
352pub fn github_repository_from_origin(origin: &str) -> Option<GithubRepository> {
353    let (owner, repository) = mj_core::remote_git::github_owner_repo(origin)?;
354    Some(GithubRepository { owner, repository })
355}
356
357/// Read the current directory's GitHub origin, through the same executor every
358/// other discovery probe uses so it is bounded and can be faked in tests.
359///
360/// `git -C` selects the directory instead of a working-directory field on the
361/// command, which no executor carries.
362fn discover_github_repository(
363    executor: &impl CommandExecutor,
364    cwd: &Path,
365) -> Option<GithubRepository> {
366    let command = CommandSpec::new(
367        "git",
368        [
369            "-C".to_owned(),
370            cwd.to_string_lossy().into_owned(),
371            "remote".to_owned(),
372            "get-url".to_owned(),
373            "origin".to_owned(),
374        ],
375    )
376    .purpose("detect the current repository's GitHub origin");
377    let output = executor.execute(&command).ok()?;
378    if output.status != 0 {
379        return None;
380    }
381    github_repository_from_origin(&String::from_utf8_lossy(&output.stdout))
382}
383
384/// Probe the container runtimes setup can configure, reusing the doctor checks
385/// so an unavailable runtime carries doctor's detail and remediation.
386pub fn probe_local_runtimes(
387    executor: &impl CommandExecutor,
388    platform: &ApplePlatform,
389) -> Vec<RuntimeProbe> {
390    let mut probes = vec![
391        runtime_probe_from_check(
392            RuntimeKind::Podman,
393            local_podman_runtime_check(executor, platform),
394        ),
395        runtime_probe_from_check(RuntimeKind::Docker, local_docker_runtime_check(executor)),
396    ];
397    if matches!(platform, ApplePlatform::Macos { .. }) {
398        probes.push(runtime_probe_from_check(
399            RuntimeKind::AppleContainer,
400            apple_container_runtime_check(platform, executor),
401        ));
402    }
403    probes
404}
405
406fn runtime_probe_from_check(kind: RuntimeKind, check: crate::doctor::DoctorCheck) -> RuntimeProbe {
407    RuntimeProbe {
408        kind,
409        status: check.status,
410        detail: check.detail,
411        remediation: check.remediation,
412    }
413}
414
415/// Configuration additions for installed profiles and the current repository.
416pub fn build_config(homes: &[DiscoveredHome], repository: Option<&GithubRepository>) -> Config {
417    let mut config = Config::default();
418    for home in homes {
419        let id = unique_id(&config.profiles, home.kind.id());
420        config.profiles.insert(
421            id,
422            HarnessProfile {
423                enabled: true,
424                kind: home.kind,
425                home: home.path.clone(),
426                environment: Default::default(),
427                context_window_bytes: None,
428                legacy_subagents: Default::default(),
429                guardian_review_model: None,
430            },
431        );
432    }
433
434    if let Some(repository) = repository {
435        let repository_id = config_id(&repository.repository);
436        config.bundles.insert(
437            "current-repository".to_owned(),
438            ProjectBundle {
439                primary_repo: repository_id.clone(),
440                repositories: vec![ProjectRepository {
441                    id: repository_id.clone(),
442                    github: Some(repository.source()),
443                    local: None,
444                    destination: PathBuf::from(repository_id),
445                    git_ref: None,
446                }],
447            },
448        );
449    }
450
451    #[cfg(unix)]
452    config
453        .targets
454        .insert("localhost".to_owned(), TargetTemplate::LocalBare);
455    config
456}
457
458/// The shared setup/startup template for a locally available container engine.
459pub fn local_runtime_target(runtime: RuntimeKind, image: &str) -> (&'static str, TargetTemplate) {
460    let container = ContainerTemplate {
461        build_cache: None,
462        image: image.trim().to_owned(),
463        pull_policy: Default::default(),
464        platform: None,
465        cpus: None,
466        memory: None,
467        environment: Default::default(),
468        workspace_storage: Default::default(),
469    };
470    match runtime {
471        RuntimeKind::Podman => ("podman", TargetTemplate::LocalPodman { container }),
472        RuntimeKind::Docker => ("docker", TargetTemplate::LocalDocker { container }),
473        RuntimeKind::AppleContainer => (
474            "apple-container",
475            TargetTemplate::AppleContainer { container },
476        ),
477    }
478}
479
480fn config_id(value: &str) -> String {
481    let mut id = value
482        .chars()
483        .filter(|character| {
484            character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
485        })
486        .take(64)
487        .collect::<String>();
488    if id.is_empty() || matches!(id.as_str(), "." | "..") {
489        id = "repository".to_owned();
490    }
491    id
492}
493
494#[cfg(test)]
495mod tests;