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