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